diff --git a/.core_files.yaml b/.core_files.yaml index 118346408f8fa1..b1870654be06f2 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -21,11 +21,13 @@ base_platforms: &base_platforms - homeassistant/components/climate/** - homeassistant/components/cover/** - homeassistant/components/date/** + - homeassistant/components/datetime/** - homeassistant/components/device_tracker/** - homeassistant/components/diagnostics/** - 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 792e0c3785ca5f..0d44a63633ae43 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 @@ -181,7 +182,6 @@ omit = homeassistant/components/crownstone/listeners.py homeassistant/components/cups/sensor.py homeassistant/components/currencylayer/sensor.py - homeassistant/components/daikin/__init__.py homeassistant/components/daikin/climate.py homeassistant/components/daikin/sensor.py homeassistant/components/daikin/switch.py @@ -202,6 +202,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 @@ -227,6 +230,7 @@ omit = homeassistant/components/dunehd/__init__.py homeassistant/components/dunehd/media_player.py homeassistant/components/dwd_weather_warnings/const.py + homeassistant/components/dwd_weather_warnings/coordinator.py homeassistant/components/dwd_weather_warnings/sensor.py homeassistant/components/dweet/* homeassistant/components/ebox/sensor.py @@ -300,23 +304,10 @@ omit = homeassistant/components/escea/__init__.py 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/esphome/manager.py homeassistant/components/etherscan/sensor.py homeassistant/components/eufy/* homeassistant/components/eufylife_ble/__init__.py @@ -326,7 +317,9 @@ 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 homeassistant/components/ezviz/sensor.py homeassistant/components/ezviz/switch.py @@ -360,10 +353,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 @@ -418,7 +414,6 @@ omit = homeassistant/components/gitlab_ci/sensor.py homeassistant/components/gitter/sensor.py homeassistant/components/glances/sensor.py - homeassistant/components/goalfeed/* homeassistant/components/goodwe/__init__.py homeassistant/components/goodwe/button.py homeassistant/components/goodwe/coordinator.py @@ -614,7 +609,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 @@ -656,6 +650,7 @@ omit = homeassistant/components/lookin/light.py homeassistant/components/lookin/media_player.py homeassistant/components/lookin/sensor.py + homeassistant/components/loqed/sensor.py homeassistant/components/luci/device_tracker.py homeassistant/components/luftdaten/sensor.py homeassistant/components/lupusec/* @@ -863,6 +858,9 @@ omit = homeassistant/components/openweathermap/sensor.py homeassistant/components/openweathermap/weather_update_coordinator.py homeassistant/components/opnsense/__init__.py + homeassistant/components/opower/__init__.py + homeassistant/components/opower/coordinator.py + homeassistant/components/opower/sensor.py homeassistant/components/opnsense/device_tracker.py homeassistant/components/opple/light.py homeassistant/components/oru/* @@ -945,6 +943,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 @@ -972,6 +972,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 @@ -1045,12 +1049,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 @@ -1297,6 +1295,7 @@ omit = homeassistant/components/touchline/climate.py homeassistant/components/tplink_lte/* homeassistant/components/tplink_omada/__init__.py + homeassistant/components/tplink_omada/binary_sensor.py homeassistant/components/tplink_omada/controller.py homeassistant/components/tplink_omada/coordinator.py homeassistant/components/tplink_omada/entity.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 237fc2888ab3e9..80291c73e6193c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -59,15 +59,15 @@ body: attributes: label: Integration causing the issue description: > - The name of the integration. For example: Automation, Philips Hue + The name of the integration, for example Automation or Philips Hue. - type: input id: integration_link attributes: label: Link to integration documentation on our website placeholder: "https://www.home-assistant.io/integrations/..." description: | - Providing a link [to the documentation][docs] helps us categorize the - issue, while also providing a useful reference for others. + Providing a link [to the documentation][docs] helps us categorize the issue and might speed up the + investigation by automatically informing a contributor, while also providing a useful reference for others. [docs]: https://www.home-assistant.io/integrations diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 94d4128bc66077..2ee32ca9dbc6e8 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,12 +24,12 @@ 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 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -48,29 +48,18 @@ 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 - needs: init + environment: ${{ needs.init.outputs.channel }} + needs: ["init", "build_base"] runs-on: ubuntu-latest if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - 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.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -100,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' @@ -123,7 +116,7 @@ jobs: uses: dawidd6/action-download-artifact@v2 with: github_token: ${{secrets.GITHUB_TOKEN}} - repo: home-assistant/intents + repo: home-assistant/intents-package branch: main workflow: nightly.yaml workflow_conclusion: success @@ -131,7 +124,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -181,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 @@ -196,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: @@ -236,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: @@ -261,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: | @@ -274,37 +266,33 @@ 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 }} publish_ha: name: Publish version files + environment: ${{ needs.init.outputs.channel }} if: github.repository_owner == 'home-assistant' needs: ["init", "build_machine"] runs-on: ubuntu-latest 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 @@ -332,37 +320,36 @@ jobs: publish_container: name: Publish meta container for ${{ matrix.registry }} + environment: ${{ needs.init.outputs.channel }} if: github.repository_owner == 'home-assistant' needs: ["init", "build_base"] runs-on: ubuntu-latest - 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: | @@ -372,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 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 }}/amd64-homeassistant:${tag_r}" \ - --os linux --arch amd64 + 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 }}/i386-homeassistant:${tag_r}" \ - --os linux --arch 386 + 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 }}/armhf-homeassistant:${tag_r}" \ - --os linux --arch arm --variant=v6 + 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 }}/armv7-homeassistant:${tag_r}" \ - --os linux --arch arm --variant=v7 + docker manifest annotate "${registry}/home-assistant:${tag_l}" \ + "${registry}/armv7-homeassistant:${tag_r}" \ + --os linux --arch arm --variant=v7 - 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}/aarch64-homeassistant:${tag_r}" \ + --os linux --arch arm64 --variant=v8 - docker manifest push --purge "${{ matrix.registry }}/home-assistant:${tag_l}" + 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 }}" - - 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 }}" + function push_dockerhub() { + local image=${1} + local tag=${2} + + 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 583be8f458658a..331a1bc151a5da 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.8 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,10 +206,10 @@ 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.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -251,9 +251,9 @@ 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.0 + uses: actions/setup-python@v4.6.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -297,9 +297,9 @@ 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.0 + uses: actions/setup-python@v4.6.1 id: python with: python-version: ${{ env.DEFAULT_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.0 - 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,9 +346,9 @@ 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.0 + uses: actions/setup-python@v4.6.1 id: python with: python-version: ${{ env.DEFAULT_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,10 +440,10 @@ 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.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -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,10 +508,10 @@ 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.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -591,10 +540,10 @@ 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.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -624,10 +573,10 @@ 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.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -668,10 +617,10 @@ 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.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -732,7 +681,6 @@ jobs: - base - gen-requirements-all - hassfest - - lint-isort - lint-other - lint-ruff - mypy @@ -751,10 +699,10 @@ 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.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -857,7 +805,6 @@ jobs: - base - gen-requirements-all - hassfest - - lint-isort - lint-other - lint-ruff - mypy @@ -877,10 +824,10 @@ 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.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -965,7 +912,6 @@ jobs: - base - gen-requirements-all - hassfest - - lint-isort - lint-other - lint-ruff - mypy @@ -985,10 +931,10 @@ 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.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -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 c0593fa3a9a346..2b5364fa950a98 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 a18c050024b86f..dd1f3d061a99f7 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,10 +19,10 @@ 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.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index c735a446938496..16bd347d7cf471 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,7 +110,7 @@ jobs: requirements-diff: "requirements_diff.txt" requirements: "requirements.txt" - integrations_cp310: + integrations_cp311: name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }} if: github.repository_owner == 'home-assistant' needs: init @@ -121,11 +118,11 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp310"] + 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 @@ -137,144 +134,16 @@ jobs: 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' - needs: init - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - abi: ["cp311"] - arch: ${{ fromJson(needs.init.outputs.architectures) }} - steps: - - name: Checkout the repository - uses: actions/checkout@v3.5.2 - - - name: Write alternative env-file for cp311 - run: | - ( - echo "GRPC_BUILD_WITH_BORING_SSL_ASM=false" - echo "GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=true" - echo "GRPC_PYTHON_BUILD_WITH_CYTHON=true" - echo "GRPC_PYTHON_DISABLE_LIBC_COMPATIBILITY=true" - - # GRPC on armv7 needed -lexecinfo (issue #56669) since home assistant installed - # execinfo-dev when building wheels. However, this package is no longer available - # Alpine 3.17, which we use for the cp311 ABI, so the flag should no longer be needed. - echo "GRPC_PYTHON_LDFLAGS=-lpthread -Wl,-wrap,memcpy -static-libgcc" # -lexecinfo - - # Fix out of memory issues with rust - echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" - - # OpenCV headless installation - echo "CI_BUILD=1" - echo "ENABLE_HEADLESS=1" - - # Use C-Extension for sqlalchemy - echo "REQUIRE_SQLALCHEMY_CEXT=1" - ) > .env_file - - - name: Download requirements_diff - uses: actions/download-artifact@v3 - with: - name: requirements_diff - - name: (Un)comment packages run: | requirement_files="requirements_all.txt requirements_diff.txt" for requirement_file in ${requirement_files}; do - - # PyBluez no longer compiles. Commented it out for now. - # It need further cleanup down the line, as all machine images - # try to install it. - # sed -i "s|# pybluez|pybluez|g" ${requirement_file} - - # beacontools requires PyBluez. - # sed -i "s|# beacontools|beacontools|g" ${requirement_file} - - # 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 8e8fef97697573..c662c6754f4c79 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 + - repo: https://github.com/astral-sh/ruff-pre-commit + 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 fc40996a37bf71..67ebca7aea772f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -49,6 +49,7 @@ homeassistant.components.air_quality.* homeassistant.components.airly.* homeassistant.components.airvisual.* homeassistant.components.airzone.* +homeassistant.components.airzone_cloud.* homeassistant.components.aladdin_connect.* homeassistant.components.alarm_control_panel.* homeassistant.components.alert.* @@ -86,6 +87,7 @@ homeassistant.components.camera.* homeassistant.components.canary.* homeassistant.components.clickatell.* homeassistant.components.clicksend.* +homeassistant.components.cloud.* homeassistant.components.configurator.* homeassistant.components.cover.* homeassistant.components.cpuspeed.* @@ -167,8 +169,10 @@ homeassistant.components.homekit_controller.utils homeassistant.components.homewizard.* homeassistant.components.http.* homeassistant.components.huawei_lte.* +homeassistant.components.hydrawise.* homeassistant.components.hyperion.* homeassistant.components.ibeacon.* +homeassistant.components.image.* homeassistant.components.image_processing.* homeassistant.components.image_upload.* homeassistant.components.imap.* @@ -233,12 +237,14 @@ homeassistant.components.oncue.* homeassistant.components.onewire.* homeassistant.components.open_meteo.* homeassistant.components.openexchangerates.* +homeassistant.components.opensky.* homeassistant.components.openuv.* homeassistant.components.otbr.* homeassistant.components.overkiz.* homeassistant.components.peco.* homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* +homeassistant.components.ping.* homeassistant.components.powerwall.* homeassistant.components.proximity.* homeassistant.components.prusalink.* @@ -271,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.* @@ -304,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 406fa6e4f3cf19..16c0426d87f620 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -59,6 +59,8 @@ build.json @home-assistant/supervisor /tests/components/airvisual_pro/ @bachya /homeassistant/components/airzone/ @Noltari /tests/components/airzone/ @Noltari +/homeassistant/components/airzone_cloud/ @Noltari +/tests/components/airzone_cloud/ @Noltari /homeassistant/components/aladdin_connect/ @mkmer /tests/components/aladdin_connect/ @mkmer /homeassistant/components/alarm_control_panel/ @home-assistant/core @@ -211,6 +213,8 @@ build.json @home-assistant/supervisor /tests/components/color_extractor/ @GenericStudent /homeassistant/components/comfoconnect/ @michaelarnauts /tests/components/comfoconnect/ @michaelarnauts +/homeassistant/components/command_line/ @gjohansson-ST +/tests/components/command_line/ @gjohansson-ST /homeassistant/components/compensation/ @Petro31 /tests/components/compensation/ @Petro31 /homeassistant/components/config/ @home-assistant/core @@ -236,6 +240,8 @@ build.json @home-assistant/supervisor /tests/components/daikin/ @fredrike /homeassistant/components/date/ @home-assistant/core /tests/components/date/ @home-assistant/core +/homeassistant/components/datetime/ @home-assistant/core +/tests/components/datetime/ @home-assistant/core /homeassistant/components/debugpy/ @frenck /tests/components/debugpy/ @frenck /homeassistant/components/deconz/ @Kane610 @@ -269,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 @@ -283,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 @@ -346,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 @@ -442,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 @@ -553,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 @@ -649,6 +661,8 @@ build.json @home-assistant/supervisor /tests/components/lametric/ @robbiet480 @frenck @bachya /homeassistant/components/landisgyr_heat_meter/ @vpathuis /tests/components/landisgyr_heat_meter/ @vpathuis +/homeassistant/components/lastfm/ @joostlek +/tests/components/lastfm/ @joostlek /homeassistant/components/launch_library/ @ludeeus @DurgNomis-drol /tests/components/launch_library/ @ludeeus @DurgNomis-drol /homeassistant/components/laundrify/ @xLarry @@ -689,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 @@ -771,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 @@ -870,6 +887,8 @@ 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 /homeassistant/components/openuv/ @bachya @@ -878,6 +897,8 @@ build.json @home-assistant/supervisor /tests/components/openweathermap/ @fabaff @freekode @nzapponi /homeassistant/components/opnsense/ @mtreinish /tests/components/opnsense/ @mtreinish +/homeassistant/components/opower/ @tronikos +/tests/components/opower/ @tronikos /homeassistant/components/oralb/ @bdraco @Lash-L /tests/components/oralb/ @bdraco @Lash-L /homeassistant/components/oru/ @bvlaicu @@ -952,6 +973,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 @@ -964,8 +987,8 @@ build.json @home-assistant/supervisor /tests/components/radarr/ @tkdrob /homeassistant/components/radio_browser/ @frenck /tests/components/radio_browser/ @frenck -/homeassistant/components/radiotherm/ @bdraco @vinnyfuria -/tests/components/radiotherm/ @bdraco @vinnyfuria +/homeassistant/components/radiotherm/ @vinnyfuria +/tests/components/radiotherm/ @vinnyfuria /homeassistant/components/rainbird/ @konikvranik @allenporter /tests/components/rainbird/ @konikvranik @allenporter /homeassistant/components/raincloud/ @vanstinator @@ -979,8 +1002,8 @@ build.json @home-assistant/supervisor /tests/components/rapt_ble/ @sairon /homeassistant/components/raspberry_pi/ @home-assistant/core /tests/components/raspberry_pi/ @home-assistant/core -/homeassistant/components/rdw/ @frenck -/tests/components/rdw/ @frenck +/homeassistant/components/rdw/ @frenck @joostlek +/tests/components/rdw/ @frenck @joostlek /homeassistant/components/recollect_waste/ @bachya /tests/components/recollect_waste/ @bachya /homeassistant/components/recorder/ @home-assistant/core @@ -990,6 +1013,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 @@ -1059,8 +1084,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 @@ -1103,8 +1126,8 @@ build.json @home-assistant/supervisor /homeassistant/components/sky_hub/ @rogerselwyn /homeassistant/components/skybell/ @tkdrob /tests/components/skybell/ @tkdrob -/homeassistant/components/slack/ @bachya @tkdrob -/tests/components/slack/ @bachya @tkdrob +/homeassistant/components/slack/ @tkdrob +/tests/components/slack/ @tkdrob /homeassistant/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/slide/ @ualex73 @@ -1209,8 +1232,8 @@ build.json @home-assistant/supervisor /homeassistant/components/synology_srm/ @aerialls /homeassistant/components/system_bridge/ @timmo001 /tests/components/system_bridge/ @timmo001 -/homeassistant/components/tado/ @michaelarnauts -/tests/components/tado/ @michaelarnauts +/homeassistant/components/tado/ @michaelarnauts @chiefdragon +/tests/components/tado/ @michaelarnauts @chiefdragon /homeassistant/components/tag/ @balloob @dmulcahey /tests/components/tag/ @balloob @dmulcahey /homeassistant/components/tailscale/ @frenck @@ -1283,6 +1306,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 @@ -1413,6 +1438,8 @@ build.json @home-assistant/supervisor /tests/components/yolink/ @matrixd2 /homeassistant/components/youless/ @gjong /tests/components/youless/ @gjong +/homeassistant/components/youtube/ @joostlek +/tests/components/youtube/ @joostlek /homeassistant/components/zamg/ @killer0071234 /tests/components/zamg/ @killer0071234 /homeassistant/components/zengge/ @emontnemery diff --git a/Dockerfile.dev b/Dockerfile.dev index de49bb77f12758..857ccfa3997e72 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 084949dc44e707..0dc98a379a317f 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 11b60a662952df..a181e9d154872b 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.05.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.05.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.05.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.05.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.05.0 + 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/docs/screenshot-integrations.png b/docs/screenshot-integrations.png index bc304f11b160f2..f169e4486a6547 100644 Binary files a/docs/screenshot-integrations.png and b/docs/screenshot-integrations.png differ diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py index f63d6d465f6a3f..bfe8a2fdddb5c4 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -13,8 +13,8 @@ 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 050a0660a6b63b..6f621b93a6af73 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -16,8 +16,8 @@ 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 47626686f9dff4..f7f01e74c270fb 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -11,8 +11,8 @@ 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 72ba3b1ecb3e40..0cadbf0758936c 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -15,8 +15,8 @@ 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 a6c4c19b02fc12..6962671cb2fde9 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -23,9 +23,9 @@ 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 @@ -46,7 +46,7 @@ [ vol.Or( cv.uuid4_hex, - vol.Schema({vol.Required(CONF_GROUP): cv.uuid4_hex}), + vol.Schema({vol.Required(CONF_GROUP): str}), ) ], ) diff --git a/homeassistant/backports/functools.py b/homeassistant/backports/functools.py new file mode 100644 index 00000000000000..c7ab0d08693921 --- /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/bootstrap.py b/homeassistant/bootstrap.py index 46bd4b5d881eb7..6a667884962076 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -19,6 +19,7 @@ from . import config as conf_util, config_entries, core, loader from .components import http from .const import ( + FORMAT_DATETIME, REQUIRED_NEXT_PYTHON_HA_RELEASE, REQUIRED_NEXT_PYTHON_VER, SIGNAL_BOOTSTRAP_INTEGRATIONS, @@ -31,6 +32,7 @@ entity_registry, issue_registry, recorder, + restore_state, template, ) from .helpers.dispatcher import async_dispatcher_send @@ -247,6 +249,7 @@ def _cache_uname_processor() -> None: issue_registry.async_load(hass), hass.async_add_executor_job(_cache_uname_processor), template.async_load_custom_templates(hass), + restore_state.async_load(hass), ) @@ -347,7 +350,6 @@ def async_enable_logging( fmt = ( "%(asctime)s.%(msecs)03d %(levelname)s (%(threadName)s) [%(name)s] %(message)s" ) - datefmt = "%Y-%m-%d %H:%M:%S" if not log_no_color: try: @@ -362,7 +364,7 @@ def async_enable_logging( logging.getLogger().handlers[0].setFormatter( ColoredFormatter( colorfmt, - datefmt=datefmt, + datefmt=FORMAT_DATETIME, reset=True, log_colors={ "DEBUG": "cyan", @@ -378,7 +380,7 @@ def async_enable_logging( # If the above initialization failed for any reason, setup the default # formatting. If the above succeeds, this will result in a no-op. - logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.INFO) + logging.basicConfig(format=fmt, datefmt=FORMAT_DATETIME, level=logging.INFO) # Capture warnings.warn(...) and friends messages in logs. # The standard destination for them is stderr, which may end up unnoticed. @@ -389,6 +391,7 @@ def async_enable_logging( logging.getLogger("requests").setLevel(logging.WARNING) logging.getLogger("urllib3").setLevel(logging.WARNING) logging.getLogger("aiohttp.access").setLevel(logging.WARNING) + logging.getLogger("httpx").setLevel(logging.WARNING) sys.excepthook = lambda *args: logging.getLogger(None).exception( "Uncaught exception", exc_info=args # type: ignore[arg-type] @@ -435,7 +438,7 @@ def async_enable_logging( _LOGGER.error("Error rolling over log file: %s", err) err_handler.setLevel(logging.INFO if verbose else logging.WARNING) - err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt)) + err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) logger = logging.getLogger("") logger.addHandler(err_handler) diff --git a/homeassistant/brands/airzone.json b/homeassistant/brands/airzone.json new file mode 100644 index 00000000000000..b41d1cb2e1cdef --- /dev/null +++ b/homeassistant/brands/airzone.json @@ -0,0 +1,5 @@ +{ + "domain": "airzone", + "name": "Airzone", + "integrations": ["airzone", "airzone_cloud"] +} diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 3eb2e9e64f014a..ce71457a656d1b 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -17,6 +17,7 @@ "google", "nest", "cast", - "dialogflow" + "dialogflow", + "youtube" ] } diff --git a/homeassistant/brands/yale.json b/homeassistant/brands/yale.json index 87c119fdd40ff7..53dc9b43569746 100644 --- a/homeassistant/brands/yale.json +++ b/homeassistant/brands/yale.json @@ -1,5 +1,5 @@ { "domain": "yale", "name": "Yale", - "integrations": ["august", "yale_smart_alarm", "yalexs_ble"] + "integrations": ["august", "yale_smart_alarm", "yalexs_ble", "yale_home"] } diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py index 2546f7629122dc..66a2e3b0db59e9 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 402b636e5d6124..a10dbc8e664839 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 17d7b820d45307..afe017bfcc714f 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 507b1284362f61..d504040ee90297 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 be69897431fb34..539b89a5546898 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 039b24230998b7..c110b3fd558fff 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 87a9f8e9a27401..1e238783221b63 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -12,6 +12,7 @@ SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import LIGHT_LUX from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -21,17 +22,14 @@ 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, ), ) @@ -71,7 +69,7 @@ def __init__( elif description.key == CONST.HUMI_STATUS_KEY: self._attr_native_unit_of_measurement = device.humidity_unit elif description.key == CONST.LUX_STATUS_KEY: - self._attr_native_unit_of_measurement = device.lux_unit + self._attr_native_unit_of_measurement = LIGHT_LUX @property def native_value(self) -> float | None: diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py index ab83e3a20c1c3a..14bdf4e0caf784 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 6107285e3760b7..20cb12179eeb3e 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -4,10 +4,13 @@ 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.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 @@ def condition(self) -> str | None: 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 @@ def forecast(self) -> list[Forecast] | None: 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 138587bbad3210..2fc106f75f5b22 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 @@ def device_id(self) -> str: """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 15a20cf693272b..2af985033b6c58 100644 --- a/homeassistant/components/acmeda/cover.py +++ b/homeassistant/components/acmeda/cover.py @@ -45,7 +45,9 @@ def async_add_acmeda_covers(): 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 f92d9fcf57b8ad..e8ccb30ada431c 100644 --- a/homeassistant/components/acmeda/sensor.py +++ b/homeassistant/components/acmeda/sensor.py @@ -40,16 +40,11 @@ def async_add_acmeda_sensors(): 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 cc15872dafa098..0db6a3615f6adf 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 @@ def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None 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 f24aa20d28d0c9..9f1c0a5b0fe07b 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 e593d4199a40dd..bde73e82b37b37 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 a359bf86c2d25d..1020e8690f10a5 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 76d73f75a8b330..17aede2bd2bb06 100644 --- a/homeassistant/components/ads/sensor.py +++ b/homeassistant/components/ads/sensor.py @@ -10,6 +10,7 @@ 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 @@ 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 a13fa95f6ba750..fa9f609ba10605 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -90,6 +90,17 @@ 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, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + ] + + _attr_supported_features = ClimateEntityFeature.FAN_MODE def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize an AdvantageAir AC unit.""" @@ -98,36 +109,14 @@ def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: # Set supported features and HVAC modes based on current operating mode if self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED): # MyAuto - self._attr_supported_features = ( - ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_supported_features |= ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) - self._attr_hvac_modes = [ - HVACMode.OFF, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.FAN_ONLY, - HVACMode.DRY, - HVACMode.HEAT_COOL, - ] - elif self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED): - # MyTemp - self._attr_supported_features = ClimateEntityFeature.FAN_MODE - self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT] - - else: + self._attr_hvac_modes += [HVACMode.HEAT_COOL] + elif not self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED): # MyZone - self._attr_supported_features = ( - ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE - ) - self._attr_hvac_modes = [ - HVACMode.OFF, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.FAN_ONLY, - HVACMode.DRY, - ] + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE # Add "ezfan" mode if supported if self._ac.get(ADVANTAGE_AIR_AUTOFAN): diff --git a/homeassistant/components/advantage_air/diagnostics.py b/homeassistant/components/advantage_air/diagnostics.py index 27eaef09b43b6a..4c440610838f6a 100644 --- a/homeassistant/components/advantage_air/diagnostics.py +++ b/homeassistant/components/advantage_air/diagnostics.py @@ -9,17 +9,30 @@ 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 bbc8738c4ae7a4..9e4f92e8c98baa 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -84,6 +84,8 @@ def _zone(self) -> dict[str, Any]: 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 13a77d5cab352f..7815354dd928f4 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 ed9d3bff9891be..a07d14896eb062 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 53e15c651a7b73..cfbe7b98883699 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -80,7 +80,6 @@ class AirlySensorEntityDescription(SensorEntityDescription): 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 @@ class AirlySensorEntityDescription(SensorEntityDescription): 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 @@ class AirlySensorEntityDescription(SensorEntityDescription): 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 @@ class AirlySensorEntityDescription(SensorEntityDescription): 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 @@ class AirlySensorEntityDescription(SensorEntityDescription): 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 @@ class AirlySensorEntityDescription(SensorEntityDescription): 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 @@ class AirlySensorEntityDescription(SensorEntityDescription): 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 @@ class AirlySensorEntityDescription(SensorEntityDescription): 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 @@ class AirlySensorEntityDescription(SensorEntityDescription): 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 50ebdd6d4dd0d9..7ec58ccd8e51be 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 67a9289efc540f..34b1f4392bc557 100644 --- a/homeassistant/components/airnow/const.py +++ b/homeassistant/components/airnow/const.py @@ -17,5 +17,3 @@ 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 decec74ee47fc4..f3d29cc65df444 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 @@ 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 @@ 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 0e86c4531dc64b..aed12596176cc6 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 7f0d51fcaa87c2..9974307b4cd3ad 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), AirQEntityDescription( key="humidity", - name="Humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -204,7 +202,7 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), AirQEntityDescription( key="pressure", - name="Pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, @@ -330,7 +320,7 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), AirQEntityDescription( key="temperature", - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -399,21 +387,22 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) ), 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 3618d9d517e092..8628ede4116365 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 6e30048d844dab..da7f30679c63a9 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 5212ff51fe8820..9c9859306ca2a2 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -35,62 +35,56 @@ "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 @@ 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 @@ def __init__( 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 af1200baa58cd0..610891fff10439 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 b6c8c25491b643..98190df6b8dc50 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -39,26 +39,26 @@ 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 @@ 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 @@ 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 @@ 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 1cfc4ccd592ea5..b1159e6f251255 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 18183eee197a86..0ba99c0984a575 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 fda007cdc823e4..74a564fa2de1c9 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -131,8 +131,6 @@ def __init__( self._attr_unique_id = f"{self._attr_unique_id}_{system_zone_id}" self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE self._attr_target_temperature_step = API_TEMPERATURE_STEP - self._attr_max_temp = self.get_airzone_value(AZD_TEMP_MAX) - self._attr_min_temp = self.get_airzone_value(AZD_TEMP_MIN) self._attr_temperature_unit = TEMP_UNIT_LIB_TO_HASS[ self.get_airzone_value(AZD_TEMP_UNIT) ] @@ -195,6 +193,8 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: 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 @@ -204,12 +204,13 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: 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 = {} @@ -240,6 +241,8 @@ def _async_update_attrs(self) -> None: ] else: self._attr_hvac_mode = HVACMode.OFF + self._attr_max_temp = self.get_airzone_value(AZD_TEMP_MAX) + self._attr_min_temp = self.get_airzone_value(AZD_TEMP_MIN) self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) if self.supported_features & ClimateEntityFeature.FAN_MODE: self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index a05b8cd6181a1c..9ee923ba1af9c2 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -7,6 +7,7 @@ from aioairzone.const import ( API_SYSTEM_ID, API_ZONE_ID, + AZD_AVAILABLE, AZD_FIRMWARE, AZD_FULL_NAME, AZD_ID, @@ -66,6 +67,11 @@ def __init__( ) self._attr_unique_id = entry.unique_id or entry.entry_id + @property + def available(self) -> bool: + """Return system availability.""" + return super().available and self.get_airzone_value(AZD_AVAILABLE) + def get_airzone_value(self, key: str) -> Any: """Return system value by key.""" value = None @@ -130,6 +136,11 @@ def __init__( ) self._attr_unique_id = entry.unique_id or entry.entry_id + @property + def available(self) -> bool: + """Return zone availability.""" + return super().available and self.get_airzone_value(AZD_AVAILABLE) + def get_airzone_value(self, key: str) -> Any: """Return zone value by key.""" value = None diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index d55ffd187fd881..88b918f699c836 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.5.6"] + "requirements": ["aioairzone==0.6.4"] } diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index 023015121d7205..1a0d577bb35f7a 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -1,7 +1,7 @@ """Support for the Airzone sensors.""" from __future__ import annotations -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import Any, Final from aioairzone.common import GrilleAngle, SleepTimeout @@ -41,14 +41,14 @@ class AirzoneSelectDescription(SelectEntityDescription, AirzoneSelectDescription GRILLE_ANGLE_DICT: Final[dict[str, int]] = { - "90º": GrilleAngle.DEG_90, - "50º": GrilleAngle.DEG_50, - "45º": GrilleAngle.DEG_45, - "40º": GrilleAngle.DEG_40, + "90deg": GrilleAngle.DEG_90, + "50deg": GrilleAngle.DEG_50, + "45deg": GrilleAngle.DEG_45, + "40deg": GrilleAngle.DEG_40, } SLEEP_DICT: Final[dict[str, int]] = { - "Off": SleepTimeout.SLEEP_OFF, + "off": SleepTimeout.SLEEP_OFF, "30m": SleepTimeout.SLEEP_30, "60m": SleepTimeout.SLEEP_60, "90m": SleepTimeout.SLEEP_90, @@ -61,21 +61,27 @@ class AirzoneSelectDescription(SelectEntityDescription, AirzoneSelectDescription entity_category=EntityCategory.CONFIG, key=AZD_COLD_ANGLE, name="Cold Angle", + options=list(GRILLE_ANGLE_DICT), options_dict=GRILLE_ANGLE_DICT, + translation_key="grille_angles", ), AirzoneSelectDescription( api_param=API_HEAT_ANGLE, entity_category=EntityCategory.CONFIG, key=AZD_HEAT_ANGLE, name="Heat Angle", + options=list(GRILLE_ANGLE_DICT), options_dict=GRILLE_ANGLE_DICT, + translation_key="grille_angles", ), AirzoneSelectDescription( api_param=API_SLEEP, entity_category=EntityCategory.CONFIG, key=AZD_SLEEP, name="Sleep", + options=list(SLEEP_DICT), options_dict=SLEEP_DICT, + translation_key="sleep_times", ), ) @@ -91,14 +97,10 @@ async def async_setup_entry( for system_zone_id, zone_data in coordinator.data[AZD_ZONES].items(): for description in ZONE_SELECT_TYPES: if description.key in zone_data: - _desc = replace( - description, - options=list(description.options_dict.keys()), - ) entities.append( AirzoneZoneSelect( coordinator, - _desc, + description, entry, system_zone_id, zone_data, diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json index 306e63da36c0d0..037ebe52d782bf 100644 --- a/homeassistant/components/airzone/strings.json +++ b/homeassistant/components/airzone/strings.json @@ -23,5 +23,25 @@ } } } + }, + "entity": { + "select": { + "grille_angles": { + "state": { + "90deg": "90°", + "50deg": "50°", + "45deg": "45°", + "40deg": "40°" + } + }, + "sleep_times": { + "state": { + "off": "[%key:common::state::off%]", + "30m": "30 minutes", + "60m": "60 minutes", + "90m": "90 minutes" + } + } + } } } diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py new file mode 100644 index 00000000000000..732f159c38178f --- /dev/null +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -0,0 +1,51 @@ +"""The Airzone Cloud integration.""" +from __future__ import annotations + +from aioairzone_cloud.cloudapi import AirzoneCloudApi +from aioairzone_cloud.common import ConnectionOptions + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator + +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Airzone Cloud from a config entry.""" + options = ConnectionOptions( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) + + airzone = AirzoneCloudApi(aiohttp_client.async_get_clientsession(hass), options) + await airzone.login() + inst_list = await airzone.list_installations() + for inst in inst_list: + if inst.get_id() == entry.data[CONF_ID]: + airzone.select_installation(inst) + await airzone.update_installation(inst) + + coordinator = AirzoneUpdateCoordinator(hass, airzone) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py new file mode 100644 index 00000000000000..29b550463d0f82 --- /dev/null +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -0,0 +1,110 @@ +"""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_ACTIVE, 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( + device_class=BinarySensorDeviceClass.RUNNING, + key=AZD_ACTIVE, + ), + 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/config_flow.py b/homeassistant/components/airzone_cloud/config_flow.py new file mode 100644 index 00000000000000..32274d4e8efc18 --- /dev/null +++ b/homeassistant/components/airzone_cloud/config_flow.py @@ -0,0 +1,116 @@ +"""Config flow for Airzone Cloud.""" +from __future__ import annotations + +from typing import Any + +from aioairzone_cloud.cloudapi import AirzoneCloudApi +from aioairzone_cloud.common import ConnectionOptions +from aioairzone_cloud.const import AZD_ID, AZD_NAME, AZD_WEBSERVERS +from aioairzone_cloud.exceptions import AirzoneCloudError, LoginError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import DOMAIN + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle config flow for an Airzone Cloud device.""" + + airzone: AirzoneCloudApi + + async def async_step_inst_pick( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the installation selection.""" + errors = {} + options: dict[str, str] = {} + + inst_desc = None + inst_id = None + if user_input is not None: + inst_id = user_input[CONF_ID] + + try: + inst_list = await self.airzone.list_installations() + except AirzoneCloudError: + errors["base"] = "cannot_connect" + else: + for inst in inst_list: + _data = inst.data() + _id = _data[AZD_ID] + options[_id] = f"{_data[AZD_NAME]} {_data[AZD_WEBSERVERS][0]} ({_id})" + if _id is not None and _id == inst_id: + inst_desc = options[_id] + + if user_input is not None and inst_desc is not None: + await self.async_set_unique_id(inst_id) + self._abort_if_unique_id_configured() + + user_input[CONF_USERNAME] = self.airzone.options.username + user_input[CONF_PASSWORD] = self.airzone.options.password + + return self.async_create_entry(title=inst_desc, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ID): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=k, label=v) + for k, v in options.items() + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is not None: + if CONF_ID in user_input: + return await self.async_step_inst_pick(user_input) + + self.airzone = AirzoneCloudApi( + aiohttp_client.async_get_clientsession(self.hass), + ConnectionOptions( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ), + ) + + try: + await self.airzone.login() + except (AirzoneCloudError, LoginError): + errors["base"] = "cannot_connect" + else: + return await self.async_step_inst_pick() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/airzone_cloud/const.py b/homeassistant/components/airzone_cloud/const.py new file mode 100644 index 00000000000000..625d897188de36 --- /dev/null +++ b/homeassistant/components/airzone_cloud/const.py @@ -0,0 +1,8 @@ +"""Constants for the Airzone Cloud integration.""" + +from typing import Final + +DOMAIN: Final[str] = "airzone_cloud" +MANUFACTURER: Final[str] = "Airzone" + +AIOAIRZONE_CLOUD_TIMEOUT_SEC: Final[int] = 30 diff --git a/homeassistant/components/airzone_cloud/coordinator.py b/homeassistant/components/airzone_cloud/coordinator.py new file mode 100644 index 00000000000000..edd99355092072 --- /dev/null +++ b/homeassistant/components/airzone_cloud/coordinator.py @@ -0,0 +1,43 @@ +"""The Airzone Cloud integration coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from aioairzone_cloud.cloudapi import AirzoneCloudApi +from aioairzone_cloud.exceptions import AirzoneCloudError +import async_timeout + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import AIOAIRZONE_CLOUD_TIMEOUT_SEC, DOMAIN + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching data from the Airzone Cloud device.""" + + def __init__(self, hass: HomeAssistant, airzone: AirzoneCloudApi) -> None: + """Initialize.""" + self.airzone = airzone + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + async with async_timeout.timeout(AIOAIRZONE_CLOUD_TIMEOUT_SEC): + try: + await self.airzone.update() + except AirzoneCloudError as error: + raise UpdateFailed(error) from error + return self.airzone.data() diff --git a/homeassistant/components/airzone_cloud/diagnostics.py b/homeassistant/components/airzone_cloud/diagnostics.py new file mode 100644 index 00000000000000..a86f95d6187b4c --- /dev/null +++ b/homeassistant/components/airzone_cloud/diagnostics.py @@ -0,0 +1,144 @@ +"""Support for the Airzone Cloud diagnostics.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aioairzone_cloud.const import ( + API_CITY, + API_GROUP_ID, + API_LOCATION_ID, + API_OLD_ID, + API_PIN, + API_STAT_AP_MAC, + API_STAT_SSID, + API_USER_ID, + AZD_WIFI_MAC, + RAW_DEVICES_STATUS, + RAW_INSTALLATIONS, + RAW_WEBSERVERS, +) + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator + +TO_REDACT_API = [ + API_CITY, + API_GROUP_ID, + API_LOCATION_ID, + API_OLD_ID, + API_PIN, + API_STAT_AP_MAC, + API_STAT_SSID, + API_USER_ID, +] + +TO_REDACT_CONFIG = [ + CONF_PASSWORD, + CONF_USERNAME, +] + +TO_REDACT_COORD = [ + AZD_WIFI_MAC, +] + + +def gather_ids(api_data: dict[str, Any]) -> dict[str, Any]: + """Return dict with IDs.""" + ids: dict[str, Any] = {} + + dev_idx = 1 + for dev_id in api_data[RAW_DEVICES_STATUS]: + if dev_id not in ids: + ids[dev_id] = f"device{dev_idx}" + dev_idx += 1 + + inst_idx = 1 + for inst_id in api_data[RAW_INSTALLATIONS]: + if inst_id not in ids: + ids[inst_id] = f"installation{inst_idx}" + inst_idx += 1 + + ws_idx = 1 + for ws_id in api_data[RAW_WEBSERVERS]: + if ws_id not in ids: + ids[ws_id] = f"webserver{ws_idx}" + ws_idx += 1 + + return ids + + +def redact_keys(data: Any, ids: dict[str, Any]) -> Any: + """Redact sensitive keys in a dict.""" + if not isinstance(data, (Mapping, list)): + return data + + if isinstance(data, list): + return [redact_keys(val, ids) for val in data] + + redacted = {**data} + + keys = list(redacted) + for key in keys: + if key in ids: + redacted[ids[key]] = redacted.pop(key) + elif isinstance(redacted[key], Mapping): + redacted[key] = redact_keys(redacted[key], ids) + elif isinstance(redacted[key], list): + redacted[key] = [redact_keys(item, ids) for item in redacted[key]] + + return redacted + + +def redact_values(data: Any, ids: dict[str, Any]) -> Any: + """Redact sensitive values in a dict.""" + if not isinstance(data, (Mapping, list)): + if data in ids: + return ids[data] + return data + + if isinstance(data, list): + return [redact_values(val, ids) for val in data] + + redacted = {**data} + + for key, value in redacted.items(): + if value is None: + continue + if isinstance(value, Mapping): + redacted[key] = redact_values(value, ids) + elif isinstance(value, list): + redacted[key] = [redact_values(item, ids) for item in value] + elif value in ids: + redacted[key] = ids[value] + + return redacted + + +def redact_all( + data: dict[str, Any], ids: dict[str, Any], to_redact: list[str] +) -> dict[str, Any]: + """Redact sensitive data.""" + _data = redact_keys(data, ids) + _data = redact_values(_data, ids) + return async_redact_data(_data, to_redact) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + raw_data = coordinator.airzone.raw_data() + ids = gather_ids(raw_data) + + return { + "api_data": redact_all(raw_data, ids, TO_REDACT_API), + "config_entry": redact_all(config_entry.as_dict(), ids, TO_REDACT_CONFIG), + "coord_data": redact_all(coordinator.data, ids, TO_REDACT_COORD), + } diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py new file mode 100644 index 00000000000000..9b3dfdae06cba6 --- /dev/null +++ b/homeassistant/components/airzone_cloud/entity.py @@ -0,0 +1,125 @@ +"""Entity classes for the Airzone Cloud integration.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from aioairzone_cloud.const import ( + AZD_AIDOOS, + AZD_AVAILABLE, + AZD_FIRMWARE, + AZD_NAME, + AZD_SYSTEM_ID, + AZD_WEBSERVER, + AZD_WEBSERVERS, + AZD_ZONES, +) + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import AirzoneUpdateCoordinator + + +class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC): + """Define an Airzone Cloud entity.""" + + @property + def available(self) -> bool: + """Return Airzone Cloud entity availability.""" + return super().available and self.get_airzone_value(AZD_AVAILABLE) + + @abstractmethod + def get_airzone_value(self, key: str) -> Any: + """Return Airzone Cloud entity value by key.""" + + +class AirzoneAidooEntity(AirzoneEntity): + """Define an Airzone Cloud Aidoo entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + aidoo_id: str, + aidoo_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.aidoo_id = aidoo_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, aidoo_id)}, + manufacturer=MANUFACTURER, + name=aidoo_data[AZD_NAME], + via_device=(DOMAIN, aidoo_data[AZD_WEBSERVER]), + ) + + def get_airzone_value(self, key: str) -> Any: + """Return Aidoo value by key.""" + value = None + if aidoo := self.coordinator.data[AZD_AIDOOS].get(self.aidoo_id): + value = aidoo.get(key) + return value + + +class AirzoneWebServerEntity(AirzoneEntity): + """Define an Airzone Cloud WebServer entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + ws_id: str, + ws_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.ws_id = ws_id + + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, ws_id)}, + identifiers={(DOMAIN, ws_id)}, + manufacturer=MANUFACTURER, + name=ws_data[AZD_NAME], + sw_version=ws_data[AZD_FIRMWARE], + ) + + def get_airzone_value(self, key: str) -> Any: + """Return WebServer value by key.""" + value = None + if webserver := self.coordinator.data[AZD_WEBSERVERS].get(self.ws_id): + value = webserver.get(key) + return value + + +class AirzoneZoneEntity(AirzoneEntity): + """Define an Airzone Cloud Zone entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + zone_id: str, + zone_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.system_id = zone_data[AZD_SYSTEM_ID] + self.zone_id = zone_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, zone_id)}, + manufacturer=MANUFACTURER, + name=zone_data[AZD_NAME], + via_device=(DOMAIN, self.system_id), + ) + + def get_airzone_value(self, key: str) -> Any: + """Return zone value by key.""" + value = None + if zone := self.coordinator.data[AZD_ZONES].get(self.zone_id): + value = zone.get(key) + return value diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json new file mode 100644 index 00000000000000..8602dfa14cf20d --- /dev/null +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "airzone_cloud", + "name": "Airzone Cloud", + "codeowners": ["@Noltari"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", + "iot_class": "cloud_polling", + "loggers": ["aioairzone_cloud"], + "requirements": ["aioairzone-cloud==0.2.0"] +} diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py new file mode 100644 index 00000000000000..c33838029b41f2 --- /dev/null +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -0,0 +1,201 @@ +"""Support for the Airzone Cloud sensors.""" +from __future__ import annotations + +from typing import Any, Final + +from aioairzone_cloud.const import ( + AZD_AIDOOS, + AZD_HUMIDITY, + AZD_TEMP, + AZD_WEBSERVERS, + AZD_WIFI_RSSI, + AZD_ZONES, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator +from .entity import ( + AirzoneAidooEntity, + AirzoneEntity, + AirzoneWebServerEntity, + AirzoneZoneEntity, +) + +AIDOO_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key=AZD_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), +) + +WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key=AZD_WIFI_RSSI, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + ), +) + +ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key=AZD_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + key=AZD_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone Cloud sensors from a config_entry.""" + coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + sensors: list[AirzoneSensor] = [] + + # Aidoos + for aidoo_id, aidoo_data in coordinator.data.get(AZD_AIDOOS, {}).items(): + for description in AIDOO_SENSOR_TYPES: + if description.key in aidoo_data: + sensors.append( + AirzoneAidooSensor( + coordinator, + description, + aidoo_id, + aidoo_data, + ) + ) + + # WebServers + for ws_id, ws_data in coordinator.data.get(AZD_WEBSERVERS, {}).items(): + for description in WEBSERVER_SENSOR_TYPES: + if description.key in ws_data: + sensors.append( + AirzoneWebServerSensor( + coordinator, + description, + ws_id, + ws_data, + ) + ) + + # Zones + for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): + for description in ZONE_SENSOR_TYPES: + if description.key in zone_data: + sensors.append( + AirzoneZoneSensor( + coordinator, + description, + zone_id, + zone_data, + ) + ) + + async_add_entities(sensors) + + +class AirzoneSensor(AirzoneEntity, SensorEntity): + """Define an Airzone Cloud sensor.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update sensor attributes.""" + self._attr_native_value = self.get_airzone_value(self.entity_description.key) + + +class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor): + """Define an Airzone Cloud Aidoo sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: SensorEntityDescription, + aidoo_id: str, + aidoo_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, aidoo_id, aidoo_data) + + self._attr_unique_id = f"{aidoo_id}_{description.key}" + self.entity_description = description + + self._async_update_attrs() + + +class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): + """Define an Airzone Cloud WebServer sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: SensorEntityDescription, + ws_id: str, + ws_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, ws_id, ws_data) + + self._attr_unique_id = f"{ws_id}_{description.key}" + self.entity_description = description + + self._async_update_attrs() + + +class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): + """Define an Airzone Cloud Zone sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: SensorEntityDescription, + 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/strings.json b/homeassistant/components/airzone_cloud/strings.json new file mode 100644 index 00000000000000..12f155b4486835 --- /dev/null +++ b/homeassistant/components/airzone_cloud/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "id": "Installation", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + } + } +} diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 32eb34333c9b84..25d601cf299cc8 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 51ae515430242c..395bbbb04a82bf 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 @@ class 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 @@ class 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 @@ def __init__( """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 ff42ca14bc38ad..bfe932b039cf35 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 de4f3df257a0d3..e453be88934a47 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -5,6 +5,7 @@ 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 @@ "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 a097aa98535a02..ee8cb57f568a84 100644 --- a/homeassistant/components/alarm_control_panel/device_condition.py +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -58,7 +58,7 @@ 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 9106942c5e5524..fc3850dce30ff0 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -46,7 +46,7 @@ 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/alexa/auth.py b/homeassistant/components/alexa/auth.py index 2dbda64568f0b4..86c038e2da8748 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -12,7 +12,7 @@ from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.storage import Store -from homeassistant.util import dt +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -95,12 +95,12 @@ def is_token_valid(self): if not self._prefs[STORAGE_ACCESS_TOKEN]: return False - expire_time = dt.parse_datetime(self._prefs[STORAGE_EXPIRE_TIME]) + expire_time = dt_util.parse_datetime(self._prefs[STORAGE_EXPIRE_TIME]) preemptive_expire_time = expire_time - timedelta( seconds=PREEMPTIVE_REFRESH_TTL_IN_SECONDS ) - return dt.utcnow() < preemptive_expire_time + return dt_util.utcnow() < preemptive_expire_time async def _async_request_new_token(self, lwa_params): try: @@ -130,7 +130,7 @@ async def _async_request_new_token(self, lwa_params): access_token = response_json["access_token"] refresh_token = response_json["refresh_token"] expires_in = response_json["expires_in"] - expire_time = dt.utcnow() + timedelta(seconds=expires_in) + expire_time = dt_util.utcnow() + timedelta(seconds=expires_in) await self._async_update_preferences( access_token, refresh_token, expire_time.isoformat() diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index e086d525cf125e..d47a548979e939 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -1,4 +1,6 @@ """Config helpers for Alexa.""" +from __future__ import annotations + from abc import ABC, abstractmethod import asyncio import logging @@ -17,15 +19,15 @@ class AbstractConfig(ABC): """Hold the configuration for Alexa.""" + _store: AlexaConfigStore _unsub_proactive_report: CALLBACK_TYPE | None = None def __init__(self, hass: HomeAssistant) -> None: """Initialize abstract config.""" self.hass = hass self._enable_proactive_mode_lock = asyncio.Lock() - self._store = None - async def async_initialize(self): + async def async_initialize(self) -> None: """Perform async initialization of config.""" self._store = AlexaConfigStore(self.hass) await self._store.async_load() @@ -65,7 +67,7 @@ def is_reporting_states(self): def user_identifier(self): """Return an identifier for the user that represents this config.""" - async def async_enable_proactive_mode(self): + async def async_enable_proactive_mode(self) -> None: """Enable proactive mode.""" _LOGGER.debug("Enable proactive mode") async with self._enable_proactive_mode_lock: @@ -75,7 +77,7 @@ async def async_enable_proactive_mode(self): self.hass, self ) - async def async_disable_proactive_mode(self): + async def async_disable_proactive_mode(self) -> None: """Disable proactive mode.""" _LOGGER.debug("Disable proactive mode") if unsub_func := self._unsub_proactive_report: @@ -105,7 +107,7 @@ def authorized(self): """Return authorization status.""" return self._store.authorized - async def set_authorized(self, authorized): + async def set_authorized(self, authorized) -> None: """Set authorization status. - Set when an incoming message is received from Alexa. diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index a189c364c02d7e..ebab3bcee8ce1d 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -5,7 +5,7 @@ from http import HTTPStatus import json import logging -from typing import cast +from typing import TYPE_CHECKING, cast import aiohttp import async_timeout @@ -23,6 +23,9 @@ from .errors import NoTokenAvailable, RequireRelink from .messages import AlexaResponse +if TYPE_CHECKING: + from .config import AbstractConfig + _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = 10 @@ -188,7 +191,9 @@ async def async_send_changereport_message( ) -async def async_send_add_or_update_message(hass, config, entity_ids): +async def async_send_add_or_update_message( + hass: HomeAssistant, config: AbstractConfig, entity_ids: list[str] +) -> aiohttp.ClientResponse: """Send an AddOrUpdateReport message for entities. https://developer.amazon.com/docs/device-apis/alexa-discovery.html#add-or-update-report @@ -223,7 +228,9 @@ async def async_send_add_or_update_message(hass, config, entity_ids): ) -async def async_send_delete_message(hass, config, entity_ids): +async def async_send_delete_message( + hass: HomeAssistant, config: AbstractConfig, entity_ids: list[str] +) -> aiohttp.ClientResponse: """Send an DeleteReport message for entities. https://developer.amazon.com/docs/device-apis/alexa-discovery.html#deletereport-event diff --git a/homeassistant/components/alpha_vantage/manifest.json b/homeassistant/components/alpha_vantage/manifest.json index 66de4b6a5f8271..c94da6bf4874c6 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/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py index 97e0af7f18eda9..5db46fc019e08f 100644 --- a/homeassistant/components/amazon_polly/tts.py +++ b/homeassistant/components/amazon_polly/tts.py @@ -167,12 +167,9 @@ def get_tts_audio( self, message: str, language: str, - options: dict[str, Any] | None = None, + options: dict[str, Any], ) -> TtsAudioType: """Request TTS file from Polly.""" - if options is None or language is None: - _LOGGER.debug("language and/or options were missing") - return None, None voice_id = options.get(CONF_VOICE, self.default_voice) voice_in_dict = self.all_voices[voice_id] if language != voice_in_dict.get("LanguageCode"): diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py index 2bb2b441430aa2..516ed319d0152e 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -98,7 +98,7 @@ async def async_setup_entry( tasks = [] for heater in data_connection.get_devices(): - tasks.append(heater.update_device_info()) + tasks.append(asyncio.create_task(heater.update_device_info())) await asyncio.wait(tasks) devs = [] diff --git a/homeassistant/components/ambiclimate/manifest.json b/homeassistant/components/ambiclimate/manifest.json index f2fd0ea5d7731c..315490b2d62bba 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 ff1a283769dc4f..306c24a94ac9c8 100644 --- a/homeassistant/components/amcrest/helpers.py +++ b/homeassistant/components/amcrest/helpers.py @@ -3,6 +3,8 @@ 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/__init__.py b/homeassistant/components/analytics/__init__.py index c02c1a3a3b6e99..ee36aa78e63fb1 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -6,12 +6,15 @@ from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import Event, HassJob, HomeAssistant, callback +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType from .analytics import Analytics from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: """Set up the analytics integration.""" diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 9419f00e41e394..19e6b5ec7b3be2 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -21,12 +21,22 @@ 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 @@ async def send_analytics(self, _: datetime | None = None) -> None: 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 @@ async def send_analytics(self, _: datetime | None = None) -> None: 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 @@ async def send_analytics(self, _: datetime | None = None) -> None: 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 @@ async def send_analytics(self, _: datetime | None = None) -> None: 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/android_ip_webcam/strings.json b/homeassistant/components/android_ip_webcam/strings.json index 6f6639cecb41a7..db21a69098477e 100644 --- a/homeassistant/components/android_ip_webcam/strings.json +++ b/homeassistant/components/android_ip_webcam/strings.json @@ -17,11 +17,5 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The Android IP Webcam YAML configuration is being removed", - "description": "Configuring Android IP Webcam using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Android IP Webcam YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 563b8f07b2a09a..f4fbe4a498f403 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -296,7 +296,6 @@ async def async_added_to_hass(self) -> None: self._process_config, ) ) - return @property def media_image_hash(self) -> str | None: diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 24b64c622a9960..f7e1078d3fa68c 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -135,7 +135,8 @@ async def async_step_zeroconf( self.host = discovery_info.host self.name = discovery_info.name.removesuffix("._androidtvremote2._tcp.local.") self.mac = discovery_info.properties.get("bt") - assert self.mac + if not self.mac: + return self.async_abort(reason="cannot_connect") await self.async_set_unique_id(format_mac(self.mac)) self._abort_if_unique_id_configured( updates={CONF_HOST: self.host, CONF_NAME: self.name} diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py index 862f317ee82d66..5a99805da62b04 100644 --- a/homeassistant/components/androidtv_remote/entity.py +++ b/homeassistant/components/androidtv_remote/entity.py @@ -16,6 +16,7 @@ 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/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index 9273e82a51b7f3..c728ea0a682735 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["androidtvremote2"], "quality_scale": "platinum", - "requirements": ["androidtvremote2==0.0.8"], + "requirements": ["androidtvremote2==0.0.9"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/anel_pwrctrl/manifest.json b/homeassistant/components/anel_pwrctrl/manifest.json index f1de40bc89e45a..48cc3b96ec0884 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 2ab23ff2d37b6b..038e71750ddeb5 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -80,6 +80,7 @@ def __init__( 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/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 4e0e46f6392f15..8b7034357dfda5 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -3,8 +3,6 @@ import logging -from apcaccess.status import ALL_UNITS - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -379,7 +377,6 @@ key="stesti", name="UPS Self Test Interval", icon="mdi:information-outline", - state_class=SensorStateClass.TOTAL_INCREASING, ), "timeleft": SensorEntityDescription( key="timeleft", @@ -427,18 +424,26 @@ ), } -SPECIFIC_UNITS = {"ITEMP": UnitOfTemperature.CELSIUS} INFERRED_UNITS = { " Minutes": UnitOfTime.MINUTES, " Seconds": UnitOfTime.SECONDS, " Percent": PERCENTAGE, " Volts": UnitOfElectricPotential.VOLT, " Ampere": UnitOfElectricCurrent.AMPERE, + " Amps": UnitOfElectricCurrent.AMPERE, " Volt-Ampere": UnitOfApparentPower.VOLT_AMPERE, + " VA": UnitOfApparentPower.VOLT_AMPERE, " Watts": UnitOfPower.WATT, " Hz": UnitOfFrequency.HERTZ, " C": UnitOfTemperature.CELSIUS, + # APCUPSd reports data for "itemp" field (eventually represented by UPS Internal + # Temperature sensor in this integration) with a trailing "Internal", e.g., + # "34.6 C Internal". Here we create a fake unit " C Internal" to handle this case. + " C Internal": UnitOfTemperature.CELSIUS, " Percent Load Capacity": PERCENTAGE, + # "stesti" field (Self Test Interval) field could report a "days" unit, e.g., + # "7 days", so here we add support for it. + " days": UnitOfTime.DAYS, } @@ -466,15 +471,16 @@ async def async_setup_entry( def infer_unit(value: str) -> tuple[str, str | None]: - """If the value ends with any of the units from ALL_UNITS. + """If the value ends with any of the units from supported units. Split the unit off the end of the value and return the value, unit tuple pair. Else return the original value and None as the unit. """ - for unit in ALL_UNITS: + for unit, ha_unit in INFERRED_UNITS.items(): if value.endswith(unit): - return value.removesuffix(unit), INFERRED_UNITS.get(unit, unit.strip()) + return value.removesuffix(unit), ha_unit + return value, None diff --git a/homeassistant/components/apcupsd/strings.json b/homeassistant/components/apcupsd/strings.json index aef33a6f8bf805..c7ebf8a0a3bc5b 100644 --- a/homeassistant/components/apcupsd/strings.json +++ b/homeassistant/components/apcupsd/strings.json @@ -16,11 +16,5 @@ "description": "Enter the host and port on which the apcupsd NIS is being served." } } - }, - "issues": { - "deprecated_yaml": { - "title": "The APC UPS Daemon YAML configuration is being removed", - "description": "Configuring APC UPS Daemon using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the APC UPS Daemon YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 5c0a60ecef7fe0..6538bd345de427 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -28,7 +28,7 @@ import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotFound, TemplateError, Unauthorized -from homeassistant.helpers import template +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.typing import ConfigType @@ -49,6 +49,8 @@ STREAM_PING_PAYLOAD = "ping" STREAM_PING_INTERVAL = 50 # seconds +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the API with the HTTP interface.""" diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 9b80d992cdd923..8a2130faca076e 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -407,8 +407,9 @@ async def async_pair_next_protocol(self): # Protocol specific arguments pair_args = {} - if self.protocol == Protocol.DMAP: + if self.protocol in {Protocol.AirPlay, Protocol.Companion, Protocol.DMAP}: pair_args["name"] = "Home Assistant" + if self.protocol == Protocol.DMAP: pair_args["zeroconf"] = await zeroconf.async_get_instance(self.hass) # Initiate the pairing process diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index c534c635317c98..4ead41e86e9758 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "iot_class": "local_push", "loggers": ["pyatv", "srptools"], - "requirements": ["pyatv==0.11.0"], + "requirements": ["pyatv==0.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 06618e4f2a3548..a70a30656f2d4c 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -138,6 +138,9 @@ def async_device_connected(self, atv: AppleTV) -> None: # Listen to power updates self.atv.power.listener = self + # Listen to volume updates + self.atv.audio.listener = self + if self.atv.features.in_state(FeatureState.Available, FeatureName.AppList): self.hass.create_task(self._update_app_list()) @@ -203,6 +206,11 @@ def powerstate_update(self, old_state: PowerState, new_state: PowerState) -> Non """Update power state when it changes.""" self.async_write_ha_state() + @callback + def volume_update(self, old_level: float, new_level: float) -> None: + """Update volume when it changes.""" + self.async_write_ha_state() + @property def app_id(self) -> str | None: """ID of the current running app.""" @@ -274,7 +282,7 @@ async def async_play_media( """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 1420c0ffefcf60..e5948a54a8d924 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/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index f1471f29666509..679ff9bfac4544 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -57,6 +57,8 @@ } UPDATE_FIELDS: dict = {} # Not supported +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @dataclass class ClientCredential: diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 9a56f5d91ebb16..04dcef052025aa 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/apprise", "iot_class": "cloud_push", "loggers": ["apprise"], - "requirements": ["apprise==1.4.0"] + "requirements": ["apprise==1.4.5"] } diff --git a/homeassistant/components/aquostv/manifest.json b/homeassistant/components/aquostv/manifest.json index 1bac2bdfb5ff8a..011b8e67a19c57 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 4596a7fd8af1fa..5c223940915b49 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,53 +25,66 @@ ATTR_SW_VERSION, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + EntityCategory, UnitOfPressure, UnitOfTemperature, UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceInfo +from homeassistant.helpers.entity import DeviceInfo 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 @@ 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, ), } @@ -105,13 +121,6 @@ def sensor_update_to_bluetooth_data_update( adv: Aranet4Advertisement, ) -> PassiveBluetoothDataUpdate: """Convert a sensor update to a Bluetooth data update.""" - entity_names: dict[PassiveBluetoothEntityKey, str | None] = {} - for key, desc in SENSOR_DESCRIPTIONS.items(): - # PassiveBluetoothDataUpdate does not support DEVICE_CLASS_NAME - # the assert satisfies the type checker and will catch attempts - # to use DEVICE_CLASS_NAME in the entity descriptions. - assert desc.name is not DEVICE_CLASS_NAME - entity_names[_device_key_to_bluetooth_entity_key(adv.device, key)] = desc.name return PassiveBluetoothDataUpdate( devices={adv.device.address: _sensor_device_info_to_hass(adv)}, entity_descriptions={ @@ -124,7 +133,10 @@ def sensor_update_to_bluetooth_data_update( ) for key in SENSOR_DESCRIPTIONS }, - entity_names=entity_names, + entity_names={ + _device_key_to_bluetooth_entity_key(adv.device, key): desc.name + for key, desc in SENSOR_DESCRIPTIONS.items() + }, ) diff --git a/homeassistant/components/arcam_fmj/device_trigger.py b/homeassistant/components/arcam_fmj/device_trigger.py index ecaec0e0e7dfe1..ef83217ee26d62 100644 --- a/homeassistant/components/arcam_fmj/device_trigger.py +++ b/homeassistant/components/arcam_fmj/device_trigger.py @@ -3,7 +3,9 @@ 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 @@ 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 @@ def _handle_event(event: Event) -> None: 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 9a76d4843f03f0..2c9b64b00ce184 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/arest/switch.py b/homeassistant/components/arest/switch.py index 6efa24c5a0f5ab..1c67723fc02865 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -161,16 +161,13 @@ def update(self) -> None: class ArestSwitchPin(ArestSwitchBase): """Representation of an aREST switch. Based on digital I/O.""" - def __init__(self, resource, location, name, pin, invert): + def __init__(self, resource, location, name, pin, invert) -> None: """Initialize the switch.""" super().__init__(resource, location, name) self._pin = pin self.invert = invert - request = requests.get(f"{resource}/mode/{pin}/o", timeout=10) - if request.status_code != HTTPStatus.OK: - _LOGGER.error("Can't set mode") - self._attr_available = False + self.__set_pin_output() def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" @@ -200,7 +197,15 @@ def update(self) -> None: request = requests.get(f"{self._resource}/digital/{self._pin}", timeout=10) status_value = int(self.invert) self._attr_is_on = request.json()["return_value"] != status_value - self._attr_available = True + if self._attr_available is False: + self._attr_available = True + self.__set_pin_output() except requests.exceptions.ConnectionError: _LOGGER.warning("No route to device %s", self._resource) self._attr_available = False + + def __set_pin_output(self) -> None: + request = requests.get(f"{self._resource}/mode/{self._pin}/o", timeout=10) + if request.status_code != HTTPStatus.OK: + _LOGGER.error("Can't set mode") + self._attr_available = False diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 7af379804e1d88..55b192a730ae1d 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -5,6 +5,7 @@ from homeassistant.components import stt from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -33,8 +34,11 @@ "Pipeline", "PipelineEvent", "PipelineEventType", + "PipelineNotFound", ) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Assist pipeline integration.""" @@ -53,28 +57,26 @@ 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.""" - pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id) - if pipeline is None: - raise PipelineNotFound( - "pipeline_not_found", f"Pipeline {pipeline_id} not found" - ) + """Create an audio pipeline from an audio stream. + Raises PipelineNotFound if no pipeline is found. + """ pipeline_input = PipelineInput( conversation_id=conversation_id, + device_id=device_id, stt_metadata=stt_metadata, stt_stream=stt_stream, run=PipelineRun( hass, context=context, - pipeline=pipeline, + pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id), start_stage=PipelineStage.STT, end_stage=PipelineStage.TTS, event_callback=event_callback, tts_audio_output=tts_audio_output, ), ) - await pipeline_input.validate() await pipeline_input.execute() diff --git a/homeassistant/components/assist_pipeline/error.py b/homeassistant/components/assist_pipeline/error.py index fa26d916eeb788..c5ffdcaf2d346c 100644 --- a/homeassistant/components/assist_pipeline/error.py +++ b/homeassistant/components/assist_pipeline/error.py @@ -19,7 +19,7 @@ class PipelineNotFound(PipelineError): class SpeechToTextError(PipelineError): - """Error in speech to text portion of pipeline.""" + """Error in speech-to-text portion of pipeline.""" class IntentRecognitionError(PipelineError): @@ -27,4 +27,4 @@ class IntentRecognitionError(PipelineError): class TextToSpeechError(PipelineError): - """Error in text to speech portion of pipeline.""" + """Error in text-to-speech portion of pipeline.""" diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 6a4bbdf61e690d..891fc639feee15 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -36,6 +36,7 @@ from .error import ( IntentRecognitionError, PipelineError, + PipelineNotFound, SpeechToTextError, TextToSpeechError, ) @@ -125,7 +126,7 @@ async def _async_resolve_default_pipeline_settings( stt_language = stt_languages[0] else: _LOGGER.debug( - "Speech to text engine '%s' does not support language '%s'", + "Speech-to-text engine '%s' does not support language '%s'", stt_engine_id, pipeline_language, ) @@ -152,7 +153,7 @@ async def _async_resolve_default_pipeline_settings( tts_voice = tts_voices[0].voice_id else: _LOGGER.debug( - "Text to speech engine '%s' does not support language '%s'", + "Text-to-speech engine '%s' does not support language '%s'", tts_engine_id, pipeline_language, ) @@ -208,9 +209,7 @@ async def async_create_default_pipeline( @callback -def async_get_pipeline( - hass: HomeAssistant, pipeline_id: str | None = None -) -> Pipeline | None: +def async_get_pipeline(hass: HomeAssistant, pipeline_id: str | None = None) -> Pipeline: """Get a pipeline by id or the preferred pipeline.""" pipeline_data: PipelineData = hass.data[DOMAIN] @@ -218,7 +217,15 @@ def async_get_pipeline( # A pipeline was not specified, use the preferred one pipeline_id = pipeline_data.pipeline_store.async_get_preferred_item() - return pipeline_data.pipeline_store.data.get(pipeline_id) + pipeline = pipeline_data.pipeline_store.data.get(pipeline_id) + + # If invalid pipeline ID was specified + if pipeline is None: + raise PipelineNotFound( + "pipeline_not_found", f"Pipeline {pipeline_id} not found" + ) + + return pipeline @callback @@ -387,7 +394,7 @@ def end(self) -> None: ) async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: - """Prepare speech to text.""" + """Prepare speech-to-text.""" # pipeline.stt_engine can't be None or this function is not called stt_provider = stt.async_get_speech_to_text_engine( self.hass, @@ -398,7 +405,7 @@ async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: engine = self.pipeline.stt_engine raise SpeechToTextError( code="stt-provider-missing", - message=f"No speech to text provider for: {engine}", + message=f"No speech-to-text provider for: {engine}", ) metadata.language = self.pipeline.stt_language or self.language @@ -419,7 +426,7 @@ async def speech_to_text( metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes], ) -> str: - """Run speech to text portion of pipeline. Returns the spoken text.""" + """Run speech-to-text portion of pipeline. Returns the spoken text.""" if isinstance(self.stt_provider, stt.Provider): engine = self.stt_provider.name else: @@ -441,10 +448,10 @@ async def speech_to_text( metadata, stream ) except Exception as src_error: - _LOGGER.exception("Unexpected error during speech to text") + _LOGGER.exception("Unexpected error during speech-to-text") raise SpeechToTextError( code="stt-stream-failed", - message="Unexpected error during speech to text", + message="Unexpected error during speech-to-text", ) from src_error _LOGGER.debug("speech-to-text result %s", result) @@ -452,7 +459,7 @@ async def speech_to_text( if result.result != stt.SpeechResultState.SUCCESS: raise SpeechToTextError( code="stt-stream-failed", - message="Speech to text failed", + message="speech-to-text failed", ) if not result.text: @@ -492,7 +499,7 @@ async def prepare_recognize_intent(self) -> None: 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: @@ -505,6 +512,8 @@ async def recognize_intent( "engine": self.intent_agent, "language": self.pipeline.conversation_language, "intent_input": intent_input, + "conversation_id": conversation_id, + "device_id": device_id, }, ) ) @@ -514,6 +523,7 @@ async def recognize_intent( 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, @@ -541,7 +551,7 @@ async def recognize_intent( return speech async def prepare_text_to_speech(self) -> None: - """Prepare text to speech.""" + """Prepare text-to-speech.""" # pipeline.tts_engine can't be None or this function is not called engine = cast(str, self.pipeline.tts_engine) @@ -562,13 +572,13 @@ async def prepare_text_to_speech(self) -> None: except HomeAssistantError as err: raise TextToSpeechError( code="tts-not-supported", - message=f"Text to speech engine '{engine}' not found", + message=f"Text-to-speech engine '{engine}' not found", ) from err if not options_supported: raise TextToSpeechError( code="tts-not-supported", message=( - f"Text to speech engine {engine} " + f"Text-to-speech engine {engine} " f"does not support language {self.pipeline.tts_language} or options {tts_options}" ), ) @@ -577,7 +587,7 @@ async def prepare_text_to_speech(self) -> None: self.tts_options = tts_options async def text_to_speech(self, tts_input: str) -> str: - """Run text to speech portion of pipeline. Returns URL of TTS audio.""" + """Run text-to-speech portion of pipeline. Returns URL of TTS audio.""" self.process_event( PipelineEvent( PipelineEventType.TTS_START, @@ -605,10 +615,10 @@ async def text_to_speech(self, tts_input: str) -> str: None, ) except Exception as src_error: - _LOGGER.exception("Unexpected error during text to speech") + _LOGGER.exception("Unexpected error during text-to-speech") raise TextToSpeechError( code="tts-failed", - message="Unexpected error during text to speech", + message="Unexpected error during text-to-speech", ) from src_error _LOGGER.debug("TTS result %s", tts_media) @@ -644,17 +654,19 @@ class PipelineInput: """Input for conversation agent. Required when start_stage = intent.""" tts_input: str | None = None - """Input for text to speech. Required when start_stage = tts.""" + """Input for text-to-speech. Required when start_stage = tts.""" conversation_id: str | None = None + device_id: str | None = None + async def execute(self) -> None: """Run pipeline.""" self.run.start() current_stage = self.run.start_stage try: - # Speech to text + # speech-to-text intent_input = self.intent_input if current_stage == PipelineStage.STT: assert self.stt_metadata is not None @@ -671,7 +683,9 @@ async def execute(self) -> None: 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 @@ -696,15 +710,15 @@ async def validate(self) -> None: if self.run.start_stage == PipelineStage.STT: if self.run.pipeline.stt_engine is None: raise PipelineRunValidationError( - "the pipeline does not support speech to text" + "the pipeline does not support speech-to-text" ) if self.stt_metadata is None: raise PipelineRunValidationError( - "stt_metadata is required for speech to text" + "stt_metadata is required for speech-to-text" ) if self.stt_stream is None: raise PipelineRunValidationError( - "stt_stream is required for speech to text" + "stt_stream is required for speech-to-text" ) elif self.run.start_stage == PipelineStage.INTENT: if self.intent_input is None: @@ -714,26 +728,39 @@ async def validate(self) -> None: elif self.run.start_stage == PipelineStage.TTS: if self.tts_input is None: raise PipelineRunValidationError( - "tts_input is required for text to speech" + "tts_input is required for text-to-speech" ) if self.run.end_stage == PipelineStage.TTS: if self.run.pipeline.tts_engine is None: raise PipelineRunValidationError( - "the pipeline does not support text to speech" + "the pipeline does not support text-to-speech" ) start_stage_index = PIPELINE_STAGE_ORDER.index(self.run.start_stage) + 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: @@ -937,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 9ac1d6b5888325..2ae46fcb9ac591 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -10,7 +10,8 @@ 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 @@ async def async_added_to_hass(self) -> None: """When entity is added to Home Assistant.""" await super().async_added_to_hass() - pipeline_store: PipelineStorageCollection = self.hass.data[ - DOMAIN - ].pipeline_store - pipeline_store.async_add_change_set_listener(self._pipelines_updated) + 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 @@ def _update_options(self) -> None: 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 d85eb1aaed9893..8fa67879fc3883 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 c5f87f1336ad5e..a737490f22f18a 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 @@ def _process_chunk(self, chunk: bytes) -> bool: 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 6c1dbe3dbce921..ea3aacf43a4124 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -17,6 +17,7 @@ from homeassistant.util import language as language_util from .const import DOMAIN +from .error import PipelineNotFound from .pipeline import ( PipelineData, PipelineError, @@ -55,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), }, ), @@ -85,8 +87,9 @@ async def websocket_run( ) -> None: """Run a pipeline.""" pipeline_id = msg.get("pipeline") - pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id) - if pipeline is None: + try: + pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id) + except PipelineNotFound: connection.send_error( msg["id"], "pipeline-not-found", @@ -103,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: @@ -151,7 +155,7 @@ def handle_binary( # Input to conversation agent input_args["intent_input"] = msg["input"]["text"] elif start_stage == PipelineStage.TTS: - # Input to text to speech system + # Input to text-to-speech system input_args["tts_input"] = msg["input"]["text"] input_args["run"] = PipelineRun( @@ -278,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 8348e40ba6b9aa..840c48aff2aa29 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/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py new file mode 100644 index 00000000000000..9e6da0ea8f779c --- /dev/null +++ b/homeassistant/components/asuswrt/bridge.py @@ -0,0 +1,273 @@ +"""aioasuswrt and pyasuswrt bridge classes.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections import namedtuple +import logging +from typing import Any, cast + +from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy + +from homeassistant.const import ( + CONF_HOST, + CONF_MODE, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .const import ( + CONF_DNSMASQ, + CONF_INTERFACE, + CONF_REQUIRE_IP, + CONF_SSH_KEY, + DEFAULT_DNSMASQ, + DEFAULT_INTERFACE, + KEY_METHOD, + KEY_SENSORS, + PROTOCOL_TELNET, + SENSORS_BYTES, + SENSORS_LOAD_AVG, + SENSORS_RATES, + SENSORS_TEMPERATURES, +) + +SENSORS_TYPE_BYTES = "sensors_bytes" +SENSORS_TYPE_COUNT = "sensors_count" +SENSORS_TYPE_LOAD_AVG = "sensors_load_avg" +SENSORS_TYPE_RATES = "sensors_rates" +SENSORS_TYPE_TEMPERATURES = "sensors_temperatures" + +WrtDevice = namedtuple("WrtDevice", ["ip", "name", "connected_to"]) + +_LOGGER = logging.getLogger(__name__) + + +def _get_dict(keys: list, values: list) -> dict[str, Any]: + """Create a dict from a list of keys and values.""" + return dict(zip(keys, values)) + + +class AsusWrtBridge(ABC): + """The Base Bridge abstract class.""" + + @staticmethod + def get_bridge( + hass: HomeAssistant, conf: dict[str, Any], options: dict[str, Any] | None = None + ) -> AsusWrtBridge: + """Get Bridge instance.""" + return AsusWrtLegacyBridge(conf, options) + + def __init__(self, host: str) -> None: + """Initialize Bridge.""" + self._host = host + self._firmware: str | None = None + self._label_mac: str | None = None + self._model: str | None = None + + @property + def host(self) -> str: + """Return hostname.""" + return self._host + + @property + def firmware(self) -> str | None: + """Return firmware information.""" + return self._firmware + + @property + def label_mac(self) -> str | None: + """Return label mac information.""" + return self._label_mac + + @property + def model(self) -> str | None: + """Return model information.""" + return self._model + + @property + @abstractmethod + def is_connected(self) -> bool: + """Get connected status.""" + + @abstractmethod + async def async_connect(self) -> None: + """Connect to the device.""" + + @abstractmethod + async def async_disconnect(self) -> None: + """Disconnect to the device.""" + + @abstractmethod + async def async_get_connected_devices(self) -> dict[str, WrtDevice]: + """Get list of connected devices.""" + + @abstractmethod + async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: + """Return a dictionary of available sensors for this bridge.""" + + +class AsusWrtLegacyBridge(AsusWrtBridge): + """The Bridge that use legacy library.""" + + def __init__( + self, conf: dict[str, Any], options: dict[str, Any] | None = None + ) -> None: + """Initialize Bridge.""" + super().__init__(conf[CONF_HOST]) + self._protocol: str = conf[CONF_PROTOCOL] + self._api: AsusWrtLegacy = self._get_api(conf, options) + + @staticmethod + def _get_api( + conf: dict[str, Any], options: dict[str, Any] | None = None + ) -> AsusWrtLegacy: + """Get the AsusWrtLegacy API.""" + opt = options or {} + + return AsusWrtLegacy( + conf[CONF_HOST], + conf.get(CONF_PORT), + conf[CONF_PROTOCOL] == PROTOCOL_TELNET, + conf[CONF_USERNAME], + conf.get(CONF_PASSWORD, ""), + conf.get(CONF_SSH_KEY, ""), + conf[CONF_MODE], + opt.get(CONF_REQUIRE_IP, True), + interface=opt.get(CONF_INTERFACE, DEFAULT_INTERFACE), + dnsmasq=opt.get(CONF_DNSMASQ, DEFAULT_DNSMASQ), + ) + + @property + def is_connected(self) -> bool: + """Get connected status.""" + return cast(bool, self._api.is_connected) + + async def async_connect(self) -> None: + """Connect to the device.""" + await self._api.connection.async_connect() + + # get main router properties + if self._label_mac is None: + await self._get_label_mac() + if self._firmware is None: + await self._get_firmware() + if self._model is None: + await self._get_model() + + async def async_disconnect(self) -> None: + """Disconnect to the device.""" + if self._api is not None and self._protocol == PROTOCOL_TELNET: + self._api.connection.disconnect() + + async def async_get_connected_devices(self) -> dict[str, WrtDevice]: + """Get list of connected devices.""" + try: + api_devices = await self._api.async_get_connected_devices() + except OSError as exc: + raise UpdateFailed(exc) from exc + return { + format_mac(mac): WrtDevice(dev.ip, dev.name, None) + for mac, dev in api_devices.items() + } + + async def _get_nvram_info(self, info_type: str) -> dict[str, Any]: + """Get AsusWrt router info from nvram.""" + info = {} + try: + info = await self._api.async_get_nvram(info_type) + except OSError as exc: + _LOGGER.warning( + "Error calling method async_get_nvram(%s): %s", info_type, exc + ) + + return info + + async def _get_label_mac(self) -> None: + """Get label mac information.""" + label_mac = await self._get_nvram_info("LABEL_MAC") + if label_mac and "label_mac" in label_mac: + self._label_mac = format_mac(label_mac["label_mac"]) + + async def _get_firmware(self) -> None: + """Get firmware information.""" + firmware = await self._get_nvram_info("FIRMWARE") + if firmware and "firmver" in firmware: + firmver: str = firmware["firmver"] + if "buildno" in firmware: + firmver += f" (build {firmware['buildno']})" + self._firmware = firmver + + async def _get_model(self) -> None: + """Get model information.""" + model = await self._get_nvram_info("MODEL") + if model and "model" in model: + self._model = model["model"] + + async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: + """Return a dictionary of available sensors for this bridge.""" + sensors_temperatures = await self._get_available_temperature_sensors() + sensors_types = { + SENSORS_TYPE_BYTES: { + KEY_SENSORS: SENSORS_BYTES, + KEY_METHOD: self._get_bytes, + }, + SENSORS_TYPE_LOAD_AVG: { + KEY_SENSORS: SENSORS_LOAD_AVG, + KEY_METHOD: self._get_load_avg, + }, + SENSORS_TYPE_RATES: { + KEY_SENSORS: SENSORS_RATES, + KEY_METHOD: self._get_rates, + }, + SENSORS_TYPE_TEMPERATURES: { + KEY_SENSORS: sensors_temperatures, + KEY_METHOD: self._get_temperatures, + }, + } + return sensors_types + + async def _get_available_temperature_sensors(self) -> list[str]: + """Check which temperature information is available on the router.""" + availability = await self._api.async_find_temperature_commands() + return [SENSORS_TEMPERATURES[i] for i in range(3) if availability[i]] + + async def _get_bytes(self) -> dict[str, Any]: + """Fetch byte information from the router.""" + try: + datas = await self._api.async_get_bytes_total() + except (IndexError, OSError, ValueError) as exc: + raise UpdateFailed(exc) from exc + + return _get_dict(SENSORS_BYTES, datas) + + async def _get_rates(self) -> dict[str, Any]: + """Fetch rates information from the router.""" + try: + rates = await self._api.async_get_current_transfer_rates() + except (IndexError, OSError, ValueError) as exc: + raise UpdateFailed(exc) from exc + + return _get_dict(SENSORS_RATES, rates) + + async def _get_load_avg(self) -> dict[str, Any]: + """Fetch load average information from the router.""" + try: + avg = await self._api.async_get_loadavg() + except (IndexError, OSError, ValueError) as exc: + raise UpdateFailed(exc) from exc + + return _get_dict(SENSORS_LOAD_AVG, avg) + + async def _get_temperatures(self) -> dict[str, Any]: + """Fetch temperatures information from the router.""" + try: + temperatures: dict[str, Any] = await self._api.async_get_temperature() + except (OSError, ValueError) as exc: + raise UpdateFailed(exc) from exc + + return temperatures diff --git a/homeassistant/components/asuswrt/config_flow.py b/homeassistant/components/asuswrt/config_flow.py index 6b0056b14faab9..56569d4f23bc5a 100644 --- a/homeassistant/components/asuswrt/config_flow.py +++ b/homeassistant/components/asuswrt/config_flow.py @@ -25,13 +25,13 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaFlowFormStep, SchemaOptionsFlowHandler, ) +from .bridge import AsusWrtBridge from .const import ( CONF_DNSMASQ, CONF_INTERFACE, @@ -47,7 +47,6 @@ PROTOCOL_SSH, PROTOCOL_TELNET, ) -from .router import get_api, get_nvram_info LABEL_MAC = "LABEL_MAC" @@ -143,16 +142,15 @@ def _show_setup_form( errors=errors or {}, ) - @staticmethod async def _async_check_connection( - user_input: dict[str, Any] + self, user_input: dict[str, Any] ) -> tuple[str, str | None]: """Attempt to connect the AsusWrt router.""" host: str = user_input[CONF_HOST] - api = get_api(user_input) + api = AsusWrtBridge.get_bridge(self.hass, user_input) try: - await api.connection.async_connect() + await api.async_connect() except OSError: _LOGGER.error("Error connecting to the AsusWrt router at %s", host) @@ -168,14 +166,9 @@ async def _async_check_connection( _LOGGER.error("Error connecting to the AsusWrt router at %s", host) return RESULT_CONN_ERROR, None - label_mac = await get_nvram_info(api, LABEL_MAC) - conf_protocol = user_input[CONF_PROTOCOL] - if conf_protocol == PROTOCOL_TELNET: - api.connection.disconnect() + unique_id = api.label_mac + await api.async_disconnect() - unique_id = None - if label_mac and "label_mac" in label_mac: - unique_id = format_mac(label_mac["label_mac"]) return RESULT_SUCCESS, unique_id async def async_step_user( diff --git a/homeassistant/components/asuswrt/const.py b/homeassistant/components/asuswrt/const.py index f80643f078d4d2..1733d4c09c3765 100644 --- a/homeassistant/components/asuswrt/const.py +++ b/homeassistant/components/asuswrt/const.py @@ -13,6 +13,10 @@ DEFAULT_INTERFACE = "eth0" DEFAULT_TRACK_UNKNOWN = False +KEY_COORDINATOR = "coordinator" +KEY_METHOD = "method" +KEY_SENSORS = "sensors" + MODE_AP = "ap" MODE_ROUTER = "router" diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 4291c21d0ed1ac..c782a8f0f3b036 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -6,22 +6,12 @@ import logging from typing import Any -from aioasuswrt.asuswrt import AsusWrt, Device as WrtDevice - from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, DOMAIN as TRACKER_DOMAIN, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_MODE, - CONF_PASSWORD, - CONF_PORT, - CONF_PROTOCOL, - CONF_USERNAME, -) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er @@ -32,55 +22,36 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util +from .bridge import AsusWrtBridge, WrtDevice from .const import ( CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP, - CONF_SSH_KEY, CONF_TRACK_UNKNOWN, DEFAULT_DNSMASQ, DEFAULT_INTERFACE, DEFAULT_TRACK_UNKNOWN, DOMAIN, - PROTOCOL_TELNET, - SENSORS_BYTES, + KEY_COORDINATOR, + KEY_METHOD, + KEY_SENSORS, SENSORS_CONNECTED_DEVICE, - SENSORS_LOAD_AVG, - SENSORS_RATES, - SENSORS_TEMPERATURES, ) CONF_REQ_RELOAD = [CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP] DEFAULT_NAME = "Asuswrt" -KEY_COORDINATOR = "coordinator" -KEY_SENSORS = "sensors" - SCAN_INTERVAL = timedelta(seconds=30) -SENSORS_TYPE_BYTES = "sensors_bytes" SENSORS_TYPE_COUNT = "sensors_count" -SENSORS_TYPE_LOAD_AVG = "sensors_load_avg" -SENSORS_TYPE_RATES = "sensors_rates" -SENSORS_TYPE_TEMPERATURES = "sensors_temperatures" _LOGGER = logging.getLogger(__name__) -def _get_dict(keys: list, values: list) -> dict[str, Any]: - """Create a dict from a list of keys and values.""" - ret_dict: dict[str, Any] = dict.fromkeys(keys) - - for index, key in enumerate(ret_dict): - ret_dict[key] = values[index] - - return ret_dict - - class AsusWrtSensorDataHandler: """Data handler for AsusWrt sensor.""" - def __init__(self, hass: HomeAssistant, api: AsusWrt) -> None: + def __init__(self, hass: HomeAssistant, api: AsusWrtBridge) -> None: """Initialize a AsusWrt sensor data handler.""" self._hass = hass self._api = api @@ -90,42 +61,6 @@ async def _get_connected_devices(self) -> dict[str, int]: """Return number of connected devices.""" return {SENSORS_CONNECTED_DEVICE[0]: self._connected_devices} - async def _get_bytes(self) -> dict[str, Any]: - """Fetch byte information from the router.""" - try: - datas = await self._api.async_get_bytes_total() - except (OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc - - return _get_dict(SENSORS_BYTES, datas) - - async def _get_rates(self) -> dict[str, Any]: - """Fetch rates information from the router.""" - try: - rates = await self._api.async_get_current_transfer_rates() - except (OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc - - return _get_dict(SENSORS_RATES, rates) - - async def _get_load_avg(self) -> dict[str, Any]: - """Fetch load average information from the router.""" - try: - avg = await self._api.async_get_loadavg() - except (OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc - - return _get_dict(SENSORS_LOAD_AVG, avg) - - async def _get_temperatures(self) -> dict[str, Any]: - """Fetch temperatures information from the router.""" - try: - temperatures: dict[str, Any] = await self._api.async_get_temperature() - except (OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc - - return temperatures - def update_device_count(self, conn_devices: int) -> bool: """Update connected devices attribute.""" if self._connected_devices == conn_devices: @@ -134,19 +69,17 @@ def update_device_count(self, conn_devices: int) -> bool: return True async def get_coordinator( - self, sensor_type: str, should_poll: bool = True + self, + sensor_type: str, + update_method: Callable[[], Any] | None = None, ) -> DataUpdateCoordinator: """Get the coordinator for a specific sensor type.""" + should_poll = True if sensor_type == SENSORS_TYPE_COUNT: + should_poll = False method = self._get_connected_devices - elif sensor_type == SENSORS_TYPE_BYTES: - method = self._get_bytes - elif sensor_type == SENSORS_TYPE_LOAD_AVG: - method = self._get_load_avg - elif sensor_type == SENSORS_TYPE_RATES: - method = self._get_rates - elif sensor_type == SENSORS_TYPE_TEMPERATURES: - method = self._get_temperatures + elif update_method is not None: + method = update_method else: raise RuntimeError(f"Invalid sensor type: {sensor_type}") @@ -226,12 +159,6 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self.hass = hass self._entry = entry - self._api: AsusWrt = None - self._protocol: str = entry.data[CONF_PROTOCOL] - self._host: str = entry.data[CONF_HOST] - self._model: str = "Asus Router" - self._sw_v: str | None = None - self._devices: dict[str, AsusWrtDevInfo] = {} self._connected_devices: int = 0 self._connect_error: bool = False @@ -248,26 +175,19 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: } self._options.update(entry.options) + self._api: AsusWrtBridge = AsusWrtBridge.get_bridge( + self.hass, dict(self._entry.data), self._options + ) + async def setup(self) -> None: """Set up a AsusWrt router.""" - self._api = get_api(dict(self._entry.data), self._options) - try: - await self._api.connection.async_connect() - except OSError as exp: - raise ConfigEntryNotReady from exp - + await self._api.async_connect() + except OSError as exc: + raise ConfigEntryNotReady from exc if not self._api.is_connected: raise ConfigEntryNotReady - # System - model = await get_nvram_info(self._api, "MODEL") - if model and "model" in model: - self._model = model["model"] - firmware = await get_nvram_info(self._api, "FIRMWARE") - if firmware and "firmver" in firmware and "buildno" in firmware: - self._sw_v = f"{firmware['firmver']} (build {firmware['buildno']})" - # Load tracked entities from registry entity_reg = er.async_get(self.hass) track_entries = er.async_entries_for_config_entry( @@ -312,24 +232,24 @@ async def update_all(self, now: datetime | None = None) -> None: async def update_devices(self) -> None: """Update AsusWrt devices tracker.""" new_device = False - _LOGGER.debug("Checking devices for ASUS router %s", self._host) + _LOGGER.debug("Checking devices for ASUS router %s", self.host) try: - api_devices = await self._api.async_get_connected_devices() - except OSError as exc: + wrt_devices = await self._api.async_get_connected_devices() + except UpdateFailed as exc: if not self._connect_error: self._connect_error = True _LOGGER.error( "Error connecting to ASUS router %s for device update: %s", - self._host, + self.host, exc, ) return if self._connect_error: self._connect_error = False - _LOGGER.info("Reconnected to ASUS router %s", self._host) + _LOGGER.info("Reconnected to ASUS router %s", self.host) - self._connected_devices = len(api_devices) + self._connected_devices = len(wrt_devices) consider_home: int = self._options.get( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() ) @@ -337,7 +257,6 @@ async def update_devices(self) -> None: CONF_TRACK_UNKNOWN, DEFAULT_TRACK_UNKNOWN ) - wrt_devices = {format_mac(mac): dev for mac, dev in api_devices.items()} for device_mac, device in self._devices.items(): dev_info = wrt_devices.pop(device_mac, None) device.update(dev_info, consider_home) @@ -363,19 +282,14 @@ async def init_sensors_coordinator(self) -> None: self._sensors_data_handler = AsusWrtSensorDataHandler(self.hass, self._api) self._sensors_data_handler.update_device_count(self._connected_devices) - sensors_types: dict[str, list[str]] = { - SENSORS_TYPE_BYTES: SENSORS_BYTES, - SENSORS_TYPE_COUNT: SENSORS_CONNECTED_DEVICE, - SENSORS_TYPE_LOAD_AVG: SENSORS_LOAD_AVG, - SENSORS_TYPE_RATES: SENSORS_RATES, - SENSORS_TYPE_TEMPERATURES: await self._get_available_temperature_sensors(), - } + sensors_types = await self._api.async_get_available_sensors() + sensors_types[SENSORS_TYPE_COUNT] = {KEY_SENSORS: SENSORS_CONNECTED_DEVICE} - for sensor_type, sensor_names in sensors_types.items(): - if not sensor_names: + for sensor_type, sensor_def in sensors_types.items(): + if not (sensor_names := sensor_def.get(KEY_SENSORS)): continue coordinator = await self._sensors_data_handler.get_coordinator( - sensor_type, sensor_type != SENSORS_TYPE_COUNT + sensor_type, update_method=sensor_def.get(KEY_METHOD) ) self._sensors_coordinator[sensor_type] = { KEY_COORDINATOR: coordinator, @@ -392,31 +306,10 @@ async def _update_unpolled_sensors(self) -> None: if self._sensors_data_handler.update_device_count(self._connected_devices): await coordinator.async_refresh() - async def _get_available_temperature_sensors(self) -> list[str]: - """Check which temperature information is available on the router.""" - try: - availability = await self._api.async_find_temperature_commands() - available_sensors = [ - SENSORS_TEMPERATURES[i] for i in range(3) if availability[i] - ] - except Exception as exc: # pylint: disable=broad-except - _LOGGER.debug( - ( - "Failed checking temperature sensor availability for ASUS router" - " %s. Exception: %s" - ), - self._host, - exc, - ) - return [] - - return available_sensors - async def close(self) -> None: """Close the connection.""" - if self._api is not None and self._protocol == PROTOCOL_TELNET: - self._api.connection.disconnect() - self._api = None + if self._api is not None: + await self._api.async_disconnect() for func in self._on_close: func() @@ -443,14 +336,17 @@ def update_options(self, new_options: dict[str, Any]) -> bool: @property def device_info(self) -> DeviceInfo: """Return the device information.""" - return DeviceInfo( + info = DeviceInfo( identifiers={(DOMAIN, self.unique_id or "AsusWRT")}, - name=self._host, - model=self._model, + name=self.host, + model=self._api.model or "Asus Router", manufacturer="Asus", - sw_version=self._sw_v, - configuration_url=f"http://{self._host}", + configuration_url=f"http://{self.host}", ) + if self._api.firmware: + info["sw_version"] = self._api.firmware + + return info @property def signal_device_new(self) -> str: @@ -465,7 +361,7 @@ def signal_device_update(self) -> str: @property def host(self) -> str: """Return router hostname.""" - return self._host + return self._api.host @property def unique_id(self) -> str | None: @@ -475,7 +371,7 @@ def unique_id(self) -> str | None: @property def name(self) -> str: """Return router name.""" - return self._host if self.unique_id else DEFAULT_NAME + return self.host if self.unique_id else DEFAULT_NAME @property def devices(self) -> dict[str, AsusWrtDevInfo]: @@ -486,32 +382,3 @@ def devices(self) -> dict[str, AsusWrtDevInfo]: def sensors_coordinator(self) -> dict[str, Any]: """Return sensors coordinators.""" return self._sensors_coordinator - - -async def get_nvram_info(api: AsusWrt, info_type: str) -> dict[str, Any]: - """Get AsusWrt router info from nvram.""" - info = {} - try: - info = await api.async_get_nvram(info_type) - except OSError as exc: - _LOGGER.warning("Error calling method async_get_nvram(%s): %s", info_type, exc) - - return info - - -def get_api(conf: dict[str, Any], options: dict[str, Any] | None = None) -> AsusWrt: - """Get the AsusWrt API.""" - opt = options or {} - - return AsusWrt( - conf[CONF_HOST], - conf.get(CONF_PORT), - conf[CONF_PROTOCOL] == PROTOCOL_TELNET, - conf[CONF_USERNAME], - conf.get(CONF_PASSWORD, ""), - conf.get(CONF_SSH_KEY, ""), - conf[CONF_MODE], - opt.get(CONF_REQUIRE_IP, True), - interface=opt.get(CONF_INTERFACE, DEFAULT_INTERFACE), - dnsmasq=opt.get(CONF_DNSMASQ, DEFAULT_DNSMASQ), - ) diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 95724ec3bb5599..accd1eba59bece 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -26,13 +26,15 @@ from .const import ( DATA_ASUSWRT, DOMAIN, + KEY_COORDINATOR, + KEY_SENSORS, SENSORS_BYTES, SENSORS_CONNECTED_DEVICE, SENSORS_LOAD_AVG, SENSORS_RATES, SENSORS_TEMPERATURES, ) -from .router import KEY_COORDINATOR, KEY_SENSORS, AsusWrtRouter +from .router import AsusWrtRouter @dataclass diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index ce52bd4fd6539a..9b2729f141e75c 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -15,6 +15,7 @@ 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 @@ def __init__(self, coordinator, atag_id): 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 236bf6cb082e0f..cafe24e2e1349b 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/__init__.py b/homeassistant/components/august/__init__.py index 8be7d8dd2d19b2..8738b58dab9d3a 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -23,7 +23,7 @@ ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import device_registry as dr, discovery_flow +from homeassistant.helpers import aiohttp_client, device_registry as dr, discovery_flow from .activity import ActivityStream from .const import CONF_BRAND, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS @@ -44,8 +44,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up August from a config entry.""" - - august_gateway = AugustGateway(hass) + # Create an aiohttp session instead of using the default one since the + # default one is likely to trigger august's WAF if another integration + # is also using Cloudflare + session = aiohttp_client.async_create_clientsession(hass) + august_gateway = AugustGateway(hass, session) try: await august_gateway.async_setup(entry.data) diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index d380ee11834028..c6f406a509499f 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -5,7 +5,6 @@ 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 @@ def __init__( 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 @@ async def async_added_to_hass(self) -> None: 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 @@ def __init__( 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/august/config_flow.py b/homeassistant/components/august/config_flow.py index 58f1c2fc9764ce..670d16084210d1 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -4,13 +4,16 @@ import logging from typing import Any +import aiohttp import voluptuous as vol from yalexs.authenticator import ValidationResult from yalexs.const import BRANDS, DEFAULT_BRAND from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client from .const import ( CONF_ACCESS_TOKEN_CACHE_FILE, @@ -80,6 +83,7 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Store an AugustGateway().""" self._august_gateway: AugustGateway | None = None + self._aiohttp_session: aiohttp.ClientSession | None = None self._user_auth_details: dict[str, Any] = {} self._needs_reset = True self._mode = None @@ -87,7 +91,6 @@ def __init__(self): async def async_step_user(self, user_input=None): """Handle the initial step.""" - self._august_gateway = AugustGateway(self.hass) return await self.async_step_user_validate() async def async_step_user_validate(self, user_input=None): @@ -151,12 +154,30 @@ async def async_step_validation( }, ) + @callback + def _async_get_gateway(self) -> AugustGateway: + """Set up the gateway.""" + if self._august_gateway is not None: + return self._august_gateway + # Create an aiohttp session instead of using the default one since the + # default one is likely to trigger august's WAF if another integration + # is also using Cloudflare + self._aiohttp_session = aiohttp_client.async_create_clientsession(self.hass) + self._august_gateway = AugustGateway(self.hass, self._aiohttp_session) + return self._august_gateway + + @callback + def _async_shutdown_gateway(self) -> None: + """Shutdown the gateway.""" + if self._aiohttp_session is not None: + self._aiohttp_session.detach() + self._august_gateway = None + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._user_auth_details = dict(entry_data) self._mode = "reauth" self._needs_reset = True - self._august_gateway = AugustGateway(self.hass) return await self.async_step_reauth_validate() async def async_step_reauth_validate(self, user_input=None): @@ -206,7 +227,7 @@ async def _async_reset_access_token_cache_if_needed( async def _async_auth_or_validate(self) -> ValidateResult: """Authenticate or validate.""" user_auth_details = self._user_auth_details - gateway = self._august_gateway + gateway = self._async_get_gateway() assert gateway is not None await self._async_reset_access_token_cache_if_needed( gateway, @@ -239,6 +260,8 @@ async def _async_auth_or_validate(self) -> ValidateResult: async def _async_update_or_create_entry(self, info: dict[str, Any]) -> FlowResult: """Update existing entry or create a new one.""" + self._async_shutdown_gateway() + existing_entry = await self.async_set_unique_id( self._user_auth_details[CONF_USERNAME] ) diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py index 9dcf96f057afa9..badff721d10940 100644 --- a/homeassistant/components/august/gateway.py +++ b/homeassistant/components/august/gateway.py @@ -7,7 +7,7 @@ import os from typing import Any -from aiohttp import ClientError, ClientResponseError +from aiohttp import ClientError, ClientResponseError, ClientSession from yalexs.api_async import ApiAsync from yalexs.authenticator_async import AuthenticationState, AuthenticatorAsync from yalexs.authenticator_common import Authentication @@ -16,7 +16,6 @@ from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client from .const import ( CONF_ACCESS_TOKEN_CACHE_FILE, @@ -35,12 +34,9 @@ class AugustGateway: """Handle the connection to August.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, aiohttp_session: ClientSession) -> None: """Init the connection.""" - # Create an aiohttp session instead of using the default one since the - # default one is likely to trigger august's WAF if another integration - # is also using Cloudflare - self._aiohttp_session = aiohttp_client.async_create_clientsession(hass) + self._aiohttp_session = aiohttp_session self._token_refresh_lock = asyncio.Lock() self._access_token_cache_file: str | None = None self._hass: HomeAssistant = hass diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index eeaa5f6c6222a7..ca4e799f16b0dd 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.5.1", "yalexs-ble==2.1.17"] + "requirements": ["yalexs==1.5.1", "yalexs-ble==2.1.18"] } diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 6e0969a6724c10..169a344e2bd00a 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -172,6 +172,7 @@ async def _async_migrate_old_unique_ids(hass, devices): registry.async_update_entity(old_entity_id, new_unique_id=device.unique_id) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): """Representation of an August lock operation sensor.""" diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index bac402fe633246..db054910d9aa33 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 @@ 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 ee2fc53691e929..a0e09685a0f7a4 100644 --- a/homeassistant/components/aurora/binary_sensor.py +++ b/homeassistant/components/aurora/binary_sensor.py @@ -4,8 +4,8 @@ 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 00000000000000..c126e2a8c68d1c --- /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 00000000000000..8948ff9c43cdaa --- /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 de5e566e2680c5..a5436e1e2190d6 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -5,8 +5,8 @@ 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 5a524851bdfbbc..6d3260a45f4a86 100644 --- a/homeassistant/components/aurora_abb_powerone/aurora_device.py +++ b/homeassistant/components/aurora_abb_powerone/aurora_device.py @@ -47,10 +47,10 @@ def available(self) -> bool: @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 27a8a65c27fda2..55f3be5d6dbc83 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -34,7 +34,7 @@ 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 @@ 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 bed403bd641b80..50b6e0db502d42 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 1ed146b6237d25..fa407949b40572 100644 --- a/homeassistant/components/aussie_broadband/sensor.py +++ b/homeassistant/components/aussie_broadband/sensor.py @@ -35,7 +35,7 @@ class SensorValueEntityDescription(SensorEntityDescription): # 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 @@ class SensorValueEntityDescription(SensorEntityDescription): ), 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 @@ class SensorValueEntityDescription(SensorEntityDescription): ), 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 @@ class SensorValueEntityDescription(SensorEntityDescription): # 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 @@ class SensorValueEntityDescription(SensorEntityDescription): ), 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 @@ class SensorValueEntityDescription(SensorEntityDescription): ), 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 @@ class SensorValueEntityDescription(SensorEntityDescription): ), 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 @@ class SensorValueEntityDescription(SensorEntityDescription): # 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 c2052defa816fc..90e4f094ee6fb4 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/auth/__init__.py b/homeassistant/components/auth/__init__.py index 1b47fb09393663..deaf3b7892d1d1 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -149,6 +149,7 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import OAuth2AuthorizeCallbackView from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -161,6 +162,8 @@ StoreResultType = Callable[[str, Credentials], str] RetrieveResultType = Callable[[str, str], Credentials | None] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @bind_hass def create_auth_code( diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 4712592edc7ff3..f4db7831235fb1 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 @@ -228,9 +229,23 @@ def automations_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list ] +@callback +def blueprint_in_automation(hass: HomeAssistant, entity_id: str) -> str | None: + """Return the blueprint the automation is based on or None.""" + if DOMAIN not in hass.data: + return None + + component: EntityComponent[BaseAutomationEntity] = hass.data[DOMAIN] + + if (automation_entity := component.get_entity(entity_id)) is None: + return None + + return automation_entity.referenced_blueprint + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up all automations.""" - hass.data[DOMAIN] = component = EntityComponent[AutomationEntity]( + hass.data[DOMAIN] = component = EntityComponent[BaseAutomationEntity]( LOGGER, DOMAIN, hass ) @@ -248,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( @@ -296,7 +311,103 @@ async def reload_service_handler(service_call: ServiceCall) -> None: 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 @@ -316,7 +427,7 @@ def __init__( 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 @@ -334,6 +445,11 @@ def __init__( 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.""" @@ -344,8 +460,6 @@ def extra_state_attributes(self) -> dict[str, Any]: } 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 @@ -578,6 +692,14 @@ async def async_will_remove_from_hass(self) -> None: await super().async_will_remove_from_hass() await self.async_disable() + async def _async_enable_automation(self, event: Event) -> None: + """Start automation on startup.""" + # Don't do anything if no longer enabled or already attached + if not self._is_enabled or self._async_detach_triggers is not None: + return + + self._async_detach_triggers = await self._async_attach_triggers(True) + async def async_enable(self) -> None: """Enable this automation entity. @@ -594,16 +716,8 @@ async def async_enable(self) -> None: self.async_write_ha_state() return - async def async_enable_automation(event: Event) -> None: - """Start automation on startup.""" - # Don't do anything if no longer enabled or already attached - if not self._is_enabled or self._async_detach_triggers is not None: - return - - self._async_detach_triggers = await self._async_attach_triggers(True) - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, async_enable_automation + EVENT_HOMEASSISTANT_STARTED, self._async_enable_automation ) self.async_write_ha_state() @@ -667,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( @@ -681,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, ) ) @@ -699,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 @@ -709,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( @@ -767,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. @@ -824,7 +954,7 @@ def find_matches( 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) @@ -846,8 +976,6 @@ def find_matches( 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] @@ -951,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 c127208377ff8a..ed801772e6dd76 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -41,7 +41,15 @@ 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 @@ 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 @@ 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 @@ def _log_invalid_automation( ) 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 @@ def _log_invalid_automation( "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 @@ def _log_invalid_automation( 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 @@ def _log_invalid_automation( 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 @@ def _log_invalid_automation( _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 @@ def _log_invalid_automation( _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 @@ def _log_invalid_automation( _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 cef2c7d1fd41c7..dca885ffe0d932 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 @@ 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 d483df6429872d..19341ab605012f 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 @@ 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 19e3339cef6c4a..25257bc3e1c421 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 f42a46999fbfd9..ee0febf1455895 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 . 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 6040bc1d7b05bd..731cd5db8ddb71 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/backup/__init__.py b/homeassistant/components/backup/__init__.py index 8d5f9764959cb6..8ce8bee779366c 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -1,6 +1,7 @@ """The Backup integration.""" from homeassistant.components.hassio import is_hassio from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, LOGGER @@ -8,6 +9,8 @@ from .manager import BackupManager from .websocket import async_register_websocket_handlers +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Backup integration.""" diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 1f8b70f4d35730..fe0d494a650984 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import integration_platform from homeassistant.helpers.json import save_json -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads_object from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER @@ -176,7 +176,7 @@ async def generate_backup(self) -> Backup: raise result backup_name = f"Core {HAVERSION}" - date_str = dt.now().isoformat() + date_str = dt_util.now().isoformat() slug = _generate_slug(date_str, backup_name) backup_data = { diff --git a/homeassistant/components/baf/binary_sensor.py b/homeassistant/components/baf/binary_sensor.py index fcfd0f3241d837..a68e80c3ac23e2 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 6798639e7a8b12..531659e901f1cd 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 22054d0b16d01a..4aeb287b861702 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 a166c346f12f16..059603fc589393 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 b177d383cd52ec..9557005e5ebe29 100644 --- a/homeassistant/components/baf/light.py +++ b/homeassistant/components/baf/light.py @@ -63,9 +63,11 @@ async def async_turn_off(self, **kwargs: Any) -> None: 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/manifest.json b/homeassistant/components/baf/manifest.json index b5b5b76967e690..37fd5cee7c6645 100644 --- a/homeassistant/components/baf/manifest.json +++ b/homeassistant/components/baf/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/baf", "iot_class": "local_push", - "requirements": ["aiobafi6==0.8.0"], + "requirements": ["aiobafi6==0.8.2"], "zeroconf": [ { "type": "_api._tcp.local.", diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index 020f34fefafbc8..7fd1c9ed290842 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 @@ class BAFNumberDescription(NumberEntityDescription, BAFNumberDescriptionMixin): ), 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 @@ class BAFNumberDescription(NumberEntityDescription, BAFNumberDescriptionMixin): ), 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 @@ class BAFNumberDescription(NumberEntityDescription, BAFNumberDescriptionMixin): 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 @@ class BAFNumberDescription(NumberEntityDescription, BAFNumberDescriptionMixin): ), 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 @@ class BAFNumberDescription(NumberEntityDescription, BAFNumberDescriptionMixin): 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 @@ class BAFNumberDescription(NumberEntityDescription, BAFNumberDescriptionMixin): ), 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 d8700886e0a53f..d811180414211f 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 @@ class BAFSensorDescription( 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 @@ class BAFSensorDescription( 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 @@ class BAFSensorDescription( ), 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 @@ class BAFSensorDescription( ), 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 59a20ea400c502..cb322320675b2a 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 d5236f9b861af1..ed4e635ece3459 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 @@ class BAFSwitchDescription( 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 @@ class BAFSwitchDescription( 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 @@ class BAFSwitchDescription( 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/baidu/tts.py b/homeassistant/components/baidu/tts.py index 72694248fa1cf5..05e2956c10eca0 100644 --- a/homeassistant/components/baidu/tts.py +++ b/homeassistant/components/baidu/tts.py @@ -104,7 +104,7 @@ def supported_options(self): """Return a list of supported options.""" return SUPPORTED_OPTIONS - def get_tts_audio(self, message, language, options=None): + def get_tts_audio(self, message, language, options): """Load TTS from BaiduTTS.""" aip_speech = AipSpeech( @@ -113,14 +113,11 @@ def get_tts_audio(self, message, language, options=None): self._app_data["secretkey"], ) - if options is None: - result = aip_speech.synthesis(message, language, 1, self._speech_conf_data) - else: - speech_data = self._speech_conf_data.copy() - for key, value in options.items(): - speech_data[_OPTIONS[key]] = value + speech_data = self._speech_conf_data.copy() + for key, value in options.items(): + speech_data[_OPTIONS[key]] = value - result = aip_speech.synthesis(message, language, 1, speech_data) + result = aip_speech.synthesis(message, language, 1, speech_data) if isinstance(result, dict): _LOGGER.error( diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py index 11a0cae0a01b76..9f363746a8fcdb 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 06e8d26550282f..0d0fa9bd179ccb 100644 --- a/homeassistant/components/balboa/climate.py +++ b/homeassistant/components/balboa/climate.py @@ -78,7 +78,7 @@ def hvac_mode(self) -> HVACMode | None: 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/balboa/entity.py b/homeassistant/components/balboa/entity.py index 1b2c8d48f0bbda..e50c35db47777f 100644 --- a/homeassistant/components/balboa/entity.py +++ b/homeassistant/components/balboa/entity.py @@ -4,7 +4,7 @@ from pybalboa import EVENT_UPDATE, SpaClient from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceClassName, DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN @@ -12,9 +12,7 @@ class BalboaBaseEntity(Entity): """Balboa base entity.""" - def __init__( - self, client: SpaClient, name: str | DeviceClassName | None = None - ) -> None: + def __init__(self, client: SpaClient, name: str | None = None) -> None: """Initialize the control.""" mac = client.mac_address model = client.model diff --git a/homeassistant/components/beewi_smartclim/manifest.json b/homeassistant/components/beewi_smartclim/manifest.json index f238c76d36652d..3555f9181bb6c8 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 d99f569ed591fa..1c2d6d779fba47 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 4da9bd4567066f..81d2ebf26a2f0d 100644 --- a/homeassistant/components/binary_sensor/device_condition.py +++ b/homeassistant/components/binary_sensor/device_condition.py @@ -256,7 +256,7 @@ 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 969a52d15149c0..de6dbdbe0756a7 100644 --- a/homeassistant/components/binary_sensor/device_trigger.py +++ b/homeassistant/components/binary_sensor/device_trigger.py @@ -70,62 +70,6 @@ CONF_NOT_OPENED = "not_opened" -TURNED_ON = [ - CONF_BAT_LOW, - CONF_CO, - CONF_COLD, - CONF_CONNECTED, - CONF_GAS, - CONF_HOT, - CONF_LIGHT, - CONF_NOT_LOCKED, - CONF_MOIST, - CONF_MOTION, - CONF_MOVING, - CONF_OCCUPIED, - CONF_OPENED, - CONF_PLUGGED_IN, - CONF_POWERED, - CONF_PRESENT, - CONF_PROBLEM, - CONF_RUNNING, - CONF_SMOKE, - CONF_SOUND, - CONF_UNSAFE, - CONF_UPDATE, - CONF_VIBRATION, - CONF_TAMPERED, - CONF_TURNED_ON, -] - -TURNED_OFF = [ - CONF_NOT_BAT_LOW, - CONF_NOT_COLD, - CONF_NOT_CONNECTED, - CONF_NOT_HOT, - CONF_LOCKED, - CONF_NOT_MOIST, - CONF_NOT_MOVING, - CONF_NOT_OCCUPIED, - CONF_NOT_OPENED, - CONF_NOT_PLUGGED_IN, - CONF_NOT_POWERED, - CONF_NOT_PRESENT, - CONF_NOT_TAMPERED, - CONF_NOT_UNSAFE, - CONF_NO_CO, - CONF_NO_GAS, - CONF_NO_LIGHT, - CONF_NO_MOTION, - CONF_NO_PROBLEM, - CONF_NOT_RUNNING, - CONF_NO_SMOKE, - CONF_NO_SOUND, - CONF_NO_VIBRATION, - CONF_TURNED_OFF, -] - - ENTITY_TRIGGERS = { BinarySensorDeviceClass.BATTERY: [ {CONF_TYPE: CONF_BAT_LOW}, @@ -168,8 +112,8 @@ {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}, @@ -245,10 +189,13 @@ ], } +TURNED_ON = [trigger[0][CONF_TYPE] for trigger in ENTITY_TRIGGERS.values()] +TURNED_OFF = [trigger[1][CONF_TYPE] for trigger in ENTITY_TRIGGERS.values()] + TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - 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, } @@ -307,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 f2bbc72e7a5f61..ee70420fec04a2 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -4,6 +4,8 @@ "condition_type": { "is_bat_low": "{entity_name} battery is low", "is_not_bat_low": "{entity_name} battery is normal", + "is_charging": "{entity_name} is charging", + "is_not_charging": "{entity_name} is not charging", "is_co": "{entity_name} is detecting carbon monoxide", "is_no_co": "{entity_name} is not detecting carbon monoxide", "is_cold": "{entity_name} is cold", @@ -56,6 +58,8 @@ "trigger_type": { "bat_low": "{entity_name} battery low", "not_bat_low": "{entity_name} battery normal", + "charging": "{entity_name} charging", + "not_charging": "{entity_name} not charging", "co": "{entity_name} started detecting carbon monoxide", "no_co": "{entity_name} stopped detecting carbon monoxide", "cold": "{entity_name} became cold", @@ -298,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 8cb7ddb5c1e37e..b639e28d6980ed 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 668a7f99c02512..b94a77fbf18397 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -8,7 +8,13 @@ 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 @@ DOMAIN, PLATFORMS, SERVICE_REFRESH, + SERVICE_SAVE_RECENT_CLIPS, SERVICE_SAVE_VIDEO, SERVICE_SEND_PIN, ) @@ -28,6 +35,9 @@ {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_save_video(call): """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 @@ def send_pin(call): 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 22a142ff44cb78..5d0ea67f31df76 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 @@ def __init__(self, data, name, sync): 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 9454daa85ec36e..c7daf0ec1e1d58 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 @@ 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 @@ def __init__( 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 e500eb79e42147..e74555f8db909e 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 8986782031fd3f..d58920562f4f1a 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -23,6 +23,7 @@ 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/manifest.json b/homeassistant/components/blink/manifest.json index 3e061df32a29e7..302a9f1e86a0a7 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.19.2"] + "requirements": ["blinkpy==0.21.0"] } diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index c051fef98f4fea..c996a90e54d047 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -25,14 +25,13 @@ 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 @@ def __init__(self, data, camera, description: SensorEntityDescription) -> None: ) 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 89af4799c858a2..3d51ba2f7bbe56 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 ae04f37714be8c..61c9a21af37043 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 70e5c2a4672819..e3a6638f2a9e55 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/__init__.py b/homeassistant/components/blueprint/__init__.py index 3087309f36ae45..1fe1ad8e189fac 100644 --- a/homeassistant/components/blueprint/__init__.py +++ b/homeassistant/components/blueprint/__init__.py @@ -1,5 +1,6 @@ """The blueprint integration.""" from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import websocket_api @@ -15,6 +16,8 @@ from .models import Blueprint, BlueprintInputs, DomainBlueprints # noqa: F401 from .schemas import is_blueprint_instance_config # noqa: F401 +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the blueprint integration.""" diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py index d857992a13cee8..4517d134e69d7d 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 @@ 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 2c48b473b73dab..bf4dbf81f01724 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -6,7 +6,6 @@ import platform from typing import TYPE_CHECKING -from awesomeversion import AwesomeVersion from bleak_retry_connector import BleakSlotManager from bluetooth_adapters import ( ADAPTER_ADDRESS, @@ -25,22 +24,18 @@ from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from homeassistant.components import usb -from homeassistant.config_entries import ( - SOURCE_IGNORE, - SOURCE_INTEGRATION_DISCOVERY, - ConfigEntry, -) -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP +from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HassJob, HomeAssistant, callback as hass_callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, discovery_flow +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + discovery_flow, +) from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) +from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.loader import async_get_bluetooth from . import models @@ -81,7 +76,7 @@ BluetoothScanningMode, HaBluetoothConnector, ) -from .scanner import HaScanner, ScannerStartError +from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError from .storage import BluetoothStorage if TYPE_CHECKING: @@ -113,11 +108,12 @@ "HaBluetoothConnector", "SOURCE_LOCAL", "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", + "MONOTONIC_TIME", ] _LOGGER = logging.getLogger(__name__) -RECOMMENDED_MIN_HAOS_VERSION = AwesomeVersion("9.0.dev0") +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def _async_get_adapter_from_address( @@ -127,43 +123,6 @@ async def _async_get_adapter_from_address( return await _get_manager(hass).async_get_adapter_from_address(address) -@hass_callback -def _async_haos_is_new_enough(hass: HomeAssistant) -> bool: - """Check if the version of Home Assistant Operating System is new enough.""" - # Only warn if a USB adapter is plugged in - if not any( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.source != SOURCE_IGNORE - ): - return True - if ( - not hass.components.hassio.is_hassio() - or not (os_info := hass.components.hassio.get_os_info()) - or not (haos_version := os_info.get("version")) - or AwesomeVersion(haos_version) >= RECOMMENDED_MIN_HAOS_VERSION - ): - return True - return False - - -@hass_callback -def _async_check_haos(hass: HomeAssistant) -> None: - """Create or delete an the haos_outdated issue.""" - if _async_haos_is_new_enough(hass): - async_delete_issue(hass, DOMAIN, "haos_outdated") - return - async_create_issue( - hass, - DOMAIN, - "haos_outdated", - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url="/config/updates", - translation_key="haos_outdated", - ) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the bluetooth integration.""" integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass)) @@ -236,12 +195,7 @@ def _async_trigger_discovery() -> None: EVENT_HOMEASSISTANT_STOP, hass_callback(lambda event: cancel()) ) - # Wait to check until after start to make sure - # that the system info is available. - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, - hass_callback(lambda event: _async_check_haos(hass)), - ) + async_delete_issue(hass, DOMAIN, "haos_outdated") return True diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 8f7750fe322054..e8de285138e8ac 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -299,10 +299,10 @@ def _async_on_advertisement( 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 @@ def _async_on_advertisement( 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 @@ def _async_on_advertisement( 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 3210822e795274..d1fcb115180cbb 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -246,9 +246,12 @@ def async_scanner_devices_by_address( 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 @@ def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]: """ 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 @@ def _async_check_unavailable(self, now: datetime) -> None: 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) @@ -409,23 +413,20 @@ def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: # Pre-filter noisy apple devices as they can account for 20-35% of the # traffic on a typical network. - advertisement_data = service_info.advertisement - manufacturer_data = advertisement_data.manufacturer_data if ( - len(manufacturer_data) == 1 - and (apple_data := manufacturer_data.get(APPLE_MFR_ID)) - and apple_data[0] not in APPLE_START_BYTES_WANTED - and not advertisement_data.service_data + (manufacturer_data := service_info.manufacturer_data) + and APPLE_MFR_ID in manufacturer_data + and manufacturer_data[APPLE_MFR_ID][0] not in APPLE_START_BYTES_WANTED + and len(manufacturer_data) == 1 + and not service_info.service_data ): return - device = service_info.device - address = device.address + address = service_info.device.address all_history = self._all_history connectable = service_info.connectable connectable_history = self._connectable_history old_connectable_service_info = connectable and connectable_history.get(address) - source = service_info.source # This logic is complex due to the many combinations of scanners # that are supported. @@ -540,13 +541,17 @@ def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: "%s: %s %s match: %s", self._async_describe_source(service_info), address, - advertisement_data, + service_info.advertisement, matched_domains, ) - if connectable or old_connectable_service_info: + if (connectable or old_connectable_service_info) and ( + bleak_callbacks := self._bleak_callbacks + ): # Bleak callbacks must get a connectable device - for callback_filters in self._bleak_callbacks: + device = service_info.device + advertisement_data = service_info.advertisement + for callback_filters in bleak_callbacks: _dispatch_bleak_callback(*callback_filters, device, advertisement_data) for match in self._callback_index.match_callbacks(service_info): @@ -583,7 +588,10 @@ def async_track_unavailable( 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 +628,13 @@ def _async_remove_callback() -> None: # 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 +650,32 @@ def async_ble_device_from_address( 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 +699,6 @@ def async_rediscover_address(self, address: str) -> None: 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 +707,10 @@ def async_register_scanner( ) -> 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 e7f95a2b63a3ef..dbe8ac3f1ab7df 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -1,7 +1,6 @@ { "domain": "bluetooth", "name": "Bluetooth", - "after_dependencies": ["hassio"], "codeowners": ["@bdraco"], "config_flow": true, "dependencies": ["logger", "usb"], @@ -19,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/strings.json b/homeassistant/components/bluetooth/strings.json index a988477778d97e..cae88ef24c19c8 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -1,10 +1,4 @@ { - "issues": { - "haos_outdated": { - "title": "Update to Home Assistant Operating System 9.0 or later", - "description": "To improve Bluetooth reliability and performance, we highly recommend you update to version 9.0 or later of the Home Assistant Operating System." - } - }, "config": { "flow_title": "{name}", "step": { diff --git a/homeassistant/components/bluetooth_adapters/__init__.py b/homeassistant/components/bluetooth_adapters/__init__.py index c2af10d5455bcd..3d5580aabf170f 100644 --- a/homeassistant/components/bluetooth_adapters/__init__.py +++ b/homeassistant/components/bluetooth_adapters/__init__.py @@ -2,10 +2,13 @@ from __future__ import annotations from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DOMAIN = "bluetooth_adapters" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Bluetooth Adapters from a config entry. diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index 659243df733016..f4fc6a8df08ee4 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -173,7 +173,11 @@ async def perform_bluetooth_update() -> None: rssi = await hass.async_add_executor_job(client.request_rssi) client.close() - tasks.append(see_device(hass, async_see, mac, friendly_name, rssi)) + tasks.append( + asyncio.create_task( + see_device(hass, async_see, mac, friendly_name, rssi) + ) + ) if tasks: await asyncio.wait(tasks) diff --git a/homeassistant/components/bluetooth_tracker/manifest.json b/homeassistant/components/bluetooth_tracker/manifest.json index b1411a41f87d1d..0a0356e666964d 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 640f4e3653beeb..c3be7ae189b6df 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -37,6 +37,7 @@ "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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 0ecc07357fed76..6edb1a3f2acbda 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -6,12 +6,14 @@ 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 @@ def __init__( 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,9 +133,9 @@ async def async_press(self) -> None: " 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 - # Always update HA states after a button was executed. - # BMW remote services that change the vehicle's state update the local object - # when executing the service, so only the HA state machine needs further updates. self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py index 37225fc052fbf3..96ef152307d1e7 100644 --- a/homeassistant/components/bmw_connected_drive/const.py +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -21,3 +21,9 @@ "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 f6354422312d4c..4a586aab3730e7 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -15,10 +15,8 @@ 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 @@ def __init__(self, hass: HomeAssistant, *, entry: ConfigEntry) -> 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 ffc6cf6d8b73d6..6608206a0ee0c4 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -4,12 +4,14 @@ 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,14 @@ async def async_lock(self, **kwargs: Any) -> None: # 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() async def async_unlock(self, **kwargs: Any) -> None: """Unlock the car.""" @@ -77,7 +86,14 @@ async def async_unlock(self, **kwargs: Any) -> None: # 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() @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index c9612d00c64319..82426fbce08336 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.6"] + "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 036d5147c4fdf8..4a9f7679dc4568 100644 --- a/homeassistant/components/bmw_connected_drive/notify.py +++ b/homeassistant/components/bmw_connected_drive/notify.py @@ -4,6 +4,7 @@ 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 @@ 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 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None: 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 820257f616329a..f37f7627140c5f 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, @@ -116,3 +116,5 @@ async def async_set_native_value(self, value: float) -> None: await self.entity_description.remote_service(self.vehicle, value) except MyBMWAPIError as ex: raise HomeAssistantError(ex) from ex + + self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 52d35b477a2d23..3467322a4af051 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -4,6 +4,7 @@ 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.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 @@ class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): ), "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,4 +125,9 @@ async def async_select_option(self, option: str) -> None: 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 314ff47c14cf65..8f5b4fb8608a4c 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -55,7 +55,7 @@ def convert_and_round( # --- 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 @@ def convert_and_round( ), "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 @@ def convert_and_round( # --- 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 @@ def convert_and_round( ), "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 @@ def convert_and_round( ), "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 @@ def convert_and_round( ), "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 @@ def convert_and_round( ), "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 506175becd9187..af73417b1a93e8 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 af7a42b35b0a69..298338dc9fa339 100644 --- a/homeassistant/components/bmw_connected_drive/switch.py +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -7,6 +7,7 @@ from bimmer_connected.models import MyBMWAPIError from bimmer_connected.vehicle import MyBMWVehicle +from bimmer_connected.vehicle.fuel_and_battery import ChargingState from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -38,16 +39,34 @@ class BMWSwitchEntityDescription(SwitchEntityDescription, BMWRequiredKeysMixin): dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None +CHARGING_STATE_ON = { + ChargingState.CHARGING, + ChargingState.COMPLETE, + ChargingState.FULLY_CHARGED, + ChargingState.FINISHED_FULLY_CHARGED, + ChargingState.FINISHED_NOT_FULL, + ChargingState.TARGET_REACHED, +} + NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ BMWSwitchEntityDescription( key="climate", - name="Climate", + 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(), remote_service_off=lambda v: v.remote_services.trigger_remote_air_conditioning_stop(), icon="mdi:fan", ), + BMWSwitchEntityDescription( + key="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(), + remote_service_off=lambda v: v.remote_services.trigger_charge_stop(), + icon="mdi:ev-station", + ), ] @@ -101,9 +120,13 @@ async def async_turn_on(self, **kwargs: Any) -> None: except MyBMWAPIError as ex: raise HomeAssistantError(ex) from ex + self.coordinator.async_update_listeners() + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" try: await self.entity_description.remote_service_off(self.vehicle) except MyBMWAPIError as ex: raise HomeAssistantError(ex) from ex + + self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index d3fc58a35beaf4..1109cf0d311470 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -35,9 +35,8 @@ class BondButtonEntityDescription( ): """Class to describe a Bond Button entity.""" - # BondEntity does not support DEVICE_CLASS_NAME - # Restrict the type to satisfy the type checker and catch attempts - # to use DEVICE_CLASS_NAME in the entity descriptions. + # BondEntity does not support UNDEFINED, + # restrict the type to str | None name: str | None = None diff --git a/homeassistant/components/bosch_shc/manifest.json b/homeassistant/components/bosch_shc/manifest.json index 905589365922e2..9fd1055dd60403 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 b310799323a87b..73307d9ea0a407 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 6fe06213d759ab..3b3b6e2ffd47f2 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 ff5691f9aed216..cfa388fcce7e5d 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 f45b2d740041b8..f9e3f464dcbb0a 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 3b1312a64c5ce9..e6a769fd2c4644 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -5,12 +5,15 @@ 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 4bbb3fe1513698..c0fb80971ca1e8 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -39,7 +39,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DOMAIN from .entity import BroadlinkEntity @@ -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.""" @@ -330,8 +331,8 @@ async def _async_learn_ir_command(self, command): ) try: - start_time = dt.utcnow() - while (dt.utcnow() - start_time) < LEARNING_TIMEOUT: + start_time = dt_util.utcnow() + while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) try: code = await device.async_request(device.api.check_data) @@ -368,8 +369,8 @@ async def _async_learn_rf_command(self, command): ) try: - start_time = dt.utcnow() - while (dt.utcnow() - start_time) < LEARNING_TIMEOUT: + start_time = dt_util.utcnow() + while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) found = await device.async_request(device.api.check_frequency) if found: @@ -403,8 +404,8 @@ async def _async_learn_rf_command(self, command): ) try: - start_time = dt.utcnow() - while (dt.utcnow() - start_time) < LEARNING_TIMEOUT: + start_time = dt_util.utcnow() + while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) try: code = await device.async_request(device.api.check_data) diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index 50c58d41667ec6..747418e1e7961a 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -25,18 +25,16 @@ 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 @@ ), 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 adff2303c74156..87567bcb7b173a 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 009536a9adb1b5..b87448658981c2 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/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index f3837c732633fd..da8461bf90fc49 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -6,7 +6,7 @@ from broadlink.exceptions import AuthorizationError, BroadlinkException from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -64,7 +64,7 @@ async def async_update(self): except (BroadlinkException, OSError) as err: if self.available and ( - dt.utcnow() - self.last_update > self.SCAN_INTERVAL * 3 + dt_util.utcnow() - self.last_update > self.SCAN_INTERVAL * 3 or isinstance(err, (AuthorizationError, OSError)) ): self.available = False @@ -84,7 +84,7 @@ async def async_update(self): self.device.api.host[0], ) self.available = True - self.last_update = dt.utcnow() + self.last_update = dt_util.utcnow() return data @abstractmethod diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index ca6173d2ef596f..5512bcd1176ff8 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/browser/__init__.py b/homeassistant/components/browser/__init__.py index 954621ed66ff8a..b01f04fa1401d2 100644 --- a/homeassistant/components/browser/__init__.py +++ b/homeassistant/components/browser/__init__.py @@ -4,6 +4,7 @@ import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType ATTR_URL = "url" @@ -20,6 +21,8 @@ } ) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + def _browser_url(service: ServiceCall) -> None: """Browse to URL.""" diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index 1df34929b1cbed..3fb328ab7fbc5c 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -88,7 +88,7 @@ def __init__( self._attr_attribution = ATTRIBUTION self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, # type: ignore[arg-type] - name=self._thing.name, + name=self._attr_name, via_device=(DOMAIN, self._entry_id), manufacturer="Brunt", sw_version=self._thing.fw_version, diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index cbc6dd00471b24..dc403611da2e2e 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -23,6 +23,7 @@ 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 @@ def target_temperature(self) -> float | None: 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 90f5d92a0a22d6..8f2dc631e8061a 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/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index ef3d9bc002d108..91f4940a4e57c9 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==2.11.3"] + "requirements": ["bthome-ble==2.12.0"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index f8693c5fb348bf..fc8673e801bfea 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -47,6 +47,15 @@ from .device import device_key_to_bluetooth_entity_key SENSOR_DESCRIPTIONS = { + # Acceleration (m/s²) + ( + BTHomeSensorDeviceClass.ACCELERATION, + Units.ACCELERATION_METERS_PER_SQUARE_SECOND, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.ACCELERATION}_{Units.ACCELERATION_METERS_PER_SQUARE_SECOND}", + native_unit_of_measurement=Units.ACCELERATION_METERS_PER_SQUARE_SECOND, + state_class=SensorStateClass.MEASUREMENT, + ), # Battery (percent) (BTHomeSensorDeviceClass.BATTERY, Units.PERCENTAGE): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.BATTERY}_{Units.PERCENTAGE}", @@ -131,6 +140,15 @@ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL, ), + # Gyroscope (°/s) + ( + BTHomeSensorDeviceClass.GYROSCOPE, + Units.GYROSCOPE_DEGREES_PER_SECOND, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.GYROSCOPE}_{Units.GYROSCOPE_DEGREES_PER_SECOND}", + native_unit_of_measurement=Units.GYROSCOPE_DEGREES_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + ), # Humidity in (percent) (BTHomeSensorDeviceClass.HUMIDITY, Units.PERCENTAGE): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.HUMIDITY}_{Units.PERCENTAGE}", @@ -242,6 +260,15 @@ native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), + # Timestamp (datetime object) + ( + BTHomeSensorDeviceClass.TIMESTAMP, + None, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.TIMESTAMP}", + device_class=SensorDeviceClass.TIMESTAMP, + state_class=SensorStateClass.MEASUREMENT, + ), # UV index (-) ( BTHomeSensorDeviceClass.UV_INDEX, diff --git a/homeassistant/components/buienradar/const.py b/homeassistant/components/buienradar/const.py index 6af579dd74f65f..8111f63c923f70 100644 --- a/homeassistant/components/buienradar/const.py +++ b/homeassistant/components/buienradar/const.py @@ -18,3 +18,49 @@ SCHEDULE_OK = 10 """When an error occurred, new call after (minutes).""" SCHEDULE_NOK = 2 + +STATE_CONDITIONS = ["clear", "cloudy", "fog", "rainy", "snowy", "lightning"] + +STATE_DETAILED_CONDITIONS = [ + "clear", + "partlycloudy", + "partlycloudy-fog", + "partlycloudy-light-rain", + "partlycloudy-rain", + "cloudy", + "fog", + "rainy", + "light-rain", + "light-snow", + "partlycloudy-light-snow", + "partlycloudy-snow", + "partlycloudy-lightning", + "snowy", + "snowy-rainy", + "lightning", +] + +STATE_CONDITION_CODES = [ + "a", + "b", + "j", + "o", + "r", + "c", + "p", + "d", + "n", + "f", + "h", + "k", + "l", + "q", + "w", + "m", + "u", + "i", + "v", + "t", + "g", + "s", +] diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 06b97cdedadb2b..b5c6e9cf32c667 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -48,7 +48,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import CONF_TIMEFRAME, DEFAULT_TIMEFRAME, DOMAIN +from .const import ( + CONF_TIMEFRAME, + DEFAULT_TIMEFRAME, + DOMAIN, + STATE_CONDITION_CODES, + STATE_CONDITIONS, + STATE_DETAILED_CONDITIONS, +) from .util import BrData _LOGGER = logging.getLogger(__name__) @@ -67,584 +74,620 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="stationname", - name=STATIONNAME_LABEL, + translation_key="stationname", ), # new in json api (>1.0.0): SensorEntityDescription( key="barometerfc", - name="Barometer value", + translation_key="barometerfc", icon="mdi:gauge", ), # new in json api (>1.0.0): SensorEntityDescription( key="barometerfcname", - name="Barometer", + translation_key="barometerfcname", icon="mdi:gauge", ), # new in json api (>1.0.0): SensorEntityDescription( key="barometerfcnamenl", - name="Barometer", + translation_key="barometerfcnamenl", icon="mdi:gauge", ), SensorEntityDescription( key="condition", - name="Condition", + translation_key="condition", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="conditioncode", - name="Condition code", + translation_key="conditioncode", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditiondetailed", - name="Detailed condition", + translation_key="conditiondetailed", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditionexact", - name="Full condition", + translation_key="conditionexact", ), SensorEntityDescription( key="symbol", - name="Symbol", + translation_key="symbol", ), # new in json api (>1.0.0): SensorEntityDescription( key="feeltemperature", - name="Feel temperature", + translation_key="feeltemperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="humidity", - name="Humidity", + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, icon="mdi:water-percent", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="temperature", - name="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="groundtemperature", - name="Ground temperature", + translation_key="groundtemperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="windspeed", - name="Wind speed", + translation_key="windspeed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="windforce", - name="Wind force", + translation_key="windforce", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="winddirection", - name="Wind direction", + translation_key="winddirection", icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth", - name="Wind direction azimuth", + translation_key="windazimuth", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="pressure", - name="Pressure", + translation_key="pressure", native_unit_of_measurement=UnitOfPressure.HPA, icon="mdi:gauge", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="visibility", - name="Visibility", + translation_key="visibility", native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="windgust", - name="Wind gust", + translation_key="windgust", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="precipitation", - name="Precipitation", + translation_key="precipitation", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SensorEntityDescription( key="irradiance", - name="Irradiance", + translation_key="irradiance", device_class=SensorDeviceClass.IRRADIANCE, native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="precipitation_forecast_average", - name="Precipitation forecast average", + translation_key="precipitation_forecast_average", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SensorEntityDescription( key="precipitation_forecast_total", - name="Precipitation forecast total", + translation_key="precipitation_forecast_total", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), # new in json api (>1.0.0): SensorEntityDescription( key="rainlast24hour", - name="Rain last 24h", + translation_key="rainlast24hour", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), # new in json api (>1.0.0): SensorEntityDescription( key="rainlasthour", - name="Rain last hour", + translation_key="rainlasthour", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="temperature_1d", - name="Temperature 1d", + translation_key="temperature_1d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="temperature_2d", - name="Temperature 2d", + translation_key="temperature_2d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="temperature_3d", - name="Temperature 3d", + translation_key="temperature_3d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="temperature_4d", - name="Temperature 4d", + translation_key="temperature_4d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="temperature_5d", - name="Temperature 5d", + translation_key="temperature_5d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="mintemp_1d", - name="Minimum temperature 1d", + translation_key="mintemp_1d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="mintemp_2d", - name="Minimum temperature 2d", + translation_key="mintemp_2d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="mintemp_3d", - name="Minimum temperature 3d", + translation_key="mintemp_3d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="mintemp_4d", - name="Minimum temperature 4d", + translation_key="mintemp_4d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="mintemp_5d", - name="Minimum temperature 5d", + translation_key="mintemp_5d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="rain_1d", - name="Rain 1d", + translation_key="rain_1d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="rain_2d", - name="Rain 2d", + translation_key="rain_2d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="rain_3d", - name="Rain 3d", + translation_key="rain_3d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="rain_4d", - name="Rain 4d", + translation_key="rain_4d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="rain_5d", - name="Rain 5d", + translation_key="rain_5d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), # new in json api (>1.0.0): SensorEntityDescription( key="minrain_1d", - name="Minimum rain 1d", + translation_key="minrain_1d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="minrain_2d", - name="Minimum rain 2d", + translation_key="minrain_2d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="minrain_3d", - name="Minimum rain 3d", + translation_key="minrain_3d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="minrain_4d", - name="Minimum rain 4d", + translation_key="minrain_4d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="minrain_5d", - name="Minimum rain 5d", + translation_key="minrain_5d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), # new in json api (>1.0.0): SensorEntityDescription( key="maxrain_1d", - name="Maximum rain 1d", + translation_key="maxrain_1d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="maxrain_2d", - name="Maximum rain 2d", + translation_key="maxrain_2d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="maxrain_3d", - name="Maximum rain 3d", + translation_key="maxrain_3d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="maxrain_4d", - name="Maximum rain 4d", + translation_key="maxrain_4d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="maxrain_5d", - name="Maximum rain 5d", + translation_key="maxrain_5d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="rainchance_1d", - name="Rainchance 1d", + translation_key="rainchance_1d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-pouring", ), SensorEntityDescription( key="rainchance_2d", - name="Rainchance 2d", + translation_key="rainchance_2d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-pouring", ), SensorEntityDescription( key="rainchance_3d", - name="Rainchance 3d", + translation_key="rainchance_3d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-pouring", ), SensorEntityDescription( key="rainchance_4d", - name="Rainchance 4d", + translation_key="rainchance_4d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-pouring", ), SensorEntityDescription( key="rainchance_5d", - name="Rainchance 5d", + translation_key="rainchance_5d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-pouring", ), SensorEntityDescription( key="sunchance_1d", - name="Sunchance 1d", + translation_key="sunchance_1d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-partly-cloudy", ), SensorEntityDescription( key="sunchance_2d", - name="Sunchance 2d", + translation_key="sunchance_2d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-partly-cloudy", ), SensorEntityDescription( key="sunchance_3d", - name="Sunchance 3d", + translation_key="sunchance_3d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-partly-cloudy", ), SensorEntityDescription( key="sunchance_4d", - name="Sunchance 4d", + translation_key="sunchance_4d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-partly-cloudy", ), SensorEntityDescription( key="sunchance_5d", - name="Sunchance 5d", + translation_key="sunchance_5d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-partly-cloudy", ), SensorEntityDescription( key="windforce_1d", - name="Wind force 1d", + translation_key="windforce_1d", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="windforce_2d", - name="Wind force 2d", + translation_key="windforce_2d", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="windforce_3d", - name="Wind force 3d", + translation_key="windforce_3d", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="windforce_4d", - name="Wind force 4d", + translation_key="windforce_4d", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="windforce_5d", - name="Wind force 5d", + translation_key="windforce_5d", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="windspeed_1d", - name="Wind speed 1d", + translation_key="windspeed_1d", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="windspeed_2d", - name="Wind speed 2d", + translation_key="windspeed_2d", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="windspeed_3d", - name="Wind speed 3d", + translation_key="windspeed_3d", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="windspeed_4d", - name="Wind speed 4d", + translation_key="windspeed_4d", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="windspeed_5d", - name="Wind speed 5d", + translation_key="windspeed_5d", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="winddirection_1d", - name="Wind direction 1d", + translation_key="winddirection_1d", icon="mdi:compass-outline", ), SensorEntityDescription( key="winddirection_2d", - name="Wind direction 2d", + translation_key="winddirection_2d", icon="mdi:compass-outline", ), SensorEntityDescription( key="winddirection_3d", - name="Wind direction 3d", + translation_key="winddirection_3d", icon="mdi:compass-outline", ), SensorEntityDescription( key="winddirection_4d", - name="Wind direction 4d", + translation_key="winddirection_4d", icon="mdi:compass-outline", ), SensorEntityDescription( key="winddirection_5d", - name="Wind direction 5d", + translation_key="winddirection_5d", icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth_1d", - name="Wind direction azimuth 1d", + translation_key="windazimuth_1d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth_2d", - name="Wind direction azimuth 2d", + translation_key="windazimuth_2d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth_3d", - name="Wind direction azimuth 3d", + translation_key="windazimuth_3d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth_4d", - name="Wind direction azimuth 4d", + translation_key="windazimuth_4d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth_5d", - name="Wind direction azimuth 5d", + translation_key="windazimuth_5d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="condition_1d", - name="Condition 1d", + translation_key="condition_1d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="condition_2d", - name="Condition 2d", + translation_key="condition_2d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="condition_3d", - name="Condition 3d", + translation_key="condition_3d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="condition_4d", - name="Condition 4d", + translation_key="condition_4d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="condition_5d", - name="Condition 5d", + translation_key="condition_5d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="conditioncode_1d", - name="Condition code 1d", + translation_key="conditioncode_1d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditioncode_2d", - name="Condition code 2d", + translation_key="conditioncode_2d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditioncode_3d", - name="Condition code 3d", + translation_key="conditioncode_3d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditioncode_4d", - name="Condition code 4d", + translation_key="conditioncode_4d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditioncode_5d", - name="Condition code 5d", + translation_key="conditioncode_5d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditiondetailed_1d", - name="Detailed condition 1d", + translation_key="conditiondetailed_1d", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditiondetailed_2d", - name="Detailed condition 2d", + translation_key="conditiondetailed_2d", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditiondetailed_3d", - name="Detailed condition 3d", + translation_key="conditiondetailed_3d", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditiondetailed_4d", - name="Detailed condition 4d", + translation_key="conditiondetailed_4d", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditiondetailed_5d", - name="Detailed condition 5d", + translation_key="conditiondetailed_5d", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditionexact_1d", - name="Full condition 1d", + translation_key="conditionexact_1d", ), SensorEntityDescription( key="conditionexact_2d", - name="Full condition 2d", + translation_key="conditionexact_2d", ), SensorEntityDescription( key="conditionexact_3d", - name="Full condition 3d", + translation_key="conditionexact_3d", ), SensorEntityDescription( key="conditionexact_4d", - name="Full condition 4d", + translation_key="conditionexact_4d", ), SensorEntityDescription( key="conditionexact_5d", - name="Full condition 5d", + translation_key="conditionexact_5d", ), SensorEntityDescription( key="symbol_1d", - name="Symbol 1d", + translation_key="symbol_1d", ), SensorEntityDescription( key="symbol_2d", - name="Symbol 2d", + translation_key="symbol_2d", ), SensorEntityDescription( key="symbol_3d", - name="Symbol 3d", + translation_key="symbol_3d", ), SensorEntityDescription( key="symbol_4d", - name="Symbol 4d", + translation_key="symbol_4d", ), SensorEntityDescription( key="symbol_5d", - name="Symbol 5d", + translation_key="symbol_5d", ), ) @@ -689,17 +732,17 @@ async def async_setup_entry( class BrSensor(SensorEntity): - """Representation of an Buienradar sensor.""" + """Representation of a Buienradar sensor.""" _attr_entity_registry_enabled_default = False _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, client_name, coordinates, description: SensorEntityDescription ) -> None: """Initialize the sensor.""" self.entity_description = description - self._attr_name = f"{client_name} {description.name}" self._measured = None self._attr_unique_id = "{:2.6f}{:2.6f}{}".format( coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], description.key diff --git a/homeassistant/components/buienradar/strings.json b/homeassistant/components/buienradar/strings.json index 740068a952bac7..bac4e63e288fe6 100644 --- a/homeassistant/components/buienradar/strings.json +++ b/homeassistant/components/buienradar/strings.json @@ -25,5 +25,483 @@ } } } + }, + "entity": { + "sensor": { + "stationname": { + "name": "Station name" + }, + "barometerfc": { + "name": "Barometer value" + }, + "barometerfcname": { + "name": "Barometer" + }, + "barometerfcnamenl": { + "name": "Barometer" + }, + "condition": { + "name": "Condition", + "state": { + "clear": "Clear", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" + } + }, + "conditioncode": { + "name": "Condition code" + }, + "conditiondetailed": { + "name": "Detailed condition", + "state": { + "clear": "Clear", + "partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]", + "partlycloudy-fog": "Partly cloudy, fog", + "partlycloudy-light-rain": "Partly cloudy, light rain", + "partlycloudy-rain": "Partly cloudy, rain", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "light-rain": "Light rain", + "light-snow": "Light snow", + "partlycloudy-light-snow": "Partly cloudy, light snow", + "partlycloudy-snow": "Partly cloudy, snow", + "partlycloudy-lightning": "Partly cloudy, lightning", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" + } + }, + "conditionexact": { + "name": "Full condition" + }, + "symbol": { + "name": "Symbol" + }, + "feeltemperature": { + "name": "Feel temperature" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "groundtemperature": { + "name": "Ground temperature" + }, + "windspeed": { + "name": "[%key:component::sensor::entity_component::wind_speed::name%]" + }, + "windforce": { + "name": "Wind force" + }, + "winddirection": { + "name": "Wind direction" + }, + "windazimuth": { + "name": "Wind direction azimuth" + }, + "pressure": { + "name": "[%key:component::sensor::entity_component::pressure::name%]" + }, + "visibility": { + "name": "[%key:component::weather::entity_component::_::state_attributes::visibility::name%]" + }, + "windgust": { + "name": "Wind gust" + }, + "precipitation": { + "name": "[%key:component::sensor::entity_component::precipitation::name%]" + }, + "irradiance": { + "name": "[%key:component::sensor::entity_component::irradiance::name%]" + }, + "precipitation_forecast_average": { + "name": "Precipitation forecast average" + }, + "precipitation_forecast_total": { + "name": "Precipitation forecast total" + }, + "rainlast24hour": { + "name": "Rain last 24h" + }, + "rainlasthour": { + "name": "Rain last hour" + }, + "temperature_1d": { + "name": "Temperature 1d" + }, + "temperature_2d": { + "name": "Temperature 2d" + }, + "temperature_3d": { + "name": "Temperature 3d" + }, + "temperature_4d": { + "name": "Temperature 4d" + }, + "temperature_5d": { + "name": "Temperature 5d" + }, + "mintemp_1d": { + "name": "Minimum temperature 1d" + }, + "mintemp_2d": { + "name": "Minimum temperature 2d" + }, + "mintemp_3d": { + "name": "Minimum temperature 3d" + }, + "mintemp_4d": { + "name": "Minimum temperature 4d" + }, + "mintemp_5d": { + "name": "Minimum temperature 5d" + }, + "rain_1d": { + "name": "Rain 1d" + }, + "rain_2d": { + "name": "Rain 2d" + }, + "rain_3d": { + "name": "Rain 3d" + }, + "rain_4d": { + "name": "Rain 4d" + }, + "rain_5d": { + "name": "Rain 5d" + }, + "minrain_1d": { + "name": "Minimum rain 1d" + }, + "minrain_2d": { + "name": "Minimum rain 2d" + }, + "minrain_3d": { + "name": "Minimum rain 3d" + }, + "minrain_4d": { + "name": "Minimum rain 4d" + }, + "minrain_5d": { + "name": "Minimum rain 5d" + }, + "maxrain_1d": { + "name": "Maximum rain 1d" + }, + "maxrain_2d": { + "name": "Maximum rain 2d" + }, + "maxrain_3d": { + "name": "Maximum rain 3d" + }, + "maxrain_4d": { + "name": "Maximum rain 4d" + }, + "maxrain_5d": { + "name": "Maximum rain 5d" + }, + "rainchance_1d": { + "name": "Rainchance 1d" + }, + "rainchance_2d": { + "name": "Rainchance 2d" + }, + "rainchance_3d": { + "name": "Rainchance 3d" + }, + "rainchance_4d": { + "name": "Rainchance 4d" + }, + "rainchance_5d": { + "name": "Rainchance 5d" + }, + "sunchance_1d": { + "name": "Sunchance 1d" + }, + "sunchance_2d": { + "name": "Sunchance 2d" + }, + "sunchance_3d": { + "name": "Sunchance 3d" + }, + "sunchance_4d": { + "name": "Sunchance 4d" + }, + "sunchance_5d": { + "name": "Sunchance 5d" + }, + "windforce_1d": { + "name": "Wind force 1d" + }, + "windforce_2d": { + "name": "Wind force 2d" + }, + "windforce_3d": { + "name": "Wind force 3d" + }, + "windforce_4d": { + "name": "Wind force 4d" + }, + "windforce_5d": { + "name": "Wind force 5d" + }, + "windspeed_1d": { + "name": "Wind speed 1d" + }, + "windspeed_2d": { + "name": "Wind speed 2d" + }, + "windspeed_3d": { + "name": "Wind speed 3d" + }, + "windspeed_4d": { + "name": "Wind speed 4d" + }, + "windspeed_5d": { + "name": "Wind speed 5d" + }, + "winddirection_1d": { + "name": "Wind direction 1d" + }, + "winddirection_2d": { + "name": "Wind direction 2d" + }, + "winddirection_3d": { + "name": "Wind direction 3d" + }, + "winddirection_4d": { + "name": "Wind direction 4d" + }, + "winddirection_5d": { + "name": "Wind direction 5d" + }, + "windazimuth_1d": { + "name": "Wind direction azimuth 1d" + }, + "windazimuth_2d": { + "name": "Wind direction azimuth 2d" + }, + "windazimuth_3d": { + "name": "Wind direction azimuth 3d" + }, + "windazimuth_4d": { + "name": "Wind direction azimuth 4d" + }, + "windazimuth_5d": { + "name": "Wind direction azimuth 5d" + }, + "condition_1d": { + "name": "Condition 1d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" + } + }, + "condition_2d": { + "name": "Condition 2d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" + } + }, + "condition_3d": { + "name": "Condition 3d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" + } + }, + "condition_4d": { + "name": "Condition 4d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" + } + }, + "condition_5d": { + "name": "Condition 5d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" + } + }, + "conditioncode_1d": { + "name": "Condition code 1d" + }, + "conditioncode_2d": { + "name": "Condition code 2d" + }, + "conditioncode_3d": { + "name": "Condition code 3d" + }, + "conditioncode_4d": { + "name": "Condition code 4d" + }, + "conditioncode_5d": { + "name": "Condition code 5d" + }, + "conditiondetailed_1d": { + "name": "Detailed condition 1d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", + "partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]", + "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", + "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", + "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", + "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", + "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", + "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", + "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" + } + }, + "conditiondetailed_2d": { + "name": "Detailed condition 2d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", + "partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]", + "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", + "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", + "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", + "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", + "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", + "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", + "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" + } + }, + "conditiondetailed_3d": { + "name": "Detailed condition 3d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", + "partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]", + "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", + "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", + "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", + "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", + "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", + "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", + "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" + } + }, + "conditiondetailed_4d": { + "name": "Detailed condition 4d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", + "partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]", + "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", + "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", + "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", + "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", + "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", + "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", + "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", + "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", + "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", + "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + } + }, + "conditiondetailed_5d": { + "name": "Detailed condition 5d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", + "partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]", + "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", + "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", + "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", + "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", + "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", + "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", + "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" + } + }, + "conditionexact_1d": { + "name": "Full condition 1d" + }, + "conditionexact_2d": { + "name": "Full condition 2d" + }, + "conditionexact_3d": { + "name": "Full condition 3d" + }, + "conditionexact_4d": { + "name": "Full condition 4d" + }, + "conditionexact_5d": { + "name": "Full condition 5d" + }, + "symbol_1d": { + "name": "Symbol 1d" + }, + "symbol_2d": { + "name": "Symbol 2d" + }, + "symbol_3d": { + "name": "Symbol 3d" + }, + "symbol_4d": { + "name": "Symbol 4d" + }, + "symbol_5d": { + "name": "Symbol 5d" + } + } } } diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 99fe02f7a9df1b..0e2790a2e85b4a 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -35,6 +35,7 @@ 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 8398b4990cd64d..338b11e765bf1f 100644 --- a/homeassistant/components/button/device_action.py +++ b/homeassistant/components/button/device_action.py @@ -3,6 +3,7 @@ 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 @@ 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 fbf054996c3d35..1b206337f33555 100644 --- a/homeassistant/components/button/device_trigger.py +++ b/homeassistant/components/button/device_trigger.py @@ -26,7 +26,7 @@ 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 4fd88853893724..a92a5a0f38a7ad 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/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 9a01cd2186fff2..e4892ae0383e4d 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -1,7 +1,7 @@ """Support for WebDav Calendar.""" from __future__ import annotations -from datetime import date, datetime, timedelta +from datetime import date, datetime, time, timedelta from functools import partial import logging import re @@ -29,7 +29,7 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle, dt +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -204,8 +204,8 @@ async def async_get_events( @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data.""" - start_of_today = dt.start_of_local_day() - start_of_tomorrow = dt.start_of_local_day() + timedelta(days=self.days) + start_of_today = dt_util.start_of_local_day() + start_of_tomorrow = dt_util.start_of_local_day() + timedelta(days=self.days) # We have to retrieve the results for the whole day as the server # won't return events that have already started @@ -312,7 +312,7 @@ def is_all_day(vevent): @staticmethod def is_over(vevent): """Return if the event is over.""" - return dt.now() >= WebDavCalendarData.to_datetime( + return dt_util.now() >= WebDavCalendarData.to_datetime( WebDavCalendarData.get_end_date(vevent) ) @@ -321,9 +321,7 @@ def to_datetime(obj): """Return a datetime.""" if isinstance(obj, datetime): return WebDavCalendarData.to_local(obj) - return dt.dt.datetime.combine(obj, dt.dt.time.min).replace( - tzinfo=dt.DEFAULT_TIME_ZONE - ) + return datetime.combine(obj, time.min).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) @staticmethod def to_local(obj: datetime | date) -> datetime | date: @@ -334,7 +332,7 @@ def to_local(obj: datetime | date) -> datetime | date: used by the caldav client and dateutil so the datetime can be copied. """ if isinstance(obj, datetime): - return dt.as_local(obj) + return dt_util.as_local(obj) return obj @staticmethod diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 0f047bf375853b..d56b2b0ddfae67 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -8,7 +8,7 @@ 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.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 @@ -31,11 +36,13 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt +from homeassistant.util import dt as dt_util +from 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 @@ EVENT_TIME_FIELDS, EVENT_TYPES, EVENT_UID, + LIST_EVENT_FIELDS, CalendarEntityFeature, ) @@ -117,7 +125,7 @@ def validate(obj: dict[str, Any]) -> dict[str, Any]: """Convert all keys that are datetime values to local timezone.""" for k in keys: if (value := obj.get(k)) and isinstance(value, datetime.datetime): - obj[k] = dt.as_local(value) + obj[k] = dt_util.as_local(value) return obj return validate @@ -250,6 +258,21 @@ def _validate_rrule(value: Any) -> str: 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 @@ -294,14 +322,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def get_date(date: dict[str, Any]) -> datetime.datetime: """Get the dateTime from date or dateTime as a local.""" if "date" in date: - parsed_date = dt.parse_date(date["date"]) + parsed_date = dt_util.parse_date(date["date"]) assert parsed_date - return dt.start_of_local_day( + return dt_util.start_of_local_day( datetime.datetime.combine(parsed_date, datetime.time.min) ) - parsed_datetime = dt.parse_datetime(date["dateTime"]) + parsed_datetime = dt_util.parse_datetime(date["dateTime"]) assert parsed_datetime - return dt.as_local(parsed_datetime) + return dt_util.as_local(parsed_datetime) @dataclasses.dataclass @@ -380,7 +408,7 @@ def _api_event_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, Any]: result: dict[str, Any] = {} for name, value in obj: if isinstance(value, datetime.datetime): - result[name] = {"dateTime": dt.as_local(value).isoformat()} + result[name] = {"dateTime": dt_util.as_local(value).isoformat()} elif isinstance(value, datetime.date): result[name] = {"date": value.isoformat()} else: @@ -388,19 +416,30 @@ 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: """Convert a calendar event date/datetime to a datetime if needed.""" if isinstance(dt_or_d, datetime.datetime): - return dt.as_local(dt_or_d) - return dt.start_of_local_day(dt_or_d) + return dt_util.as_local(dt_or_d) + return dt_util.start_of_local_day(dt_or_d) def _get_api_date(dt_or_d: datetime.datetime | datetime.date) -> dict[str, str]: """Convert a calendar event date/datetime to a datetime if needed.""" if isinstance(dt_or_d, datetime.datetime): - return {"dateTime": dt.as_local(dt_or_d).isoformat()} + return {"dateTime": dt_util.as_local(dt_or_d).isoformat()} return {"date": dt_or_d.isoformat()} @@ -433,7 +472,7 @@ def is_offset_reached( """Have we reached the offset time specified in the event title.""" if offset_time == datetime.timedelta(): return False - return start + offset_time <= dt.now(start.tzinfo) + return start + offset_time <= dt_util.now(start.tzinfo) class CalendarEntity(Entity): @@ -467,7 +506,7 @@ def state(self) -> str: if (event := self.event) is None: return STATE_OFF - now = dt.now() + now = dt_util.now() if event.start_datetime_local <= now < event.end_datetime_local: return STATE_ON @@ -529,8 +568,8 @@ async def get(self, request: web.Request, entity_id: str) -> web.Response: if start is None or end is None: return web.Response(status=HTTPStatus.BAD_REQUEST) try: - start_date = dt.parse_datetime(start) - end_date = dt.parse_datetime(end) + start_date = dt_util.parse_datetime(start) + end_date = dt_util.parse_datetime(end) except (ValueError, AttributeError): return web.Response(status=HTTPStatus.BAD_REQUEST) if start_date is None or end_date is None: @@ -540,7 +579,9 @@ async def get(self, request: web.Request, entity_id: str) -> web.Response: try: calendar_event_list = await entity.async_get_events( - request.app["hass"], dt.as_local(start_date), dt.as_local(end_date) + request.app["hass"], + dt_util.as_local(start_date), + dt_util.as_local(end_date), ) except HomeAssistantError as err: _LOGGER.debug("Error reading events: %s", err) @@ -741,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 3fbab6742a9806..e667510325bcd5 100644 --- a/homeassistant/components/calendar/const.py +++ b/homeassistant/components/calendar/const.py @@ -40,3 +40,13 @@ class CalendarEntityFeature(IntFlag): 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 5d1a3ccf0f4093..1f4d6aa3152a65 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -4,6 +4,8 @@ create_event: target: entity: domain: calendar + supported_features: + - calendar.CalendarEntityFeature.CREATE_EVENT fields: summary: name: Summary @@ -52,3 +54,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/camera/__init__.py b/homeassistant/components/camera/__init__.py index c09586848dffca..277aa10075e231 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -514,8 +514,8 @@ def frontend_stream_type(self) -> StreamType | None: @property def available(self) -> bool: """Return True if entity is available.""" - if self.stream and not self.stream.available: - return self.stream.available + if (stream := self.stream) and not stream.available: + return False return super().available async def async_create_stream(self) -> Stream | None: @@ -673,7 +673,10 @@ def async_update_token(self) -> None: async def async_internal_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_internal_added_to_hass() - await self.async_refresh_providers() + # Avoid calling async_refresh_providers() in here because it + # it will write state a second time since state is always + # written when an entity is added to hass. + self._rtsp_to_webrtc = await self._async_use_rtsp_to_webrtc() async def async_refresh_providers(self) -> None: """Determine if any of the registered providers are suitable for this entity. diff --git a/homeassistant/components/camera/manifest.json b/homeassistant/components/camera/manifest.json index a0ae9d925a89cf..b1df158a260904 100644 --- a/homeassistant/components/camera/manifest.json +++ b/homeassistant/components/camera/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/camera", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.6.7"] + "requirements": ["PyTurboJPEG==1.7.1"] } diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index 4d1c00f967b4ed..aa0bdfa81187b5 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -1,14 +1,12 @@ """Component to embed Google Cast.""" from __future__ import annotations -import logging from typing import Protocol from pychromecast import Chromecast -import voluptuous as vol from homeassistant.components.media_player import BrowseMedia, MediaType -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -16,44 +14,14 @@ from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) -from homeassistant.helpers.typing import ConfigType from . import home_assistant_cast from .const import DOMAIN -from .media_player import ENTITY_SCHEMA CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) - -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [Platform.MEDIA_PLAYER] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Cast component.""" - if (conf := config.get(DOMAIN)) is not None: - media_player_config_validated = [] - media_player_config = conf.get("media_player", {}) - if not isinstance(media_player_config, list): - media_player_config = [media_player_config] - for cfg in media_player_config: - try: - cfg = ENTITY_SCHEMA(cfg) - media_player_config_validated.append(cfg) - except vol.Error as ex: - _LOGGER.warning("Invalid config '%s': %s", cfg, ex) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=media_player_config_validated, - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Cast from a config entry.""" await home_assistant_cast.async_setup_ha_cast(hass, entry) diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index a5fc4360097070..e58bcb71b28e44 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -38,21 +38,6 @@ def async_get_options_flow( """Get the options flow for this handler.""" return CastOptionsFlowHandler(config_entry) - async def async_step_import(self, import_data=None): - """Import data.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - media_player_config = import_data or [] - for cfg in media_player_config: - if CONF_IGNORE_CEC in cfg: - self._ignore_cec.update(set(cfg[CONF_IGNORE_CEC])) - if CONF_UUID in cfg: - self._wanted_uuid.add(cfg[CONF_UUID]) - - data = self._get_data() - return self.async_create_entry(title="Google Cast", data=data) - async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" if self._async_current_entries(): diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 48921303ce0457..7cf318f12a641a 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 b701890d85dbb7..d32ff07c261de1 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -23,7 +23,6 @@ CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED, ) -import voluptuous as vol import yarl from homeassistant.components import media_source, zeroconf @@ -47,7 +46,6 @@ ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -83,15 +81,6 @@ CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png" -ENTITY_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_UUID): cv.string, - vol.Optional(CONF_IGNORE_CEC): vol.All(cv.ensure_list, [cv.string]), - } - ), -) - @callback def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo): @@ -278,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 @@ -372,15 +362,7 @@ def new_media_status(self, media_status): ): 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) @@ -388,8 +370,6 @@ def new_media_status(self, media_status): 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 5f6152b7bc79be..4fc89bc918b790 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -8,10 +8,10 @@ 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 219b5425b5c0ba..0817025c703f27 100644 --- a/homeassistant/components/cert_expiry/helper.py +++ b/homeassistant/components/cert_expiry/helper.py @@ -1,9 +1,10 @@ """Helper functions for the Cert Expiry platform.""" +from functools import cache import socket import ssl from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import TIMEOUT from .errors import ( @@ -14,12 +15,18 @@ ) +@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] @@ -52,4 +59,4 @@ async def get_cert_expiry_timestamp( raise ValidationFailure(err.args[0]) from err ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"]) - return dt.utc_from_timestamp(ts_seconds) + return dt_util.utc_from_timestamp(ts_seconds) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index ed0f8f2a4aac13..e62cb1143b5d80 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 @@ def target_humidity(self) -> int | None: 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 @@ async def async_turn_on(self) -> None: 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 9ee561b9c1beb6..41d4646aeae718 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 0119ad658015c4..6714e0bf35a294 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -3,6 +3,10 @@ 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 @@ 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_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 97dc27cfa090e7..d9f1b240a9ae5b 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -3,6 +3,9 @@ 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 @@ 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 @@ 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 005e744b53ffc8..0afd2485517a06 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -34,7 +34,7 @@ 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 @@ 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 5e3fe15d56675e..8034799a6d0c5f 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -53,7 +53,8 @@ "hvac_action": { "name": "Current action", "state": { - "off": "Off", + "off": "[%key:common::state::off%]", + "preheating": "Preheating", "heating": "Heating", "cooling": "Cooling", "drying": "Drying", diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 0af85fe9d4d092..40e5f264caf443 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -3,7 +3,7 @@ import asyncio from collections.abc import Awaitable, Callable -from datetime import timedelta +from datetime import datetime, timedelta from enum import Enum from hass_nabucasa import Cloud @@ -18,7 +18,7 @@ EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entityfilter from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -31,7 +31,6 @@ from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util.aiohttp import MockRequest from . import account_link, http_api from .client import CloudClient @@ -184,8 +183,10 @@ async def async_create_cloudhook(hass: HomeAssistant, webhook_id: str) -> str: if not async_is_logged_in(hass): raise CloudNotAvailable - hook = await hass.data[DOMAIN].cloudhooks.async_create(webhook_id, True) - return hook["cloudhook_url"] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] + hook = await cloud.cloudhooks.async_create(webhook_id, True) + cloudhook_url: str = hook["cloudhook_url"] + return cloudhook_url @bind_hass @@ -213,14 +214,6 @@ def async_remote_ui_url(hass: HomeAssistant) -> str: return f"https://{remote_domain}" -def is_cloudhook_request(request): - """Test if a request came from a cloudhook. - - Async friendly. - """ - return isinstance(request, MockRequest) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the Home Assistant cloud.""" # Process configs @@ -241,9 +234,8 @@ 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): + async def _shutdown(event: Event) -> None: """Shutdown event.""" await cloud.stop() @@ -263,7 +255,7 @@ async def _service_handler(service: ServiceCall) -> None: hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler ) - async def async_startup_repairs(_=None) -> None: + async def async_startup_repairs(_: datetime) -> None: """Create repair issues after startup.""" if not cloud.is_logged_in: return @@ -273,7 +265,7 @@ async def async_startup_repairs(_=None) -> None: loaded = False - async def _on_start(): + async def _on_start() -> None: """Discover platforms.""" nonlocal loaded @@ -292,19 +284,19 @@ async def _on_start(): await async_load_platform(hass, Platform.TTS, DOMAIN, tts_info, config) await asyncio.gather(stt_platform_loaded.wait(), tts_platform_loaded.wait()) - async def _on_connect(): + async def _on_connect() -> None: """Handle cloud connect.""" async_dispatcher_send( hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_CONNECTED ) - async def _on_disconnect(): + async def _on_disconnect() -> None: """Handle cloud disconnect.""" async_dispatcher_send( hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_DISCONNECTED ) - async def _on_initialized(): + async def _on_initialized() -> None: """Update preferences.""" await prefs.async_update(remote_domain=cloud.remote.instance_domain) @@ -330,7 +322,7 @@ async def _on_initialized(): @callback -def _remote_handle_prefs_updated(cloud: Cloud) -> None: +def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None: """Handle remote preferences updated.""" cur_pref = cloud.client.prefs.remote_enabled lock = asyncio.Lock() diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index e3b3c1231bbae9..1423330cb44f6d 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -1,5 +1,8 @@ """Account linking via the cloud.""" +from __future__ import annotations + import asyncio +from datetime import datetime import logging from typing import Any @@ -24,14 +27,16 @@ @callback -def async_setup(hass: HomeAssistant): +def async_setup(hass: HomeAssistant) -> None: """Set up cloud account link.""" config_entry_oauth2_flow.async_add_implementation_provider( hass, DOMAIN, async_provide_implementation ) -async def async_provide_implementation(hass: HomeAssistant, domain: str): +async def async_provide_implementation( + hass: HomeAssistant, domain: str +) -> list[config_entry_oauth2_flow.AbstractOAuth2Implementation]: """Provide an implementation for a domain.""" services = await _get_services(hass) @@ -55,9 +60,11 @@ async def async_provide_implementation(hass: HomeAssistant, domain: str): return [] -async def _get_services(hass): +async def _get_services(hass: HomeAssistant) -> list[dict[str, Any]]: """Get the available services.""" - if (services := hass.data.get(DATA_SERVICES)) is not None: + services: list[dict[str, Any]] + if DATA_SERVICES in hass.data: + services = hass.data[DATA_SERVICES] return services try: @@ -68,7 +75,7 @@ async def _get_services(hass): hass.data[DATA_SERVICES] = services @callback - def clear_services(_now): + def clear_services(_now: datetime) -> None: """Clear services cache.""" hass.data.pop(DATA_SERVICES, None) @@ -102,7 +109,7 @@ async def async_generate_authorize_url(self, flow_id: str) -> str: ) authorize_url = await helper.async_get_authorize_url() - async def await_tokens(): + async def await_tokens() -> None: """Wait for tokens and pass them on when received.""" try: tokens = await helper.async_get_tokens() @@ -125,7 +132,8 @@ async def await_tokens(): async def async_resolve_external_data(self, external_data: Any) -> dict: """Resolve external data to tokens.""" # We already passed in tokens - return external_data + dict_data: dict = external_data + return dict_data async def _async_refresh_token(self, token: dict) -> dict: """Refresh a token.""" diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 53bf44d8aa1240..8c1300f6228a76 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -4,9 +4,10 @@ import asyncio from collections.abc import Callable from contextlib import suppress -from datetime import timedelta +from datetime import datetime, timedelta from http import HTTPStatus import logging +from typing import TYPE_CHECKING, Any import aiohttp import async_timeout @@ -29,10 +30,11 @@ ) from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES -from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.core import Event, HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, start from homeassistant.helpers.entity import get_device_class +from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -47,6 +49,9 @@ ) from .prefs import ALEXA_SETTINGS_VERSION, CloudPreferences +if TYPE_CHECKING: + from .client import CloudClient + _LOGGER = logging.getLogger(__name__) CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}" @@ -132,7 +137,7 @@ def __init__( config: dict, cloud_user: str, prefs: CloudPreferences, - cloud: Cloud, + cloud: Cloud[CloudClient], ) -> None: """Initialize the Alexa config.""" super().__init__(hass) @@ -141,13 +146,13 @@ def __init__( self._prefs = prefs self._cloud = cloud self._token = None - self._token_valid = None + self._token_valid: datetime | None = None self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA) self._alexa_sync_unsub: Callable[[], None] | None = None - self._endpoint = None + self._endpoint: Any = None @property - def enabled(self): + def enabled(self) -> bool: """Return if Alexa is enabled.""" return ( self._cloud.is_logged_in @@ -156,12 +161,12 @@ def enabled(self): ) @property - def supports_auth(self): + def supports_auth(self) -> bool: """Return if config supports auth.""" return True @property - def should_report_state(self): + def should_report_state(self) -> bool: """Return if states should be proactively reported.""" return ( self._prefs.alexa_enabled @@ -170,7 +175,7 @@ def should_report_state(self): ) @property - def endpoint(self): + def endpoint(self) -> Any | None: """Endpoint for report state.""" if self._endpoint is None: raise ValueError("No endpoint available. Fetch access token first") @@ -178,22 +183,22 @@ def endpoint(self): return self._endpoint @property - def locale(self): + def locale(self) -> str: """Return config locale.""" # Not clear how to determine locale atm. return "en-US" @property - def entity_config(self): + def entity_config(self) -> dict[str, Any]: """Return entity config.""" return self._config.get(CONF_ENTITY_CONFIG) or {} @callback - def user_identifier(self): + def user_identifier(self) -> str: """Return an identifier for the user that represents this config.""" return self._cloud_user - def _migrate_alexa_entity_settings_v1(self): + def _migrate_alexa_entity_settings_v1(self) -> None: """Migrate alexa entity settings to entity registry options.""" if not self._config[CONF_FILTER].empty_filter: # Don't migrate if there's a YAML config @@ -210,12 +215,17 @@ def _migrate_alexa_entity_settings_v1(self): self._should_expose_legacy(entity_id), ) - async def async_initialize(self): + async def async_initialize(self) -> None: """Initialize the Alexa config.""" await super().async_initialize() - async def on_hass_started(hass): + async def on_hass_started(hass: HomeAssistant) -> None: if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION: + _LOGGER.info( + "Start migration of Alexa settings from v%s to v%s", + self._prefs.alexa_settings_version, + ALEXA_SETTINGS_VERSION, + ) if self._prefs.alexa_settings_version < 2 or ( # Recover from a bug we had in 2023.5.0 where entities didn't get exposed self._prefs.alexa_settings_version < 3 @@ -228,6 +238,11 @@ async def on_hass_started(hass): ): self._migrate_alexa_entity_settings_v1() + _LOGGER.info( + "Finished migration of Alexa settings from v%s to v%s", + self._prefs.alexa_settings_version, + ALEXA_SETTINGS_VERSION, + ) await self._prefs.async_update( alexa_settings_version=ALEXA_SETTINGS_VERSION ) @@ -235,7 +250,7 @@ async def on_hass_started(hass): self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated ) - async def on_hass_start(hass): + async def on_hass_start(hass: HomeAssistant) -> None: if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, ALEXA_DOMAIN, {}) @@ -248,14 +263,14 @@ async def on_hass_start(hass): self._handle_entity_registry_updated, ) - def _should_expose_legacy(self, entity_id): + def _should_expose_legacy(self, entity_id: str) -> bool: """If an entity should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False entity_configs = self._prefs.alexa_entity_configs entity_config = entity_configs.get(entity_id, {}) - entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) + entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE) if entity_expose is not None: return entity_expose @@ -279,21 +294,22 @@ def _should_expose_legacy(self, entity_id): ) @callback - def should_expose(self, entity_id): + def should_expose(self, entity_id: str) -> bool: """If an entity should be exposed.""" - if not self._config[CONF_FILTER].empty_filter: + entity_filter: EntityFilter = self._config[CONF_FILTER] + if not entity_filter.empty_filter: if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - return self._config[CONF_FILTER](entity_id) + return entity_filter(entity_id) return async_should_expose(self.hass, CLOUD_ALEXA, entity_id) @callback - def async_invalidate_access_token(self): + def async_invalidate_access_token(self) -> None: """Invalidate access token.""" self._token_valid = None - async def async_get_access_token(self): + async def async_get_access_token(self) -> Any: """Get an access token.""" if self._token_valid is not None and self._token_valid > utcnow(): return self._token @@ -380,7 +396,7 @@ def _async_exposed_entities_updated(self) -> None: self.hass, SYNC_DELAY, self._sync_prefs ) - async def _sync_prefs(self, _now): + async def _sync_prefs(self, _now: datetime) -> None: """Sync the updated preferences to Alexa.""" self._alexa_sync_unsub = None old_prefs = self._cur_entity_prefs @@ -432,7 +448,7 @@ async def _sync_prefs(self, _now): if await self._sync_helper(to_update, to_remove): self._cur_entity_prefs = new_prefs - async def async_sync_entities(self): + async def async_sync_entities(self) -> bool: """Sync all entities to Alexa.""" # Remove any pending sync if self._alexa_sync_unsub: @@ -452,7 +468,7 @@ async def async_sync_entities(self): return await self._sync_helper(to_update, to_remove) - async def _sync_helper(self, to_update, to_remove) -> bool: + async def _sync_helper(self, to_update: list[str], to_remove: list[str]) -> bool: """Sync entities to Alexa. Return boolean if it was successful. @@ -497,7 +513,7 @@ async def _sync_helper(self, to_update, to_remove) -> bool: _LOGGER.warning("Error trying to sync entities to Alexa: %s", err) return False - async def _handle_entity_registry_updated(self, event): + async def _handle_entity_registry_updated(self, event: Event) -> None: """Handle when entity registry updated.""" if not self.enabled or not self._cloud.is_logged_in: return diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py index 2d78ad8b512be8..e09122ac7bf59b 100644 --- a/homeassistant/components/cloud/binary_sensor.py +++ b/homeassistant/components/cloud/binary_sensor.py @@ -2,6 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable +from typing import Any + +from hass_nabucasa import Cloud from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -13,6 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .client import CloudClient from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN WAIT_UNTIL_CHANGE = 3 @@ -41,10 +46,10 @@ class CloudRemoteBinary(BinarySensorEntity): _attr_unique_id = "cloud-remote-ui-connectivity" _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, cloud): + def __init__(self, cloud: Cloud[CloudClient]) -> None: """Initialize the binary sensor.""" self.cloud = cloud - self._unsub_dispatcher = None + self._unsub_dispatcher: Callable[[], None] | None = None @property def is_on(self) -> bool: @@ -59,7 +64,7 @@ def available(self) -> bool: async def async_added_to_hass(self) -> None: """Register update dispatcher.""" - async def async_state_update(data): + async def async_state_update(data: Any) -> None: """Update callback.""" await asyncio.sleep(WAIT_UNTIL_CHANGE) self.async_write_ha_state() diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index f3878fd68afb0b..236635a0bb80e0 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from datetime import datetime from http import HTTPStatus import logging from pathlib import Path @@ -16,6 +17,7 @@ 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 @@ -95,7 +97,9 @@ async def get_alexa_config(self) -> alexa_config.CloudAlexaConfig: if self._alexa_config is None: async with self._alexa_config_init_lock: if self._alexa_config is not None: - return self._alexa_config + # This is reachable if the config was set while we waited + # for the lock + return self._alexa_config # type: ignore[unreachable] cloud_user = await self._prefs.get_cloud_user() @@ -132,11 +136,11 @@ async def get_google_config(self) -> google_config.CloudGoogleConfig: 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) - async def enable_alexa(_): + async def enable_alexa(_: Any) -> None: """Enable Alexa.""" aconf = await self.get_alexa_config() try: @@ -156,7 +160,7 @@ async def enable_alexa(_): enable_alexa_job = HassJob(enable_alexa, cancel_on_shutdown=True) - async def enable_google(_): + async def enable_google(_: datetime) -> None: """Enable Google.""" gconf = await self.get_google_config() @@ -179,6 +183,9 @@ async def enable_google(_): 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.""" @@ -206,11 +213,24 @@ async def async_cloud_connect_update(self, connect: bool) -> None: """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() aconfig = await self.get_alexa_config() - return await alexa_smart_home.async_handle_message( + return await alexa_smart_home.async_handle_message( # type: ignore[no-any-return, no-untyped-call] self._hass, aconfig, payload, @@ -223,9 +243,11 @@ async def async_google_message(self, payload: dict[Any, Any]) -> dict[Any, Any]: gconf = await self.get_google_config() if not self._prefs.google_enabled: - return ga.api_disabled_response(payload, gconf.agent_user_id) + return ga.api_disabled_response( # type: ignore[no-any-return, no-untyped-call] + payload, gconf.agent_user_id + ) - return await ga.async_handle_message( + return await ga.async_handle_message( # type: ignore[no-any-return, no-untyped-call] self._hass, gconf, gconf.cloud_user, payload, google_assistant.SOURCE_CLOUD ) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 351de5d0e654e5..0a49c0b6ed6358 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -1,8 +1,10 @@ """Google config for Cloud.""" +from __future__ import annotations + import asyncio from http import HTTPStatus import logging -from typing import Any +from typing import TYPE_CHECKING, Any from hass_nabucasa import Cloud, cloud_api from hass_nabucasa.google_report_state import ErrorResponse @@ -24,12 +26,14 @@ CoreState, Event, HomeAssistant, + State, callback, split_entity_id, ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er, start from homeassistant.helpers.entity import get_device_class +from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.setup import async_setup_component from .const import ( @@ -42,6 +46,9 @@ ) from .prefs import GOOGLE_SETTINGS_VERSION, CloudPreferences +if TYPE_CHECKING: + from .client import CloudClient + _LOGGER = logging.getLogger(__name__) CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}" @@ -101,7 +108,12 @@ def _supported_legacy(hass: HomeAssistant, entity_id: str) -> bool: if domain in SUPPORTED_DOMAINS: return True - device_class = get_device_class(hass, entity_id) + try: + device_class = get_device_class(hass, entity_id) + except HomeAssistantError: + # The entity no longer exists + return False + if ( domain == "binary_sensor" and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES @@ -123,7 +135,7 @@ def __init__( config: dict[str, Any], cloud_user: str, prefs: CloudPreferences, - cloud: Cloud, + cloud: Cloud[CloudClient], ) -> None: """Initialize the Google config.""" super().__init__(hass) @@ -134,7 +146,7 @@ def __init__( self._sync_entities_lock = asyncio.Lock() @property - def enabled(self): + def enabled(self) -> bool: """Return if Google is enabled.""" return ( self._cloud.is_logged_in @@ -143,34 +155,34 @@ def enabled(self): ) @property - def entity_config(self): + def entity_config(self) -> dict[str, Any]: """Return entity config.""" return self._config.get(CONF_ENTITY_CONFIG) or {} @property - def secure_devices_pin(self): + def secure_devices_pin(self) -> str | None: """Return entity config.""" return self._prefs.google_secure_devices_pin @property - def should_report_state(self): + def should_report_state(self) -> bool: """Return if states should be proactively reported.""" return self.enabled and self._prefs.google_report_state - def get_local_webhook_id(self, agent_user_id): + def get_local_webhook_id(self, agent_user_id: Any) -> str: """Return the webhook ID to be used for actions for a given agent user id via the local SDK.""" return self._prefs.google_local_webhook_id - def get_local_agent_user_id(self, webhook_id): + def get_local_agent_user_id(self, webhook_id: Any) -> str: """Return the user ID to be used for actions received via the local SDK.""" return self._user @property - def cloud_user(self): + def cloud_user(self) -> str: """Return Cloud User account.""" return self._user - def _migrate_google_entity_settings_v1(self): + def _migrate_google_entity_settings_v1(self) -> None: """Migrate Google entity settings to entity registry options.""" if not self._config[CONF_FILTER].empty_filter: # Don't migrate if there's a YAML config @@ -195,12 +207,17 @@ def _migrate_google_entity_settings_v1(self): _2fa_disabled, ) - async def async_initialize(self): + async def async_initialize(self) -> None: """Perform async initialization of config.""" await super().async_initialize() async def on_hass_started(hass: HomeAssistant) -> None: if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: + _LOGGER.info( + "Start migration of Google Assistant settings from v%s to v%s", + self._prefs.google_settings_version, + GOOGLE_SETTINGS_VERSION, + ) if self._prefs.google_settings_version < 2 or ( # Recover from a bug we had in 2023.5.0 where entities didn't get exposed self._prefs.google_settings_version < 3 @@ -213,6 +230,11 @@ async def on_hass_started(hass: HomeAssistant) -> None: ): self._migrate_google_entity_settings_v1() + _LOGGER.info( + "Finished migration of Google Assistant settings from v%s to v%s", + self._prefs.google_settings_version, + GOOGLE_SETTINGS_VERSION, + ) await self._prefs.async_update( google_settings_version=GOOGLE_SETTINGS_VERSION ) @@ -246,18 +268,18 @@ async def on_hass_start(hass: HomeAssistant) -> None: self._handle_device_registry_updated, ) - def should_expose(self, state): + def should_expose(self, state: State) -> bool: """If a state object should be exposed.""" return self._should_expose_entity_id(state.entity_id) - def _should_expose_legacy(self, entity_id): + def _should_expose_legacy(self, entity_id: str) -> bool: """If an entity ID should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False entity_configs = self._prefs.google_entity_configs entity_config = entity_configs.get(entity_id, {}) - entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) + entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE) if entity_expose is not None: return entity_expose @@ -282,36 +304,37 @@ def _should_expose_legacy(self, entity_id): and _supported_legacy(self.hass, entity_id) ) - def _should_expose_entity_id(self, entity_id): + def _should_expose_entity_id(self, entity_id: str) -> bool: """If an entity should be exposed.""" - if not self._config[CONF_FILTER].empty_filter: + entity_filter: EntityFilter = self._config[CONF_FILTER] + if not entity_filter.empty_filter: if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - return self._config[CONF_FILTER](entity_id) + return entity_filter(entity_id) return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) @property - def agent_user_id(self): + def agent_user_id(self) -> str: """Return Agent User Id to use for query responses.""" return self._cloud.username @property - def has_registered_user_agent(self): + def has_registered_user_agent(self) -> bool: """Return if we have a Agent User Id registered.""" return len(self._store.agent_user_ids) > 0 - def get_agent_user_id(self, context): + def get_agent_user_id(self, context: Any) -> str: """Get agent user ID making request.""" return self.agent_user_id - def _2fa_disabled_legacy(self, entity_id): + def _2fa_disabled_legacy(self, entity_id: str) -> bool | None: """If an entity should be checked for 2FA.""" entity_configs = self._prefs.google_entity_configs entity_config = entity_configs.get(entity_id, {}) return entity_config.get(PREF_DISABLE_2FA) - def should_2fa(self, state): + def should_2fa(self, state: State) -> bool: """If an entity should be checked for 2FA.""" try: settings = async_get_entity_settings(self.hass, state.entity_id) @@ -322,14 +345,14 @@ def should_2fa(self, state): assistant_options = settings.get(CLOUD_GOOGLE, {}) return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) - async def async_report_state(self, message, agent_user_id: str): + async def async_report_state(self, message: Any, agent_user_id: str) -> None: """Send a state report to Google.""" try: await self._cloud.google_report_state.async_send_message(message) except ErrorResponse as err: _LOGGER.warning("Error reporting state - %s: %s", err.code, err.message) - async def _async_request_sync_devices(self, agent_user_id: str): + async def _async_request_sync_devices(self, agent_user_id: str) -> int: """Trigger a sync with Google.""" if self._sync_entities_lock.locked(): return HTTPStatus.OK @@ -338,7 +361,7 @@ async def _async_request_sync_devices(self, agent_user_id: str): resp = await cloud_api.async_google_actions_request_sync(self._cloud) return resp.status - async def _async_prefs_updated(self, prefs): + async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: """Handle updated preferences.""" if not self._cloud.is_logged_in: if self.is_reporting_state: diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index f5d5c98fe1acfa..84c348236d4b7c 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,14 +1,15 @@ """The HTTP api to control the cloud integration.""" import asyncio -from collections.abc import Mapping +from collections.abc import Awaitable, Callable, Coroutine, Mapping from contextlib import suppress import dataclasses from functools import wraps from http import HTTPStatus import logging -from typing import Any +from typing import Any, Concatenate, ParamSpec, TypeVar import aiohttp +from aiohttp import web import async_timeout import attr from hass_nabucasa import Cloud, auth, thingtalk @@ -32,6 +33,7 @@ from homeassistant.util.location import async_detect_location_info from .alexa_config import entity_supported as entity_supported_by_alexa +from .client import CloudClient from .const import ( DOMAIN, PREF_ALEXA_REPORT_STATE, @@ -50,7 +52,7 @@ _LOGGER = logging.getLogger(__name__) -_CLOUD_ERRORS = { +_CLOUD_ERRORS: dict[type[Exception], tuple[HTTPStatus, str]] = { asyncio.TimeoutError: ( HTTPStatus.BAD_GATEWAY, "Unable to reach the Home Assistant cloud.", @@ -62,7 +64,7 @@ } -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> None: """Initialize the HTTP API.""" websocket_api.async_register_command(hass, websocket_cloud_status) websocket_api.async_register_command(hass, websocket_subscription) @@ -107,11 +109,21 @@ async def async_setup(hass): ) -def _handle_cloud_errors(handler): +_HassViewT = TypeVar("_HassViewT", bound=HomeAssistantView) +_P = ParamSpec("_P") + + +def _handle_cloud_errors( + handler: Callable[Concatenate[_HassViewT, web.Request, _P], Awaitable[web.Response]] +) -> Callable[ + Concatenate[_HassViewT, web.Request, _P], Coroutine[Any, Any, web.Response] +]: """Webview decorator to handle auth errors.""" @wraps(handler) - async def error_handler(view, request, *args, **kwargs): + async def error_handler( + view: _HassViewT, request: web.Request, *args: _P.args, **kwargs: _P.kwargs + ) -> web.Response: """Handle exceptions that raise from the wrapped request handler.""" try: result = await handler(view, request, *args, **kwargs) @@ -126,25 +138,37 @@ async def error_handler(view, request, *args, **kwargs): return error_handler -def _ws_handle_cloud_errors(handler): +def _ws_handle_cloud_errors( + handler: Callable[ + [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], + Coroutine[None, None, None], + ] +) -> Callable[ + [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], + Coroutine[None, None, None], +]: """Websocket decorator to handle auth errors.""" @wraps(handler) - async def error_handler(hass, connection, msg): + async def error_handler( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: """Handle exceptions that raise from the wrapped handler.""" try: return await handler(hass, connection, msg) except Exception as err: # pylint: disable=broad-except err_status, err_msg = _process_cloud_exception(err, msg["type"]) - connection.send_error(msg["id"], err_status, err_msg) + connection.send_error(msg["id"], str(err_status), err_msg) return error_handler -def _process_cloud_exception(exc, where): +def _process_cloud_exception(exc: Exception, where: str) -> tuple[HTTPStatus, str]: """Process a cloud exception.""" - err_info = None + err_info: tuple[HTTPStatus, str] | None = None for err, value_info in _CLOUD_ERRORS.items(): if isinstance(exc, err): @@ -165,10 +189,10 @@ class GoogleActionsSyncView(HomeAssistantView): name = "api:cloud:google_actions/sync" @_handle_cloud_errors - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Trigger a Google Actions sync.""" hass = request.app["hass"] - cloud: Cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] gconf = await cloud.client.get_google_config() status = await gconf.async_sync_entities(gconf.agent_user_id) return self.json({}, status_code=status) @@ -184,7 +208,7 @@ class CloudLoginView(HomeAssistantView): @RequestDataValidator( vol.Schema({vol.Required("email"): str, vol.Required("password"): str}) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle login request.""" def cloud_assist_pipeline(hass: HomeAssistant) -> str | None: @@ -221,7 +245,7 @@ class CloudLogoutView(HomeAssistantView): name = "api:cloud:logout" @_handle_cloud_errors - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Handle logout request.""" hass = request.app["hass"] cloud = hass.data[DOMAIN] @@ -247,7 +271,7 @@ class CloudRegisterView(HomeAssistantView): } ) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle registration request.""" hass = request.app["hass"] cloud = hass.data[DOMAIN] @@ -283,7 +307,7 @@ class CloudResendConfirmView(HomeAssistantView): @_handle_cloud_errors @RequestDataValidator(vol.Schema({vol.Required("email"): str})) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle resending confirm email code request.""" hass = request.app["hass"] cloud = hass.data[DOMAIN] @@ -302,7 +326,7 @@ class CloudForgotPasswordView(HomeAssistantView): @_handle_cloud_errors @RequestDataValidator(vol.Schema({vol.Required("email"): str})) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle forgot password request.""" hass = request.app["hass"] cloud = hass.data[DOMAIN] @@ -330,11 +354,20 @@ async def websocket_cloud_status( ) -def _require_cloud_login(handler): +def _require_cloud_login( + handler: Callable[ + [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], + None, + ] +) -> Callable[[HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], None,]: """Websocket decorator that requires cloud to be logged in.""" @wraps(handler) - def with_cloud_auth(hass, connection, msg): + def with_cloud_auth( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: """Require to be logged into the cloud.""" cloud = hass.data[DOMAIN] if not cloud.is_logged_in: @@ -467,7 +500,9 @@ async def websocket_hook_delete( connection.send_message(websocket_api.result_message(msg["id"])) -async def _account_data(hass: HomeAssistant, cloud: Cloud): +async def _account_data( + hass: HomeAssistant, cloud: Cloud[CloudClient] +) -> dict[str, Any]: """Generate the auth data JSON response.""" assert hass.config.api diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index d352b7226f0eba..d8fd2148b4d135 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 5ccc007e5241a8..8b6f773e5d95a6 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,14 +1,15 @@ """Preference management for cloud.""" from __future__ import annotations +from collections.abc import Callable, Coroutine from typing import Any from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.components import webhook -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store -from homeassistant.helpers.typing import UNDEFINED +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util.logging import async_create_catching_coro from .const import ( @@ -46,7 +47,7 @@ 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] @@ -63,17 +64,20 @@ async def _async_migrate_func( class CloudPreferences: """Handle cloud preferences.""" - def __init__(self, hass): + _prefs: dict[str, Any] + + def __init__(self, hass: HomeAssistant) -> None: """Initialize cloud prefs.""" self._hass = hass self._store = CloudPreferencesStore( hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR ) - self._prefs = None - self._listeners = [] + self._listeners: list[ + Callable[[CloudPreferences], Coroutine[Any, Any, None]] + ] = [] self.last_updated: set[str] = set() - async def async_initialize(self): + async def async_initialize(self) -> None: """Finish initializing the preferences.""" if (prefs := await self._store.async_load()) is None: prefs = self._empty_config("") @@ -89,26 +93,28 @@ async def async_initialize(self): ) @callback - def async_listen_updates(self, listener): + def async_listen_updates( + self, listener: Callable[[CloudPreferences], Coroutine[Any, Any, None]] + ) -> None: """Listen for updates to the preferences.""" self._listeners.append(listener) async def async_update( self, *, - google_enabled=UNDEFINED, - alexa_enabled=UNDEFINED, - remote_enabled=UNDEFINED, - google_secure_devices_pin=UNDEFINED, - cloudhooks=UNDEFINED, - cloud_user=UNDEFINED, - alexa_report_state=UNDEFINED, - google_report_state=UNDEFINED, - tts_default_voice=UNDEFINED, - remote_domain=UNDEFINED, - alexa_settings_version=UNDEFINED, - google_settings_version=UNDEFINED, - ): + google_enabled: bool | UndefinedType = UNDEFINED, + alexa_enabled: bool | UndefinedType = UNDEFINED, + remote_enabled: bool | UndefinedType = UNDEFINED, + google_secure_devices_pin: str | None | UndefinedType = UNDEFINED, + cloudhooks: dict[str, dict[str, str | bool]] | UndefinedType = UNDEFINED, + cloud_user: str | UndefinedType = UNDEFINED, + alexa_report_state: bool | UndefinedType = UNDEFINED, + google_report_state: bool | UndefinedType = UNDEFINED, + tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED, + remote_domain: str | None | UndefinedType = UNDEFINED, + alexa_settings_version: int | UndefinedType = UNDEFINED, + google_settings_version: int | UndefinedType = UNDEFINED, + ) -> None: """Update user preferences.""" prefs = {**self._prefs} @@ -131,7 +137,7 @@ async def async_update( await self._save_prefs(prefs) - async def async_set_username(self, username) -> bool: + async def async_set_username(self, username: str | None) -> bool: """Set the username that is logged in.""" # Logging out. if username is None: @@ -154,7 +160,7 @@ async def async_set_username(self, username) -> bool: return True - def as_dict(self): + def as_dict(self) -> dict[str, Any]: """Return dictionary version.""" return { PREF_ALEXA_DEFAULT_EXPOSE: self.alexa_default_expose, @@ -170,7 +176,7 @@ def as_dict(self): } @property - def remote_enabled(self): + def remote_enabled(self) -> bool: """Return if remote is enabled on start.""" if not self._prefs.get(PREF_ENABLE_REMOTE, False): return False @@ -178,17 +184,18 @@ def remote_enabled(self): return True @property - def remote_domain(self): + def remote_domain(self) -> str | None: """Return remote domain.""" return self._prefs.get(PREF_REMOTE_DOMAIN) @property - def alexa_enabled(self): + def alexa_enabled(self) -> bool: """Return if Alexa is enabled.""" - return self._prefs[PREF_ENABLE_ALEXA] + alexa_enabled: bool = self._prefs[PREF_ENABLE_ALEXA] + return alexa_enabled @property - def alexa_report_state(self): + def alexa_report_state(self) -> bool: """Return if Alexa report state is enabled.""" return self._prefs.get(PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE) @@ -201,44 +208,48 @@ def alexa_default_expose(self) -> list[str] | None: return self._prefs.get(PREF_ALEXA_DEFAULT_EXPOSE) @property - def alexa_entity_configs(self): + def alexa_entity_configs(self) -> dict[str, Any]: """Return Alexa Entity configurations.""" return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) @property - def alexa_settings_version(self): + def alexa_settings_version(self) -> int: """Return version of Alexa settings.""" - return self._prefs[PREF_ALEXA_SETTINGS_VERSION] + alexa_settings_version: int = self._prefs[PREF_ALEXA_SETTINGS_VERSION] + return alexa_settings_version @property - def google_enabled(self): + def google_enabled(self) -> bool: """Return if Google is enabled.""" - return self._prefs[PREF_ENABLE_GOOGLE] + google_enabled: bool = self._prefs[PREF_ENABLE_GOOGLE] + return google_enabled @property - def google_report_state(self): + def google_report_state(self) -> bool: """Return if Google report state is enabled.""" return self._prefs.get(PREF_GOOGLE_REPORT_STATE, DEFAULT_GOOGLE_REPORT_STATE) @property - def google_secure_devices_pin(self): + def google_secure_devices_pin(self) -> str | None: """Return if Google is allowed to unlock locks.""" return self._prefs.get(PREF_GOOGLE_SECURE_DEVICES_PIN) @property - def google_entity_configs(self): + def google_entity_configs(self) -> dict[str, dict[str, Any]]: """Return Google Entity configurations.""" return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) @property - def google_settings_version(self): + def google_settings_version(self) -> int: """Return version of Google settings.""" - return self._prefs[PREF_GOOGLE_SETTINGS_VERSION] + google_settings_version: int = self._prefs[PREF_GOOGLE_SETTINGS_VERSION] + return google_settings_version @property - def google_local_webhook_id(self): + def google_local_webhook_id(self) -> str: """Return Google webhook ID to receive local messages.""" - return self._prefs[PREF_GOOGLE_LOCAL_WEBHOOK_ID] + google_local_webhook_id: str = self._prefs[PREF_GOOGLE_LOCAL_WEBHOOK_ID] + return google_local_webhook_id @property def google_default_expose(self) -> list[str] | None: @@ -249,12 +260,12 @@ def google_default_expose(self) -> list[str] | None: return self._prefs.get(PREF_GOOGLE_DEFAULT_EXPOSE) @property - def cloudhooks(self): + def cloudhooks(self) -> dict[str, Any]: """Return the published cloud webhooks.""" return self._prefs.get(PREF_CLOUDHOOKS, {}) @property - def tts_default_voice(self): + def tts_default_voice(self) -> tuple[str, str]: """Return the default TTS voice.""" return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) @@ -281,7 +292,7 @@ async def _load_cloud_user(self) -> User | None: # an image was restored without restoring the cloud prefs. return await self._hass.auth.async_get_user(user_id) - async def _save_prefs(self, prefs): + async def _save_prefs(self, prefs: dict[str, Any]) -> None: """Save preferences to disk.""" self.last_updated = { key for key, value in prefs.items() if value != self._prefs.get(key) @@ -294,7 +305,7 @@ async def _save_prefs(self, prefs): @callback @staticmethod - def _empty_config(username): + def _empty_config(username: str) -> dict[str, Any]: """Return an empty config.""" return { PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, diff --git a/homeassistant/components/cloud/repairs.py b/homeassistant/components/cloud/repairs.py index 0864d8b48ad7fe..f7368731d926bd 100644 --- a/homeassistant/components/cloud/repairs.py +++ b/homeassistant/components/cloud/repairs.py @@ -12,6 +12,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import issue_registry as ir +from .client import CloudClient from .const import DOMAIN from .subscription import async_migrate_paypal_agreement, async_subscription_info @@ -67,7 +68,7 @@ async def async_step_confirm_change_plan( async def async_step_change_plan(self, _: None = None) -> FlowResult: """Wait for the user to authorize the app installation.""" - cloud: Cloud = self.hass.data[DOMAIN] + cloud: Cloud[CloudClient] = self.hass.data[DOMAIN] async def _async_wait_for_plan_change() -> None: flow_manager = repairs_flow_manager(self.hass) diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index 84e1e088d47dc3..7b6da8b74039e2 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -18,15 +18,22 @@ SpeechResult, SpeechResultState, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .client import CloudClient from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -async def async_get_engine(hass, config, discovery_info=None): +async def async_get_engine( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> CloudProvider: """Set up Cloud speech component.""" - cloud: Cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] cloud_provider = CloudProvider(cloud) if discovery_info is not None: @@ -37,7 +44,7 @@ async def async_get_engine(hass, config, discovery_info=None): class CloudProvider(Provider): """NabuCasa speech API provider.""" - def __init__(self, cloud: Cloud) -> None: + def __init__(self, cloud: Cloud[CloudClient]) -> None: """Home Assistant NabuCasa Speech to text.""" self.cloud = cloud diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py index b85a50b20cd4c3..633f0c95e1b768 100644 --- a/homeassistant/components/cloud/subscription.py +++ b/homeassistant/components/cloud/subscription.py @@ -9,12 +9,13 @@ import async_timeout from hass_nabucasa import Cloud, cloud_api +from .client import CloudClient from .const import REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) -async def async_subscription_info(cloud: Cloud) -> dict[str, Any] | None: +async def async_subscription_info(cloud: Cloud[CloudClient]) -> dict[str, Any] | None: """Fetch the subscription info.""" try: async with async_timeout.timeout(REQUEST_TIMEOUT): @@ -33,7 +34,9 @@ async def async_subscription_info(cloud: Cloud) -> dict[str, Any] | None: return None -async def async_migrate_paypal_agreement(cloud: Cloud) -> dict[str, Any] | None: +async def async_migrate_paypal_agreement( + cloud: Cloud[CloudClient], +) -> dict[str, Any] | None: """Migrate a paypal agreement from legacy.""" try: async with async_timeout.timeout(REQUEST_TIMEOUT): diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py index 592338144f362d..0dfd69344f3e2d 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -1,4 +1,6 @@ """Provide info to system health.""" +from typing import Any + from hass_nabucasa import Cloud from homeassistant.components import system_health @@ -16,12 +18,12 @@ def async_register( register.async_register_info(system_health_info, "/config/cloud") -async def system_health_info(hass): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" - cloud: Cloud = hass.data[DOMAIN] - client: CloudClient = cloud.client + cloud: Cloud[CloudClient] = hass.data[DOMAIN] + client = cloud.client - data = { + data: dict[str, Any] = { "logged_in": cloud.is_logged_in, } diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index fea2ffca9873be..88f24d1290f7bd 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -1,6 +1,8 @@ -"""Support for the cloud for text to speech service.""" +"""Support for the cloud for text-to-speech service.""" +from __future__ import annotations import logging +from typing import Any from hass_nabucasa import Cloud from hass_nabucasa.voice import MAP_VOICE, TTS_VOICES, AudioOutput, VoiceError @@ -12,11 +14,15 @@ CONF_LANG, PLATFORM_SCHEMA, Provider, + TtsAudioType, Voice, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .client import CloudClient from .const import DOMAIN +from .prefs import CloudPreferences ATTR_GENDER = "gender" @@ -25,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) -def validate_lang(value): +def validate_lang(value: dict[str, Any]) -> dict[str, Any]: """Validate chosen gender or language.""" if (lang := value.get(CONF_LANG)) is None: return value @@ -52,10 +58,16 @@ def validate_lang(value): ) -async def async_get_engine(hass, config, discovery_info=None): +async def async_get_engine( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> CloudProvider: """Set up Cloud speech component.""" - cloud: Cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] + language: str | None + gender: str | None if discovery_info is not None: language = None gender = None @@ -72,7 +84,9 @@ async def async_get_engine(hass, config, discovery_info=None): class CloudProvider(Provider): """NabuCasa Cloud speech API provider.""" - def __init__(self, cloud: Cloud, language: str, gender: str) -> None: + def __init__( + self, cloud: Cloud[CloudClient], language: str | None, gender: str | None + ) -> None: """Initialize cloud provider.""" self.cloud = cloud self.name = "Cloud" @@ -85,22 +99,22 @@ def __init__(self, cloud: Cloud, language: str, gender: str) -> None: self._language, self._gender = cloud.client.prefs.tts_default_voice cloud.client.prefs.async_listen_updates(self._sync_prefs) - async def _sync_prefs(self, prefs): + async def _sync_prefs(self, prefs: CloudPreferences) -> None: """Sync preferences.""" self._language, self._gender = prefs.tts_default_voice @property - def default_language(self): + def default_language(self) -> str | None: """Return the default language.""" return self._language @property - def supported_languages(self): + def supported_languages(self) -> list[str]: """Return list of supported languages.""" return SUPPORT_LANGUAGES @property - def supported_options(self): + def supported_options(self) -> list[str]: """Return list of supported options like voice, emotion.""" return [ATTR_GENDER, ATTR_VOICE, ATTR_AUDIO_OUTPUT] @@ -112,14 +126,16 @@ def async_get_supported_voices(self, language: str) -> list[Voice] | None: return [Voice(voice, voice) for voice in voices] @property - def default_options(self): + def default_options(self) -> dict[str, Any]: """Return a dict include default options.""" return { ATTR_GENDER: self._gender, ATTR_AUDIO_OUTPUT: AudioOutput.MP3, } - async def async_get_tts_audio(self, message, language, options=None): + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: """Load TTS from NabuCasa Cloud.""" # Process TTS try: diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index 801718b88a7760..b4dc01d03aa6d0 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 61ec27b32419b2..d0a6b53964bd75 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -3,10 +3,10 @@ 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 ( @@ -24,6 +24,8 @@ _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + # Extend the existing light.turn_on service schema SERVICE_SCHEMA = vol.All( cv.has_at_least_one_key(ATTR_URL, ATTR_PATH), diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index fe0640d3efa782..6f536bf4744390 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -1 +1,224 @@ """The command_line component.""" +from __future__ import annotations + +import asyncio +from collections.abc import Coroutine +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, + DOMAIN as BINARY_SENSOR_DOMAIN, + SCAN_INTERVAL as BINARY_SENSOR_DEFAULT_SCAN_INTERVAL, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SCAN_INTERVAL as COVER_DEFAULT_SCAN_INTERVAL, +) +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, + DOMAIN as SENSOR_DOMAIN, + SCAN_INTERVAL as SENSOR_DEFAULT_SCAN_INTERVAL, + STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA, +) +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SCAN_INTERVAL as SWITCH_DEFAULT_SCAN_INTERVAL, +) +from homeassistant.const import ( + CONF_COMMAND, + CONF_COMMAND_CLOSE, + CONF_COMMAND_OFF, + CONF_COMMAND_ON, + CONF_COMMAND_OPEN, + CONF_COMMAND_STATE, + CONF_COMMAND_STOP, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_NAME, + CONF_PAYLOAD_OFF, + CONF_PAYLOAD_ON, + CONF_SCAN_INTERVAL, + CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, + SERVICE_RELOAD, + Platform, +) +from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.helpers.reload import async_integration_yaml_config +from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN + +BINARY_SENSOR_DEFAULT_NAME = "Binary Command Sensor" +DEFAULT_PAYLOAD_ON = "ON" +DEFAULT_PAYLOAD_OFF = "OFF" +CONF_JSON_ATTRIBUTES = "json_attributes" +SENSOR_DEFAULT_NAME = "Command Sensor" +CONF_NOTIFIERS = "notifiers" + +PLATFORM_MAPPING = { + BINARY_SENSOR_DOMAIN: Platform.BINARY_SENSOR, + COVER_DOMAIN: Platform.COVER, + NOTIFY_DOMAIN: Platform.NOTIFY, + SENSOR_DOMAIN: Platform.SENSOR, + SWITCH_DOMAIN: Platform.SWITCH, +} + +_LOGGER = logging.getLogger(__name__) + +BINARY_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME, default=BINARY_SENSOR_DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional( + CONF_SCAN_INTERVAL, default=BINARY_SENSOR_DEFAULT_SCAN_INTERVAL + ): vol.All(cv.time_period, cv.positive_timedelta), + } +) +COVER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_COMMAND_CLOSE, default="true"): cv.string, + vol.Optional(CONF_COMMAND_OPEN, default="true"): cv.string, + vol.Optional(CONF_COMMAND_STATE): cv.string, + vol.Optional(CONF_COMMAND_STOP, default="true"): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=COVER_DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), + } +) +NOTIFY_SCHEMA = vol.Schema( + { + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + } +) +SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_JSON_ATTRIBUTES): cv.ensure_list_csv, + vol.Optional(CONF_NAME, default=SENSOR_DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, + vol.Optional(CONF_SCAN_INTERVAL, default=SENSOR_DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), + } +) +SWITCH_SCHEMA = vol.Schema( + { + vol.Optional(CONF_COMMAND_OFF, default="true"): cv.string, + vol.Optional(CONF_COMMAND_ON, default="true"): cv.string, + vol.Optional(CONF_COMMAND_STATE): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SWITCH_DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), + } +) +COMBINED_SCHEMA = vol.Schema( + { + vol.Optional(BINARY_SENSOR_DOMAIN): BINARY_SENSOR_SCHEMA, + vol.Optional(COVER_DOMAIN): COVER_SCHEMA, + vol.Optional(NOTIFY_DOMAIN): NOTIFY_SCHEMA, + vol.Optional(SENSOR_DOMAIN): SENSOR_SCHEMA, + vol.Optional(SWITCH_DOMAIN): SWITCH_SCHEMA, + } +) +CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(DOMAIN): vol.All( + cv.ensure_list, + [COMBINED_SCHEMA], + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Command Line from yaml config.""" + + async def _reload_config(call: Event | ServiceCall) -> None: + """Reload Command Line.""" + reload_config = await async_integration_yaml_config(hass, "command_line") + reset_platforms = async_get_platforms(hass, "command_line") + for reset_platform in reset_platforms: + _LOGGER.debug("Reload resetting platform: %s", reset_platform.domain) + await reset_platform.async_reset() + if not reload_config: + return + await async_load_platforms(hass, reload_config.get(DOMAIN, []), reload_config) + + async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) + + await async_load_platforms(hass, config.get(DOMAIN, []), config) + + return True + + +async def async_load_platforms( + hass: HomeAssistant, + command_line_config: list[dict[str, dict[str, Any]]], + config: ConfigType, +) -> None: + """Load platforms from yaml.""" + if not command_line_config: + return + + _LOGGER.debug("Full config loaded: %s", command_line_config) + + load_coroutines: list[Coroutine[Any, Any, None]] = [] + platforms: list[Platform] = [] + reload_configs: list[tuple] = [] + for platform_config in command_line_config: + for platform, _config in platform_config.items(): + if (mapped_platform := PLATFORM_MAPPING[platform]) not in platforms: + platforms.append(mapped_platform) + _LOGGER.debug( + "Loading config %s for platform %s", + platform_config, + PLATFORM_MAPPING[platform], + ) + reload_configs.append((PLATFORM_MAPPING[platform], _config)) + load_coroutines.append( + discovery.async_load_platform( + hass, + PLATFORM_MAPPING[platform], + DOMAIN, + _config, + config, + ) + ) + + if load_coroutines: + _LOGGER.debug("Loading platforms: %s", platforms) + await asyncio.gather(*load_coroutines) diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 0c2edb8f1912ef..f2097178a95f3f 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -1,12 +1,14 @@ """Support for custom shell commands to retrieve values.""" from __future__ import annotations +import asyncio from datetime import timedelta import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, + DOMAIN as BINARY_SENSOR_DOMAIN, PLATFORM_SCHEMA, BinarySensorDeviceClass, BinarySensorEntity, @@ -17,17 +19,21 @@ CONF_NAME, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .sensor import CommandSensorData DEFAULT_NAME = "Binary Command Sensor" @@ -59,60 +65,111 @@ async def async_setup_platform( ) -> None: """Set up the Command line Binary Sensor.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - - name: str = config.get(CONF_NAME, DEFAULT_NAME) - command: str = config[CONF_COMMAND] - payload_off: str = config[CONF_PAYLOAD_OFF] - payload_on: str = config[CONF_PAYLOAD_ON] - device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) - value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) - command_timeout: int = config[CONF_COMMAND_TIMEOUT] - unique_id: str | None = config.get(CONF_UNIQUE_ID) + if binary_sensor_config := config: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_binary_sensor", + breaks_in_ha_version="2023.12.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_platform_yaml", + translation_placeholders={"platform": BINARY_SENSOR_DOMAIN}, + ) + if discovery_info: + binary_sensor_config = discovery_info + + name: str = binary_sensor_config.get(CONF_NAME, DEFAULT_NAME) + command: str = binary_sensor_config[CONF_COMMAND] + payload_off: str = binary_sensor_config[CONF_PAYLOAD_OFF] + payload_on: str = binary_sensor_config[CONF_PAYLOAD_ON] + device_class: BinarySensorDeviceClass | None = binary_sensor_config.get( + CONF_DEVICE_CLASS + ) + value_template: Template | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE) + command_timeout: int = binary_sensor_config[CONF_COMMAND_TIMEOUT] + unique_id: str | None = binary_sensor_config.get(CONF_UNIQUE_ID) + scan_interval: timedelta = binary_sensor_config.get( + CONF_SCAN_INTERVAL, SCAN_INTERVAL + ) if value_template is not None: value_template.hass = hass data = CommandSensorData(hass, command, command_timeout) + 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, ) ], - True, ) -class CommandBinarySensor(BinarySensorEntity): +class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): """Representation of a command line binary sensor.""" + _attr_should_poll = False + 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 + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + await self._update_entity_state(None) + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Binary Sensor - {self.name}", + cancel_on_shutdown=True, + ), + ) + + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Binary Sensor %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return - async def async_update(self) -> None: + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Get the latest data and updates the state.""" await self.hass.async_add_executor_job(self.data.update) value = self.data.value @@ -126,3 +183,13 @@ async def async_update(self) -> None: self._attr_is_on = True 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: + """Update the entity. + + Only used by the generic entity update service. + """ + await self._update_entity_state(dt_util.now()) diff --git a/homeassistant/components/command_line/const.py b/homeassistant/components/command_line/const.py index 4394f3889105d9..ff51cb7e3313f9 100644 --- a/homeassistant/components/command_line/const.py +++ b/homeassistant/components/command_line/const.py @@ -1,7 +1,11 @@ """Allows to configure custom shell commands to turn a value for a sensor.""" +import logging + from homeassistant.const import Platform +LOGGER = logging.getLogger(__package__) + CONF_COMMAND_TIMEOUT = "command_timeout" DEFAULT_TIMEOUT = 15 DOMAIN = "command_line" diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index e477affc8541a9..553af2f0c866d2 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -1,12 +1,17 @@ """Support for command line covers.""" from __future__ import annotations -import logging +import asyncio +from datetime import timedelta from typing import TYPE_CHECKING, Any import voluptuous as vol -from homeassistant.components.cover import PLATFORM_SCHEMA, CoverEntity +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + PLATFORM_SCHEMA, + CoverEntity, +) from homeassistant.const import ( CONF_COMMAND_CLOSE, CONF_COMMAND_OPEN, @@ -14,20 +19,25 @@ CONF_COMMAND_STOP, CONF_COVERS, CONF_FRIENDLY_NAME, + CONF_NAME, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util, slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import call_shell_with_timeout, check_output_or_log -_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=15) COVER_SCHEMA = vol.Schema( { @@ -55,52 +65,75 @@ async def async_setup_platform( ) -> None: """Set up cover controlled by shell commands.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - - devices: dict[str, Any] = config.get(CONF_COVERS, {}) covers = [] + if discovery_info: + entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info} + else: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_cover", + breaks_in_ha_version="2023.12.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_platform_yaml", + translation_placeholders={"platform": COVER_DOMAIN}, + ) + entities = config.get(CONF_COVERS, {}) - for device_name, device_config in devices.items(): + for device_name, device_config in entities.items(): value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass + if name := device_config.get( + CONF_FRIENDLY_NAME + ): # Backward compatibility. Can be removed after deprecation + device_config[CONF_NAME] = name + + 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_FRIENDLY_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), ) ) if not covers: - _LOGGER.error("No covers added") + LOGGER.error("No covers added") return 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 @@ -108,18 +141,32 @@ def __init__( self._command_state = command_state self._value_template = value_template self._timeout = timeout - self._attr_unique_id = unique_id - self._attr_should_poll = bool(command_state) + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + if self._command_state: + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Cover - {self.name}", + cancel_on_shutdown=True, + ), + ) def _move_cover(self, command: str) -> bool: """Execute the actual commands.""" - _LOGGER.info("Running command: %s", command) + LOGGER.info("Running command: %s", command) returncode = call_shell_with_timeout(command, self._timeout) success = returncode == 0 if not success: - _LOGGER.error( + LOGGER.error( "Command failed (with return code %s): %s", returncode, command ) @@ -143,12 +190,27 @@ def current_cover_position(self) -> int | None: def _query_state(self) -> str | None: """Query for the state.""" if self._command_state: - _LOGGER.info("Running state value command: %s", self._command_state) + LOGGER.info("Running state value command: %s", self._command_state) return check_output_or_log(self._command_state, self._timeout) if TYPE_CHECKING: return None - async def async_update(self) -> None: + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Cover %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return + + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Update device state.""" if self._command_state: payload = str(await self.hass.async_add_executor_job(self._query_state)) @@ -159,15 +221,27 @@ async def async_update(self) -> None: self._state = None if payload: self._state = int(payload) + self._process_manual_data(payload) + self.async_write_ha_state() + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ + await self._update_entity_state(dt_util.now()) - def open_cover(self, **kwargs: Any) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - self._move_cover(self._command_open) + await self.hass.async_add_executor_job(self._move_cover, self._command_open) + await self._update_entity_state(None) - def close_cover(self, **kwargs: Any) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - self._move_cover(self._command_close) + await self.hass.async_add_executor_job(self._move_cover, self._command_close) + await self._update_entity_state(None) - def stop_cover(self, **kwargs: Any) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - self._move_cover(self._command_stop) + await self.hass.async_add_executor_job(self._move_cover, self._command_stop) + await self._update_entity_state(None) diff --git a/homeassistant/components/command_line/manifest.json b/homeassistant/components/command_line/manifest.json index 998c02aad9ec1d..e99234bed1b9c8 100644 --- a/homeassistant/components/command_line/manifest.json +++ b/homeassistant/components/command_line/manifest.json @@ -1,7 +1,7 @@ { "domain": "command_line", "name": "Command Line", - "codeowners": [], + "codeowners": ["@gjohansson-ST"], "documentation": "https://www.home-assistant.io/integrations/command_line", "iot_class": "local_polling" } diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index 412456ff6e5fa7..d00926eb0ee5f3 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -7,14 +7,19 @@ import voluptuous as vol -from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService +from homeassistant.components.notify import ( + DOMAIN as NOTIFY_DOMAIN, + PLATFORM_SCHEMA, + BaseNotificationService, +) from homeassistant.const import CONF_COMMAND, CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.process import kill_subprocess -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -33,8 +38,21 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> CommandLineNotificationService: """Get the Command Line notification service.""" - command: str = config[CONF_COMMAND] - timeout: int = config[CONF_COMMAND_TIMEOUT] + if notify_config := config: + create_issue( + hass, + DOMAIN, + "deprecated_yaml_notify", + breaks_in_ha_version="2023.12.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_platform_yaml", + translation_placeholders={"platform": NOTIFY_DOMAIN}, + ) + if discovery_info: + notify_config = discovery_info + command: str = notify_config[CONF_COMMAND] + timeout: int = notify_config[CONF_COMMAND_TIMEOUT] return CommandLineNotificationService(command, timeout) @@ -54,7 +72,7 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: 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 b6a2b8d83faae4..1b865827e697c9 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -1,24 +1,28 @@ """Allows to configure custom shell commands to turn a value for a sensor.""" from __future__ import annotations +import asyncio from collections.abc import Mapping from datetime import timedelta import json -import logging +from typing import Any, cast import voluptuous as vol from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, + DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, STATE_CLASSES_SCHEMA, SensorEntity, + SensorStateClass, ) from homeassistant.const import ( CONF_COMMAND, CONF_DEVICE_CLASS, CONF_NAME, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, @@ -27,15 +31,16 @@ from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import check_output_or_log -_LOGGER = logging.getLogger(__name__) - CONF_JSON_ATTRIBUTES = "json_attributes" DEFAULT_NAME = "Command Sensor" @@ -64,58 +69,116 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Command Sensor.""" + if sensor_config := config: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_sensor", + breaks_in_ha_version="2023.12.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_platform_yaml", + translation_placeholders={"platform": SENSOR_DOMAIN}, + ) + if discovery_info: + sensor_config = discovery_info - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - - name: str = config[CONF_NAME] - command: str = config[CONF_COMMAND] - unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT) - value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) - command_timeout: int = config[CONF_COMMAND_TIMEOUT] - unique_id: str | None = config.get(CONF_UNIQUE_ID) + name: str = sensor_config[CONF_NAME] + command: str = sensor_config[CONF_COMMAND] + unit: str | None = sensor_config.get(CONF_UNIT_OF_MEASUREMENT) + value_template: Template | None = sensor_config.get(CONF_VALUE_TEMPLATE) + command_timeout: int = sensor_config[CONF_COMMAND_TIMEOUT] + unique_id: str | None = sensor_config.get(CONF_UNIQUE_ID) if value_template is not None: value_template.hass = hass - json_attributes: list[str] | None = config.get(CONF_JSON_ATTRIBUTES) + json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES) + scan_interval: timedelta = sensor_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + 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, ) - ], - True, + ] ) -class CommandSensor(SensorEntity): +class CommandSensor(ManualTriggerEntity, SensorEntity): """Representation of a sensor that is using shell commands.""" + _attr_should_poll = False + 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 - async def async_update(self) -> 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() + await self._update_entity_state(None) + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Sensor - {self.name}", + cancel_on_shutdown=True, + ), + ) + + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Sensor %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return + + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Get the latest data and updates the state.""" await self.hass.async_add_executor_job(self.data.update) value = self.data.value @@ -132,13 +195,14 @@ async def async_update(self) -> None: if k in json_dict } else: - _LOGGER.warning("JSON result was not a dictionary") + LOGGER.warning("JSON result was not a dictionary") except ValueError: - _LOGGER.warning("Unable to parse output as JSON: %s", value) + LOGGER.warning("Unable to parse output as JSON: %s", value) else: - _LOGGER.warning("Empty reply found when expecting JSON data") + LOGGER.warning("Empty reply found when expecting JSON data") if self._value_template is None: self._attr_native_value = None + self._process_manual_data(value) return if self._value_template is not None: @@ -150,6 +214,15 @@ async def async_update(self) -> None: ) else: self._attr_native_value = value + self._process_manual_data(value) + self.async_write_ha_state() + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ + await self._update_entity_state(dt_util.now()) class CommandSensorData: @@ -179,7 +252,7 @@ def update(self) -> None: args_to_render = {"arguments": args} rendered_args = args_compiled.render(args_to_render) except TemplateError as ex: - _LOGGER.exception("Error rendering command template: %s", ex) + LOGGER.exception("Error rendering command template: %s", ex) return else: rendered_args = None @@ -191,5 +264,5 @@ def update(self) -> None: # Template used. Construct the string used in the shell command = f"{prog} {rendered_args}" - _LOGGER.debug("Running command: %s", command) + LOGGER.debug("Running command: %s", command) self.value = check_output_or_log(command, self.timeout) diff --git a/homeassistant/components/command_line/strings.json b/homeassistant/components/command_line/strings.json new file mode 100644 index 00000000000000..dab4a77a6ec37d --- /dev/null +++ b/homeassistant/components/command_line/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "deprecated_platform_yaml": { + "title": "Command Line YAML configuration has moved", + "description": "Configuring Command Line `{platform}` using YAML has moved.\n\nConsult the documentation to move your YAML configuration to integration key and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index bfb45f5b5c425e..8fbafd7a4d168a 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -1,12 +1,14 @@ """Support for custom shell commands to turn a switch on/off.""" from __future__ import annotations -import logging +import asyncio +from datetime import timedelta from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, SwitchEntity, @@ -19,6 +21,7 @@ CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, + CONF_SCAN_INTERVAL, CONF_SWITCHES, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -26,15 +29,17 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util, slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import call_shell_with_timeout, check_output_or_log -_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=30) SWITCH_SCHEMA = vol.Schema( { @@ -62,16 +67,38 @@ async def async_setup_platform( ) -> None: """Find and return switches controlled by shell commands.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + if discovery_info: + entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info} + else: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_switch", + breaks_in_ha_version="2023.12.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_platform_yaml", + translation_placeholders={"platform": SWITCH_DOMAIN}, + ) + entities = config.get(CONF_SWITCHES, {}) - devices: dict[str, Any] = config.get(CONF_SWITCHES, {}) switches = [] - for object_id, device_config in devices.items(): + for object_id, device_config in entities.items(): + if name := device_config.get( + CONF_FRIENDLY_NAME + ): # Backward compatibility. Can be removed after deprecation + device_config[CONF_NAME] = name + + if icon := device_config.get( + CONF_ICON_TEMPLATE + ): # Backward compatibility. Can be removed after deprecation + device_config[CONF_ICON] = icon + trigger_entity_config = { CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), - CONF_NAME: Template(device_config.get(CONF_FRIENDLY_NAME, object_id), hass), - CONF_ICON: device_config.get(CONF_ICON_TEMPLATE), + CONF_NAME: Template(device_config.get(CONF_NAME, object_id), hass), + CONF_ICON: device_config.get(CONF_ICON), } value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE) @@ -88,11 +115,12 @@ async def async_setup_platform( device_config.get(CONF_COMMAND_STATE), value_template, device_config[CONF_COMMAND_TIMEOUT], + device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), ) ) if not switches: - _LOGGER.error("No switches added") + LOGGER.error("No switches added") return async_add_entities(switches) @@ -101,6 +129,8 @@ async def async_setup_platform( class CommandSwitch(ManualTriggerEntity, SwitchEntity): """Representation a switch that can be toggled using shell commands.""" + _attr_should_poll = False + def __init__( self, config: ConfigType, @@ -110,6 +140,7 @@ def __init__( command_state: str | None, value_template: Template | None, timeout: int, + scan_interval: timedelta, ) -> None: """Initialize the switch.""" super().__init__(self.hass, config) @@ -120,11 +151,26 @@ def __init__( self._command_state = command_state self._value_template = value_template self._timeout = timeout - self._attr_should_poll = bool(command_state) + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + if self._command_state: + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Cover - {self.name}", + cancel_on_shutdown=True, + ), + ) async def _switch(self, command: str) -> bool: """Execute the actual commands.""" - _LOGGER.info("Running command: %s", command) + LOGGER.info("Running command: %s", command) success = ( await self.hass.async_add_executor_job( @@ -134,18 +180,18 @@ async def _switch(self, command: str) -> bool: ) if not success: - _LOGGER.error("Command failed: %s", command) + LOGGER.error("Command failed: %s", command) return success def _query_state_value(self, command: str) -> str | None: """Execute state command for return value.""" - _LOGGER.info("Running state value command: %s", command) + LOGGER.info("Running state value command: %s", command) return check_output_or_log(command, self._timeout) def _query_state_code(self, command: str) -> bool: """Execute state command for return code.""" - _LOGGER.info("Running state code command: %s", command) + LOGGER.info("Running state code command: %s", command) return ( call_shell_with_timeout(command, self._timeout, log_return_code=False) == 0 ) @@ -164,7 +210,22 @@ def _query_state(self) -> str | int | None: if TYPE_CHECKING: return None - async def async_update(self) -> None: + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Switch %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return + + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Update device state.""" if self._command_state: payload = str(await self.hass.async_add_executor_job(self._query_state)) @@ -177,15 +238,25 @@ async def async_update(self) -> None: if payload or value: self._attr_is_on = (value or payload).lower() == "true" self._process_manual_data(payload) + self.async_write_ha_state() + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ + await self._update_entity_state(dt_util.now()) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" if await self._switch(self._command_on) and not self._command_state: self._attr_is_on = True self.async_schedule_update_ha_state() + await self._update_entity_state(None) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" if await self._switch(self._command_off) and not self._command_state: self._attr_is_on = False self.async_schedule_update_ha_state() + await self._update_entity_state(None) diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py index 2d42732190ef5f..66faa3a0bf828c 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 e36737c7d350fe..01003020108374 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 @@ 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 @@ 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 @@ def datapoints_greater_than_degree(value: dict) -> dict: ], 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 f116725883ecdb..d49a6982166d39 100644 --- a/homeassistant/components/compensation/const.py +++ b/homeassistant/components/compensation/const.py @@ -4,6 +4,8 @@ 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 16226974120036..4d6ff95b8107f8 100644 --- a/homeassistant/components/compensation/sensor.py +++ b/homeassistant/components/compensation/sensor.py @@ -10,6 +10,8 @@ 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 @@ def __init__( 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 @@ def __init__( 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 @@ def _async_compensation_sensor_state_listener(self, event: Event) -> None: 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/config/__init__.py b/homeassistant/components/config/__init__.py index def7edd4950cf7..514154137e4c70 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -11,6 +11,7 @@ from homeassistant.const import CONF_ID, EVENT_COMPONENT_LOADED from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.setup import ATTR_COMPONENT from homeassistant.util.file import write_utf8_file_atomic @@ -32,6 +33,8 @@ ACTION_CREATE_UPDATE = "create_update" ACTION_DELETE = "delete" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the config component.""" diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 2e1ea72dfb4522..d58616ff38f4f0 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -41,9 +41,9 @@ async def async_setup(hass): hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options)) hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options)) - websocket_api.async_register_command(hass, config_entries_get_matching) + websocket_api.async_register_command(hass, config_entries_get) websocket_api.async_register_command(hass, config_entry_disable) - websocket_api.async_register_command(hass, config_entry_get) + websocket_api.async_register_command(hass, config_entry_get_single) websocket_api.async_register_command(hass, config_entry_update) websocket_api.async_register_command(hass, config_entries_subscribe) websocket_api.async_register_command(hass, config_entries_progress) @@ -288,12 +288,12 @@ def get_entry( @websocket_api.require_admin @websocket_api.websocket_command( { - "type": "config_entries/get", + "type": "config_entries/get_single", "entry_id": str, } ) @websocket_api.async_response -async def config_entry_get( +async def config_entry_get_single( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], @@ -432,13 +432,13 @@ async def ignore_config_flow( @websocket_api.websocket_command( { - vol.Required("type"): "config_entries/get_matching", + vol.Required("type"): "config_entries/get", vol.Optional("type_filter"): vol.All(cv.ensure_list, [str]), vol.Optional("domain"): str, } ) @websocket_api.async_response -async def config_entries_get_matching( +async def config_entries_get( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index ea785b5cdfbe20..6fd3917cc9c869 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback as async_callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType @@ -44,6 +45,8 @@ ConfiguratorCallback = Callable[[list[dict[str, str]]], None] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @bind_hass @async_callback diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index fde9b00aba26bb..a2d1308be982ae 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -30,7 +30,7 @@ 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_update_data_dimmer(): """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 @@ def _create_api_object(self): 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 f156acfd568c62..5b82b5dae72e0f 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -8,6 +8,7 @@ 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.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 handle_process(service: core.ServiceCall) -> None: 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 handle_reload(service: core.ServiceCall) -> None: 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 handle_reload(service: core.ServiceCall) -> None: 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 @@ def async_is_valid_agent_id(self, agent_id: str) -> bool: 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 162338a6ff0072..99b9c9392d8fd9 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 dccf394ab3fa1a..336d6287f18913 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -3,8 +3,9 @@ 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 @@ -31,7 +32,7 @@ template, translation, ) -from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.event import async_track_state_added_domain from homeassistant.util.json import JsonObjectType, json_loads_object from .agent import AbstractConversationAgent, ConversationInput, ConversationResult @@ -42,6 +43,9 @@ _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 @@ -83,22 +95,16 @@ def async_setup(hass: core.HomeAssistant) -> None: async_should_expose(hass, DOMAIN, entity_id) @core.callback - def async_entity_state_listener( - changed_entity: str, - old_state: core.State | None, - new_state: core.State | None, - ): + def async_entity_state_listener(event: core.Event) -> None: """Set expose flag on new entities.""" - if old_state is not None or new_state is None: - return - async_should_expose(hass, DOMAIN, changed_entity) + async_should_expose(hass, DOMAIN, event.data["entity_id"]) @core.callback def async_hass_started(hass: core.HomeAssistant) -> None: """Set expose flag on all entities.""" for state in hass.states.async_all(): async_should_expose(hass, DOMAIN, state.entity_id) - async_track_state_change(hass, MATCH_ALL, async_entity_state_listener) + async_track_state_added_domain(hass, MATCH_ALL, async_entity_state_listener) start.async_at_started(hass, async_hass_started) @@ -116,6 +122,10 @@ def __init__(self, hass: core.HomeAssistant) -> None: 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.""" @@ -149,11 +159,12 @@ async def async_initialize(self, config_intents): 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 ( @@ -165,21 +176,29 @@ async def async_process(self, user_input: ConversationInput) -> ConversationResu 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( @@ -189,6 +208,10 @@ async def async_process(self, user_input: ConversationInput) -> ConversationResu 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, @@ -284,13 +307,13 @@ async def _build_speech( all_states = matched + unmatched domains = {state.domain for state in all_states} translations = await translation.async_get_translations( - self.hass, language, "state", domains + self.hass, language, "entity_component", domains ) # Use translated state names for state in all_states: device_class = state.attributes.get("device_class", "_") - key = f"component.{state.domain}.state.{device_class}.{state.state}" + key = f"component.{state.domain}.entity_component.{device_class}.state.{state.state}" state.state = translations.get(key, state.state) # Get first matched or unmatched state. @@ -419,9 +442,18 @@ def _get_or_load_intents( encoding="utf-8" ) as custom_sentences_file: # Merge custom sentences - merge_dict( - intents_dict, yaml.safe_load(custom_sentences_file) - ) + if isinstance( + custom_sentences_yaml := yaml.safe_load( + custom_sentences_file + ), + dict, + ): + merge_dict(intents_dict, custom_sentences_yaml) + else: + _LOGGER.warning( + "Custom sentences file does not match expected format path=%s", + custom_sentences_file.name, + ) # Will need to recreate graph intents_changed = True @@ -582,13 +614,109 @@ def _make_slot_lists(self) -> dict[str, SlotList]: 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 209f239df00bb1..aa2d0c32d16ee9 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.4.26"] + "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 6b031ff7142f6f..1a28044dcb5d9f 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 00000000000000..b64b74c5fa69ba --- /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 884cf98b742b24..29fd5797124535 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 a32a9833dd9478..e4dfb371a0b831 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 d27f776c655e93..6ae6613bcca87b 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 @@ def unique_id(self): """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 65f21b77534592..1607e220a55194 100644 --- a/homeassistant/components/coolmaster/entity.py +++ b/homeassistant/components/coolmaster/entity.py @@ -12,6 +12,8 @@ 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 59b0e71abb2008..5c6774e8c9238d 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 6bba26b6bc9640..7baa6444c1dbb2 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 d2834b8991b76b..6f3d48fc1bbd4e 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -292,7 +292,7 @@ def async_configure(self, **kwargs) -> None: 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 dd22821d5e462d..e34a623be937d8 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -3,6 +3,7 @@ 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 @@ 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 6144bdb6dbf6c1..2aa0a1dd2fb845 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -4,7 +4,6 @@ import voluptuous as vol from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_ABOVE, CONF_BELOW, CONF_CONDITION, @@ -43,7 +42,7 @@ 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 @@ 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 check_numeric_state( ) -> 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 aad225c803969c..2fb456d726d74c 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -43,7 +43,7 @@ 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 @@ 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 c71de53ebbe7aa..7eb3cfab753fc5 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 c642eb9112e03b..dd5366dee6a9d6 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 @@ def extra_state_attributes(self): 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/__init__.py b/homeassistant/components/daikin/__init__.py index 481a072bdb3c73..b0097f607d5809 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -15,8 +15,9 @@ CONF_UUID, Platform, ) -from homeassistant.core import HomeAssistant +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.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC @@ -52,6 +53,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not daikin_api: return False + await async_migrate_unique_id(hass, entry, daikin_api) + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: daikin_api}) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -67,7 +70,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def daikin_api_setup(hass, host, key, uuid, password): +async def daikin_api_setup(hass: HomeAssistant, host, key, uuid, password): """Create a Daikin instance only once.""" session = async_get_clientsession(hass) @@ -127,3 +130,82 @@ def device_info(self) -> DeviceInfo: name=info.get("name"), sw_version=info.get("ver", "").replace("_", "."), ) + + +async def async_migrate_unique_id( + hass: HomeAssistant, config_entry: ConfigEntry, api: DaikinApi +) -> None: + """Migrate old entry.""" + dev_reg = dr.async_get(hass) + old_unique_id = config_entry.unique_id + new_unique_id = api.device.mac + new_name = api.device.values["name"] + + @callback + def _update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + return update_unique_id(entity_entry, new_unique_id) + + if new_unique_id == old_unique_id: + return + + # Migrate devices + for device_entry in dr.async_entries_for_config_entry( + dev_reg, config_entry.entry_id + ): + for connection in device_entry.connections: + if connection[1] == old_unique_id: + new_connections = { + (CONNECTION_NETWORK_MAC, dr.format_mac(new_unique_id)) + } + + _LOGGER.debug( + "Migrating device %s connections to %s", + device_entry.name, + new_connections, + ) + dev_reg.async_update_device( + device_entry.id, + merge_connections=new_connections, + ) + + if device_entry.name is None: + _LOGGER.debug( + "Migrating device name to %s", + new_name, + ) + dev_reg.async_update_device( + device_entry.id, + name=new_name, + ) + + # Migrate entities + await er.async_migrate_entries(hass, config_entry.entry_id, _update_unique_id) + + new_data = {**config_entry.data, KEY_MAC: dr.format_mac(new_unique_id)} + + hass.config_entries.async_update_entry( + config_entry, unique_id=new_unique_id, data=new_data + ) + + +@callback +def update_unique_id( + entity_entry: er.RegistryEntry, unique_id: str +) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + if entity_entry.unique_id.startswith(unique_id): + # Already correct, nothing to do + return None + + unique_id_parts = entity_entry.unique_id.split("-") + unique_id_parts[0] = unique_id + entity_new_unique_id = "-".join(unique_id_parts) + + _LOGGER.debug( + "Migrating entity %s from %s to new id %s", + entity_entry.entity_id, + entity_entry.unique_id, + entity_new_unique_id, + ) + return {"new_unique_id": entity_new_unique_id} diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index bd0e846ea4d9cb..5ede11c60b673d 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -276,17 +276,16 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: 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/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 6f90b0cf5efa6b..c6334dfaeca417 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["pydaikin"], "quality_scale": "platinum", - "requirements": ["pydaikin==2.9.0"], + "requirements": ["pydaikin==2.10.5"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 68cd4fdc590d87..847f030fae5aba 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -42,7 +42,7 @@ async def async_setup_entry( [ DaikinZoneSwitch(daikin_api, zone_id) for zone_id, zone in enumerate(zones) - if zone != ("-", "0") + if zone[0] != "-" ] ) if daikin_api.device.support_advanced_modes: @@ -90,11 +90,11 @@ async def async_update(self) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn the zone on.""" - await self._api.device.set_zone(self._zone_id, "1") + await self._api.device.set_zone(self._zone_id, "zone_onoff", "1") async def async_turn_off(self, **kwargs: Any) -> None: """Turn the zone off.""" - await self._api.device.set_zone(self._zone_id, "0") + await self._api.device.set_zone(self._zone_id, "zone_onoff", "0") class DaikinStreamerSwitch(SwitchEntity): diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py new file mode 100644 index 00000000000000..fb67f4b1ffb688 --- /dev/null +++ b/homeassistant/components/datetime/__init__.py @@ -0,0 +1,126 @@ +"""Component to allow setting date/time as platforms.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +import logging +from typing import final + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + ENTITY_SERVICE_FIELDS, + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util + +from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +_LOGGER = logging.getLogger(__name__) + +__all__ = ["ATTR_DATETIME", "DOMAIN", "DateTimeEntity", "DateTimeEntityDescription"] + + +async def _async_set_value(entity: DateTimeEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new date/time.""" + value: datetime = service_call.data[ATTR_DATETIME] + if value.tzinfo is None: + value = value.replace( + tzinfo=dt_util.get_time_zone(entity.hass.config.time_zone) + ) + return await entity.async_set_value(value) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Date/Time entities.""" + component = hass.data[DOMAIN] = EntityComponent[DateTimeEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_SET_VALUE, + { + vol.Required(ATTR_DATETIME): cv.datetime, + **ENTITY_SERVICE_FIELDS, + }, + _async_set_value, + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[DateTimeEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[DateTimeEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class DateTimeEntityDescription(EntityDescription): + """A class that describes date/time entities.""" + + +class DateTimeEntity(Entity): + """Representation of a Date/time entity.""" + + entity_description: DateTimeEntityDescription + _attr_device_class: None = None + _attr_state: None = None + _attr_native_value: datetime | None + + @property + @final + def device_class(self) -> None: + """Return entity device class.""" + return None + + @property + @final + def state_attributes(self) -> None: + """Return the state attributes.""" + return None + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if (value := self.native_value) is None: + return None + if value.tzinfo is None: + raise ValueError( + f"Invalid datetime: {self.entity_id} provides state '{value}', " + "which is missing timezone information" + ) + + return value.astimezone(timezone.utc).isoformat(timespec="seconds") + + @property + def native_value(self) -> datetime | None: + """Return the value reported by the datetime.""" + return self._attr_native_value + + def set_value(self, value: datetime) -> None: + """Change the date/time.""" + raise NotImplementedError() + + async def async_set_value(self, value: datetime) -> None: + """Change the date/time.""" + await self.hass.async_add_executor_job(self.set_value, value) diff --git a/homeassistant/components/datetime/const.py b/homeassistant/components/datetime/const.py new file mode 100644 index 00000000000000..f9a5c4e538cf41 --- /dev/null +++ b/homeassistant/components/datetime/const.py @@ -0,0 +1,7 @@ +"""Provides the constants needed for the component.""" + +DOMAIN = "datetime" + +ATTR_DATETIME = "datetime" + +SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/datetime/manifest.json b/homeassistant/components/datetime/manifest.json new file mode 100644 index 00000000000000..469d9a8bd988ce --- /dev/null +++ b/homeassistant/components/datetime/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "datetime", + "name": "Date/Time", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/datetime", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/datetime/services.yaml b/homeassistant/components/datetime/services.yaml new file mode 100644 index 00000000000000..b5cce19e88b70e --- /dev/null +++ b/homeassistant/components/datetime/services.yaml @@ -0,0 +1,14 @@ +set_value: + name: Set Date/Time + description: Set the date/time for a datetime entity. + target: + entity: + domain: datetime + fields: + datetime: + name: Date & Time + description: The date/time to set. The time zone of the Home Assistant instance is assumed. + required: true + example: "2022/11/01 22:15" + selector: + datetime: diff --git a/homeassistant/components/datetime/strings.json b/homeassistant/components/datetime/strings.json new file mode 100644 index 00000000000000..3b97559018c770 --- /dev/null +++ b/homeassistant/components/datetime/strings.json @@ -0,0 +1,8 @@ +{ + "title": "Date/Time", + "entity_component": { + "_": { + "name": "[%key:component::datetime::title%]" + } + } +} diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 61794e7c70a242..6245558a1c55a7 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==111"], + "requirements": ["pydeconz==113"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 136f582f5c7f51..4e00ac0a41525e 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -16,6 +16,7 @@ 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 @@ GenericStatus, Humidity, LightLevel, + Moisture, Power, Pressure, Temperature, @@ -194,6 +196,7 @@ class DeconzSensorDescription(SensorEntityDescription, DeconzSensorDescriptionMi device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=1, ), DeconzSensorDescription[LightLevel]( key="light_level", @@ -205,6 +208,17 @@ class DeconzSensorDescription(SensorEntityDescription, DeconzSensorDescriptionMi 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, @@ -234,6 +248,7 @@ class DeconzSensorDescription(SensorEntityDescription, DeconzSensorDescriptionMi device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, ), DeconzSensorDescription[Time]( key="last_set", diff --git a/homeassistant/components/decora_wifi/manifest.json b/homeassistant/components/decora_wifi/manifest.json index 592942ee99b7d8..0bead527e78e7e 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/default_config/__init__.py b/homeassistant/components/default_config/__init__.py index d91d06949e691d..25a9ca311e8030 100644 --- a/homeassistant/components/default_config/__init__.py +++ b/homeassistant/components/default_config/__init__.py @@ -1,11 +1,14 @@ """Component providing default configuration for new users.""" from homeassistant.components.hassio import is_hassio from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component DOMAIN = "default_config" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize default configuration.""" diff --git a/homeassistant/components/delijn/manifest.json b/homeassistant/components/delijn/manifest.json index 81307c47bbad7c..d25dab4234ec0c 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 eed194640dd7c5..9242e3e2d5ed2e 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 @@ class DelugeSensorEntityDescription(SensorEntityDescription): ), 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 f11e1a2bd3e825..e0266d004e2b8b 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 f9e89543d26b16..483b02844d6d47 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 85f7b89483f3b3..246c952e219417 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -14,6 +14,7 @@ ) import homeassistant.core as ha from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType @@ -28,6 +29,7 @@ Platform.CLIMATE, Platform.COVER, Platform.DATE, + Platform.DATETIME, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, @@ -48,7 +50,6 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.TTS, - Platform.STT, Platform.MAILBOX, Platform.NOTIFY, Platform.IMAGE_PROCESSING, @@ -56,12 +57,11 @@ Platform.DEVICE_TRACKER, ] +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( @@ -69,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)) @@ -188,6 +191,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + await hass.config_entries.async_unload_platforms( + config_entry, COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM + ) + return True + + async def finish_setup(hass: HomeAssistant, config: ConfigType) -> None: """Finish set up once demo platforms are set up.""" switches: list[str] | None = None diff --git a/homeassistant/components/demo/binary_sensor.py b/homeassistant/components/demo/binary_sensor.py index ee718e85cc0fb0..236d4bbb1b0b8c 100644 --- a/homeassistant/components/demo/binary_sensor.py +++ b/homeassistant/components/demo/binary_sensor.py @@ -9,18 +9,16 @@ 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 626403009ce03d..f7a653e17797a2 100644 --- a/homeassistant/components/demo/button.py +++ b/homeassistant/components/demo/button.py @@ -4,59 +4,48 @@ 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( +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 Button entity.""" + """Set up the demo button platform.""" async_add_entities( [ DemoButton( unique_id="push", - name="Push", + device_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) - - 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 ae546361d8f1a6..73b45a556404f7 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 @@ async def async_get_events( 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 b55fb4ba0e9bd0..722693280a0222 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -7,16 +7,14 @@ 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( +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 camera platform.""" + """Set up the Demo config entry.""" async_add_entities( [ DemoCamera("Demo camera", "image/jpg"), @@ -25,15 +23,6 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoCamera(Camera): """The representation of a Demo camera.""" diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index 7c0a4a5c9c8507..340a4b306cba49 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -14,27 +14,24 @@ 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 @@ def __init__( ) -> 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 @@ def __init__( 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 6f443329661870..42e30aa83360d6 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -16,18 +16,16 @@ 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 @@ def __init__( """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 @@ def __init__( 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 eb96bc49038279..4129d0d392a81c 100644 --- a/homeassistant/components/demo/date.py +++ b/homeassistant/components/demo/date.py @@ -5,22 +5,19 @@ 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 @@ def __init__( """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 new file mode 100644 index 00000000000000..b769f9baba3102 --- /dev/null +++ b/homeassistant/components/demo/datetime.py @@ -0,0 +1,66 @@ +"""Demo platform that offers a fake date/time entity.""" +from __future__ import annotations + +from datetime import datetime, timezone + +from homeassistant.components.datetime import DateTimeEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the demo datetime platform.""" + async_add_entities( + [ + DemoDateTime( + "datetime", + "Date and Time", + datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "mdi:calendar-clock", + False, + ), + ] + ) + + +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, + device_name: str, + state: datetime, + icon: str, + assumed_state: bool, + ) -> None: + """Initialize the Demo date/time entity.""" + self._attr_assumed_state = assumed_state + self._attr_icon = icon + self._attr_native_value = state + self._attr_unique_id = unique_id + + self._attr_device_info = DeviceInfo( + identifiers={ + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, unique_id) + }, + name=device_name, + ) + + async def async_set_value(self, value: datetime) -> None: + """Update the date/time.""" + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/demo/humidifier.py b/homeassistant/components/demo/humidifier.py index 772726ac1d5d9b..2e16a04e171e1c 100644 --- a/homeassistant/components/demo/humidifier.py +++ b/homeassistant/components/demo/humidifier.py @@ -4,6 +4,7 @@ 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 @@ def __init__( 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 2e5291b8a133b5..fbc35965dc409f 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -20,7 +20,6 @@ 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_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 @@ def __init__( 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 @@ def __init__( 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 6f0b23525e5969..8aa3e1ef384740 100644 --- a/homeassistant/components/demo/mailbox.py +++ b/homeassistant/components/demo/mailbox.py @@ -9,7 +9,7 @@ from homeassistant.components.mailbox import CONTENT_TYPE_MPEG, Mailbox, StreamError from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -34,7 +34,9 @@ def __init__(self, hass: HomeAssistant, name: str) -> None: self._messages: dict[str, dict[str, Any]] = {} txt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " for idx in range(0, 10): - msgtime = int(dt.as_timestamp(dt.utcnow()) - 3600 * 24 * (10 - idx)) + msgtime = int( + dt_util.as_timestamp(dt_util.utcnow()) - 3600 * 24 * (10 - idx) + ) msgtxt = f"Message {idx + 1}. {txt * (1 + idx * (idx % 2))}" msgsha = sha1(msgtxt.encode("utf-8")).hexdigest() msg = { @@ -76,7 +78,7 @@ async def async_get_messages(self) -> list[dict[str, Any]]: """Return a list of the current messages.""" return sorted( self._messages.values(), - key=lambda item: item["info"]["origtime"], # type: ignore[no-any-return] + key=lambda item: item["info"]["origtime"], reverse=True, ) diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index 25ed7347bdac35..719b1078b8c79d 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -3,22 +3,20 @@ 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 @@ def __init__( 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 @@ def __init__( # 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 e30d65c9f0e468..6349b10040cd05 100644 --- a/homeassistant/components/demo/select.py +++ b/homeassistant/components/demo/select.py @@ -3,27 +3,24 @@ 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 @@ def __init__( ) -> 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 84758f0c294d4b..26689582faef03 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -25,18 +25,17 @@ 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 @@ def __init__( ) -> 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 @@ def __init__( 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 @@ def __init__( """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 @@ def __init__( self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, - name=name, + name=device_name, ) if battery: diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index add04c236e76f6..3794b27cc0ea01 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -1,46 +1,5 @@ { "title": "Demo", - "issues": { - "bad_psu": { - "title": "The power supply is not stable", - "fix_flow": { - "step": { - "confirm": { - "title": "The power supply needs to be replaced", - "description": "Press SUBMIT to confirm the power supply has been replaced" - } - } - } - }, - "out_of_blinker_fluid": { - "title": "The blinker fluid is empty and needs to be refilled", - "fix_flow": { - "step": { - "confirm": { - "title": "Blinker fluid needs to be refilled", - "description": "Press SUBMIT when blinker fluid has been refilled" - } - } - } - }, - "cold_tea": { - "title": "The tea is cold", - "fix_flow": { - "step": {}, - "abort": { - "not_tea_time": "Can not re-heat the tea at this time" - } - } - }, - "transmogrifier_deprecated": { - "title": "The transmogrifier component is deprecated", - "description": "The transmogrifier component is now deprecated due to the lack of local control available in the new API" - }, - "unfixable_problem": { - "title": "This is not a fixable problem", - "description": "This issue is never going to give up." - } - }, "options": { "step": { "init": { @@ -81,7 +40,7 @@ "2": "2", "3": "3", "auto": "Auto", - "off": "Off" + "off": "[%key:common::state::off%]" } } } diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py index 07a844c048cc52..8cbc287b71dbb3 100644 --- a/homeassistant/components/demo/stt.py +++ b/homeassistant/components/demo/stt.py @@ -1,4 +1,4 @@ -"""Support for the demo for speech to text service.""" +"""Support for the demo for speech-to-text service.""" from __future__ import annotations from collections.abc import AsyncIterable @@ -9,7 +9,6 @@ AudioCodecs, AudioFormats, AudioSampleRates, - Provider, SpeechMetadata, SpeechResult, SpeechResultState, @@ -18,20 +17,10 @@ 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 @@ async def async_process_audio_stream( 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 2ad400ff3f7ac5..49e06839be5d10 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -5,22 +5,19 @@ 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 @@ def __init__( 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 efce1af5c37cdd..7c243b73ea527e 100644 --- a/homeassistant/components/demo/text.py +++ b/homeassistant/components/demo/text.py @@ -3,40 +3,37 @@ 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 @@ def __init__( ) -> 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 @@ def __init__( 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 aafd425a024f2d..0384c0822f47f7 100644 --- a/homeassistant/components/demo/time.py +++ b/homeassistant/components/demo/time.py @@ -5,43 +5,33 @@ 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 @@ def __init__( """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/tts.py b/homeassistant/components/demo/tts.py index 2c9cd654d8429a..dfc8d7d7efb1be 100644 --- a/homeassistant/components/demo/tts.py +++ b/homeassistant/components/demo/tts.py @@ -1,4 +1,4 @@ -"""Support for the demo for text to speech service.""" +"""Support for the demo for text-to-speech service.""" from __future__ import annotations import os @@ -57,7 +57,7 @@ def supported_options(self) -> list[str]: return ["voice", "age"] def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load TTS from demo.""" filename = os.path.join(os.path.dirname(__file__), "tts.mp3") diff --git a/homeassistant/components/demo/update.py b/homeassistant/components/demo/update.py index 15e67ffa0a87d8..6373c4850377aa 100644 --- a/homeassistant/components/demo/update.py +++ b/homeassistant/components/demo/update.py @@ -10,29 +10,26 @@ 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 @@ def __init__( 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 e1cc278137cfcd..af04da27406c28 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -19,7 +19,12 @@ 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 @@ def __init__( 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 d5017ac2329e81..af2fd61081cb80 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -19,6 +19,7 @@ ATTR_ENTITY_ID, CONF_DEVICE_ID, CONF_DOMAIN, + CONF_ENTITY_ID, CONF_PLATFORM, ) from homeassistant.core import HomeAssistant, callback @@ -40,7 +41,7 @@ CONF_TURNED_OFF, CONF_TURNED_ON, ) -from .exceptions import DeviceNotFound, InvalidDeviceAutomationConfig +from .exceptions import DeviceNotFound, EntityNotFound, InvalidDeviceAutomationConfig if TYPE_CHECKING: from .action import DeviceAutomationActionProtocol @@ -57,6 +58,8 @@ DOMAIN = "device_automation" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + DEVICE_TRIGGER_BASE_SCHEMA: vol.Schema = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "device", @@ -311,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() @@ -326,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 f38daf2dae6eab..87ff5a2cb5203e 100644 --- a/homeassistant/components/device_automation/entity.py +++ b/homeassistant/components/device_automation/entity.py @@ -23,7 +23,7 @@ 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 ad92696cb945df..0b2f2c01be7651 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 5f844c36aa573d..038ded07e8aef0 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -5,7 +5,7 @@ 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 @@ 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 e5061cb691e8c7..189fc750e50a5b 100644 --- a/homeassistant/components/device_automation/toggle_entity.py +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -78,14 +78,14 @@ 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 @@ _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 5d56548f0ecb41..286929c534588d 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -176,7 +176,9 @@ def handle_device_event(ev: Event) -> None: # Enable entity ent_reg.async_update_entity(entity_id, disabled_by=None) - hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event) + hass.bus.async_listen( + dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event, run_immediately=True + ) class BaseTrackerEntity(Entity): @@ -371,7 +373,6 @@ async def async_internal_added_to_hass(self) -> None: # 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 96ee70baca8252..b5bf850b4fad7f 100644 --- a/homeassistant/components/device_tracker/device_condition.py +++ b/homeassistant/components/device_tracker/device_condition.py @@ -27,7 +27,7 @@ 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 150b58722754c9..a96f9affb1dd32 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -27,7 +27,7 @@ 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 e27ff57f03f284..b428018cd9ec36 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -726,6 +726,10 @@ async def async_init_single_device(dev: Device) -> None: 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/device_tracker/services.yaml b/homeassistant/components/device_tracker/services.yaml index c6c2d212e2d04e..22d89b42253c68 100644 --- a/homeassistant/components/device_tracker/services.yaml +++ b/homeassistant/components/device_tracker/services.yaml @@ -30,7 +30,7 @@ see: text: gps: name: GPS coordinates - description: GPS coordinates where device is located (latitude, longitude). + description: GPS coordinates where device is located, specified by latitude and longitude. example: "[51.509802, -0.086692]" selector: object: 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 eafd1e63b1fef4..d2608ed43c7a02 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 @@ 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 4ccd5a00ab2bcc..93a66e345ecb2b 100644 --- a/homeassistant/components/devolo_home_control/light.py +++ b/homeassistant/components/devolo_home_control/light.py @@ -71,13 +71,12 @@ def turn_on(self, **kwargs: Any) -> None: 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/manifest.json b/homeassistant/components/devolo_home_control/manifest.json index 60f99daffdc531..71ca03f9638b90 100644 --- a/homeassistant/components/devolo_home_control/manifest.json +++ b/homeassistant/components/devolo_home_control/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["devolo_home_control_api"], - "quality_scale": "silver", + "quality_scale": "gold", "requirements": ["devolo-home-control-api==0.18.2"], "zeroconf": ["_dvl-deviceapi._tcp.local."] } diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py index 24b1d3545deafd..9b96e58da60062 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 bf53022b43bf0e..b9958dc7309989 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 @@ def native_value(self): 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/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 7f41d2c1d3d36b..4a9f6c2b163dab 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -42,7 +42,7 @@ ) from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.data_entry_flow import BaseServiceInfo -from homeassistant.helpers import discovery_flow +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceRegistry, @@ -59,10 +59,14 @@ from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.network import is_invalid, is_link_local, is_loopback +from .const import DOMAIN + if TYPE_CHECKING: from scapy.packet import Packet from scapy.sendrecv import AsyncSniffer +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + FILTER = "udp and (port 67 or 68)" REQUESTED_ADDR = "requested_addr" MESSAGE_TYPE = "message-type" diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index ea7b13f471913d..2ff220b90966a7 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -14,7 +14,7 @@ from homeassistant.components import http, websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import integration_platform +from homeassistant.helpers import config_validation as cv, integration_platform from homeassistant.helpers.device_registry import DeviceEntry, async_get from homeassistant.helpers.json import ( ExtendedJSONEncoder, @@ -33,7 +33,10 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +@dataclass(slots=True) class DiagnosticsPlatformData: """Diagnostic platform data.""" @@ -46,7 +49,7 @@ class DiagnosticsPlatformData: ] | None -@dataclass +@dataclass(slots=True) class DiagnosticsData: """Diagnostic data.""" diff --git a/homeassistant/components/dialogflow/config_flow.py b/homeassistant/components/dialogflow/config_flow.py index fee99898ccc179..7e62869c3fa702 100644 --- a/homeassistant/components/dialogflow/config_flow.py +++ b/homeassistant/components/dialogflow/config_flow.py @@ -7,7 +7,7 @@ DOMAIN, "Dialogflow Webhook", { - "dialogflow_url": "https://dialogflow.com/docs/fulfillment#webhook", + "dialogflow_url": "https://cloud.google.com/dialogflow/es/docs/fulfillment-webhook", "docs_url": "https://www.home-assistant.io/integrations/dialogflow/", }, ) diff --git a/homeassistant/components/directv/entity.py b/homeassistant/components/directv/entity.py index 08b24a50a755d1..9d1fd68b742c0a 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 @@ def device_info(self) -> DeviceInfo: 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/directv/media_player.py b/homeassistant/components/directv/media_player.py index 21b25962fce145..8c1570db159611 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -112,7 +112,8 @@ async def async_update(self) -> None: self._paused = self._last_position == self._program.position self._is_recorded = self._program.recorded self._last_position = self._program.position - self._last_update = state.at + if not self._paused: + self._last_update = dt_util.utcnow() self._attr_assumed_state = self._is_recorded @property diff --git a/homeassistant/components/discogs/manifest.json b/homeassistant/components/discogs/manifest.json index 2b405341841e55..fceb214aded2a2 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 a52c079ac8ea15..329709e88d28bc 100644 --- a/homeassistant/components/discord/__init__.py +++ b/homeassistant/components/discord/__init__.py @@ -6,13 +6,15 @@ 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 00000000000000..54f6fca83d4698 --- /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 00000000000000..d6b81ed88375e8 --- /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 00000000000000..866e9f11def260 --- /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 00000000000000..e3b6e91e03f50e --- /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 00000000000000..02d5585c1dcfe3 --- /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 00000000000000..c929386e8e85ca --- /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 00000000000000..3f4069752f2ed0 --- /dev/null +++ b/homeassistant/components/discovergy/sensor.py @@ -0,0 +1,210 @@ +"""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 ( + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +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 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 = DeviceInfo( + identifiers={(DOMAIN, meter.get_meter_id())}, + name=f"{meter.measurement_type.capitalize()} {meter.location.street} {meter.location.street_number}", + model=f"{meter.type} {meter.full_serial_number}", + 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 00000000000000..e8dbbab202162e --- /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 00000000000000..2baeb0e5f6e3d7 --- /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/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 53b2478490da75..79653e1c9bc16c 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -60,7 +60,6 @@ class ServiceDetails(NamedTuple): SERVICE_HANDLERS = { SERVICE_ENIGMA2: ServiceDetails("media_player", "enigma2"), "yamaha": ServiceDetails("media_player", "yamaha"), - "openhome": ServiceDetails("media_player", "openhome"), "bluesound": ServiceDetails("media_player", "bluesound"), } @@ -87,6 +86,7 @@ class ServiceDetails(NamedTuple): SERVICE_MOBILE_APP, SERVICE_NETGEAR, SERVICE_OCTOPRINT, + "openhome", "philips_hue", SERVICE_SAMSUNG_PRINTER, "sonos", diff --git a/homeassistant/components/dlib_face_detect/manifest.json b/homeassistant/components/dlib_face_detect/manifest.json index 33811d5821cc1d..e395a84f2061df 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 34cc7344cd9a93..60c0ef3c766927 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/dlink/strings.json b/homeassistant/components/dlink/strings.json index 9ac7453093cb95..ee7abb3e97907d 100644 --- a/homeassistant/components/dlink/strings.json +++ b/homeassistant/components/dlink/strings.json @@ -24,11 +24,5 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The D-Link Smart Plug YAML configuration is being removed", - "description": "Configuring D-Link Smart Plug using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the D-Link Power Plug YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 2fd1a85ebae208..8fc55830c63425 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -16,6 +16,7 @@ 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 @@ def _make_identifier(self, action: Action, object_id: str) -> str: """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 8240bb117ea3b4..c94fff1124e6e5 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -350,14 +350,13 @@ def process_image(self, image): 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 @@ def process_image(self, image): 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 79c114e2f38964..52c89f3f34b675 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 d6eba115bb88d5..2bb981ab06fefd 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 e21e35da1e53ea..6cfbdd50b34a16 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 8234b41c43a4cf..39915563b036ba 100644 --- a/homeassistant/components/dormakaba_dkey/sensor.py +++ b/homeassistant/components/dormakaba_dkey/sensor.py @@ -22,7 +22,6 @@ 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 efe9d3acb52c4b..15bcf3f9ddc633 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 00000000000000..5f9f10dc9c1294 --- /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 00000000000000..3a92bfe5510be4 --- /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 00000000000000..2d328b30ceaed9 --- /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 00000000000000..7468400ec35bce --- /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 00000000000000..6fa4d2e0a5be6e --- /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 00000000000000..cccdeb937cb4c1 --- /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 00000000000000..81e0053fd77101 --- /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 00000000000000..392869a138bd35 --- /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 00000000000000..12d4e4003c4df8 --- /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 00000000000000..660e7a90487b8c --- /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 00000000000000..0016b8f2bca144 --- /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 2ba7ce558355e7..3fc81d2f8e7474 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 524f5c4ffc2a3e..e6d1d035e3b6e8 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -459,9 +459,9 @@ async def connect_and_reconnect() -> None: @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/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py index 99c3a110caa8ec..275d47d15ca0af 100644 --- a/homeassistant/components/dwd_weather_warnings/__init__.py +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -8,18 +8,20 @@ from homeassistant.core import HomeAssistant from .const import CONF_REGION_IDENTIFIER, DOMAIN, PLATFORMS +from .coordinator import DwdWeatherWarningsCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" region_identifier: str = entry.data[CONF_REGION_IDENTIFIER] - # Initialize the API. + # Initialize the API and coordinator. api = await hass.async_add_executor_job(DwdWeatherWarningsAPI, region_identifier) + coordinator = DwdWeatherWarningsCoordinator(hass, api) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = api + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/dwd_weather_warnings/config_flow.py b/homeassistant/components/dwd_weather_warnings/config_flow.py index 653812632c0da0..e806db7ec91e19 100644 --- a/homeassistant/components/dwd_weather_warnings/config_flow.py +++ b/homeassistant/components/dwd_weather_warnings/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Final +from typing import Any from dwdwfsapi import DwdWeatherWarningsAPI import voluptuous as vol @@ -12,19 +12,7 @@ from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from .const import ( - CONF_REGION_IDENTIFIER, - CONF_REGION_NAME, - DEFAULT_NAME, - DOMAIN, - LOGGER, -) - -CONFIG_SCHEMA: Final = vol.Schema( - { - vol.Required(CONF_REGION_IDENTIFIER): cv.string, - } -) +from .const import CONF_REGION_IDENTIFIER, CONF_REGION_NAME, DOMAIN, LOGGER class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN): @@ -52,13 +40,16 @@ async def async_step_user( await self.async_set_unique_id(region_identifier) self._abort_if_unique_id_configured() - # Set the name for this config entry. - name = f"{DEFAULT_NAME} {region_identifier}" - - return self.async_create_entry(title=name, data=user_input) + return self.async_create_entry(title=region_identifier, data=user_input) return self.async_show_form( - step_id="user", errors=errors, data_schema=CONFIG_SCHEMA + step_id="user", + errors=errors, + data_schema=vol.Schema( + { + vol.Required(CONF_REGION_IDENTIFIER): cv.string, + } + ), ) async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: @@ -67,12 +58,12 @@ async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: "Starting import of sensor from configuration.yaml - %s", import_config ) - # Adjust data to new format. - region_identifier = import_config.pop(CONF_REGION_NAME) - import_config[CONF_REGION_IDENTIFIER] = region_identifier + # Extract the necessary data for the setup. + region_identifier = import_config[CONF_REGION_NAME] + name = import_config.get(CONF_NAME, region_identifier) # Set the unique ID for this imported entry. - await self.async_set_unique_id(import_config[CONF_REGION_IDENTIFIER]) + await self.async_set_unique_id(region_identifier) self._abort_if_unique_id_configured() # Validate region identifier using the API @@ -81,8 +72,6 @@ async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: ): return self.async_abort(reason="invalid_identifier") - name = import_config.get( - CONF_NAME, f"{DEFAULT_NAME} {import_config[CONF_REGION_IDENTIFIER]}" + return self.async_create_entry( + title=name, data={CONF_REGION_IDENTIFIER: region_identifier} ) - - return self.async_create_entry(title=name, data=import_config) diff --git a/homeassistant/components/dwd_weather_warnings/coordinator.py b/homeassistant/components/dwd_weather_warnings/coordinator.py new file mode 100644 index 00000000000000..a12326971306f7 --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/coordinator.py @@ -0,0 +1,26 @@ +"""Data coordinator for the dwd_weather_warnings integration.""" + +from __future__ import annotations + +from dwdwfsapi import DwdWeatherWarningsAPI + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER + + +class DwdWeatherWarningsCoordinator(DataUpdateCoordinator[None]): + """Custom coordinator for the dwd_weather_warnings integration.""" + + def __init__(self, hass: HomeAssistant, api: DwdWeatherWarningsAPI) -> None: + """Initialize the dwd_weather_warnings coordinator.""" + super().__init__( + hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + ) + + self.api = api + + async def _async_update_data(self) -> None: + """Get the latest data from the DWD Weather Warnings API.""" + await self.hass.async_add_executor_job(self.api.update) diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index de96b3e9e0866d..f44d736b426cf7 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -27,7 +27,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ADVANCE_WARNING_SENSOR, @@ -47,10 +47,10 @@ ATTR_WARNING_COUNT, CONF_REGION_NAME, CURRENT_WARNING_SENSOR, - DEFAULT_SCAN_INTERVAL, + DEFAULT_NAME, DOMAIN, - LOGGER, ) +from .coordinator import DwdWeatherWarningsCoordinator SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -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", @@ -108,56 +108,60 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up entities from config entry.""" - api = WrappedDwDWWAPI(hass.data[DOMAIN][entry.entry_id]) + coordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ - DwdWeatherWarningsSensor(api, entry.title, entry.unique_id, description) + DwdWeatherWarningsSensor(coordinator, entry, description) for description in SENSOR_TYPES ], True, ) -class DwdWeatherWarningsSensor(SensorEntity): +class DwdWeatherWarningsSensor( + CoordinatorEntity[DwdWeatherWarningsCoordinator], SensorEntity +): """Representation of a DWD-Weather-Warnings sensor.""" _attr_attribution = "Data provided by DWD" def __init__( self, - api, - name, - unique_id, + coordinator: DwdWeatherWarningsCoordinator, + entry: ConfigEntry, description: SensorEntityDescription, ) -> None: """Initialize a DWD-Weather-Warnings sensor.""" - self._api = api + super().__init__(coordinator) + self.entity_description = description - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = f"{unique_id}-{description.key}" + self._attr_name = f"{DEFAULT_NAME} {entry.title} {description.name}" + self._attr_unique_id = f"{entry.unique_id}-{description.key}" + + self.api = coordinator.api @property def native_value(self): """Return the state of the sensor.""" if self.entity_description.key == CURRENT_WARNING_SENSOR: - return self._api.api.current_warning_level + return self.api.current_warning_level - return self._api.api.expected_warning_level + return self.api.expected_warning_level @property def extra_state_attributes(self): """Return the state attributes of the sensor.""" data = { - ATTR_REGION_NAME: self._api.api.warncell_name, - ATTR_REGION_ID: self._api.api.warncell_id, - ATTR_LAST_UPDATE: self._api.api.last_update, + ATTR_REGION_NAME: self.api.warncell_name, + ATTR_REGION_ID: self.api.warncell_id, + ATTR_LAST_UPDATE: self.api.last_update, } if self.entity_description.key == CURRENT_WARNING_SENSOR: - searched_warnings = self._api.api.current_warnings + searched_warnings = self.api.current_warnings else: - searched_warnings = self._api.api.expected_warnings + searched_warnings = self.api.expected_warnings data[ATTR_WARNING_COUNT] = len(searched_warnings) @@ -173,7 +177,7 @@ def extra_state_attributes(self): data[f"warning_{i}_parameters"] = warning[API_ATTR_WARNING_PARAMETERS] data[f"warning_{i}_color"] = warning[API_ATTR_WARNING_COLOR] - # Dictionary for the attribute containing the complete warning + # Dictionary for the attribute containing the complete warning. warning_copy = warning.copy() warning_copy[API_ATTR_WARNING_START] = data[f"warning_{i}_start"] warning_copy[API_ATTR_WARNING_END] = data[f"warning_{i}_end"] @@ -184,28 +188,4 @@ def extra_state_attributes(self): @property def available(self) -> bool: """Could the device be accessed during the last update call.""" - return self._api.api.data_valid - - def update(self) -> None: - """Get the latest data from the DWD-Weather-Warnings API.""" - LOGGER.debug( - "Update requested for %s (%s) by %s", - self._api.api.warncell_name, - self._api.api.warncell_id, - self.entity_description.key, - ) - self._api.update() - - -class WrappedDwDWWAPI: - """Wrapper for the DWD-Weather-Warnings api.""" - - def __init__(self, api): - """Initialize a DWD-Weather-Warnings wrapper.""" - self.api = api - - @Throttle(DEFAULT_SCAN_INTERVAL) - def update(self): - """Get the latest data from the DWD-Weather-Warnings API.""" - self.api.update() - LOGGER.debug("Update performed") + return self.api.data_valid diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json index a3dd890cc11f93..8fd138dc49be2f 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 2650aa35489136..ce3ee2bfbecc7e 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -100,6 +100,7 @@ def __init__(self, coordinator, key): """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 @@ def name(self): """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/coordinator.py b/homeassistant/components/easyenergy/coordinator.py index 3d92f63b752eb5..3996fd4d16ab0a 100644 --- a/homeassistant/components/easyenergy/coordinator.py +++ b/homeassistant/components/easyenergy/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR @@ -47,7 +47,7 @@ def __init__(self, hass: HomeAssistant) -> None: async def _async_update_data(self) -> EasyEnergyData: """Fetch data from easyEnergy.""" - today = dt.now().date() + today = dt_util.now().date() gas_today = None energy_tomorrow = None @@ -62,7 +62,7 @@ async def _async_update_data(self) -> EasyEnergyData: except EasyEnergyNoDataError: LOGGER.debug("No data for gas prices for easyEnergy integration") # Energy for tomorrow only after 14:00 UTC - if dt.utcnow().hour >= THRESHOLD_HOUR: + if dt_util.utcnow().hour >= THRESHOLD_HOUR: tomorrow = today + timedelta(days=1) try: energy_tomorrow = await self.easyenergy.energy_prices( diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index 9cf5944dfaa1b2..a64851f669614b 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 ed89e0068d4d61..93fb264b01d4c3 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 2266d70e0ad820..e65dc221a9fb1c 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 @@ def is_on(self): """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 7925832953baa7..8c0b77b913dd23 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 @@ def __init__( 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 @@ def supported_features(self) -> ClimateEntityFeature: 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 @@ def device_info(self) -> DeviceInfo: 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 f4c4dad6527a3f..fb5533adf07a22 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 @@ def device_info(self) -> DeviceInfo: 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 15ad17b0e39909..67c975010ab9af 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 @@ class EcobeeNumberEntityDescription( ), 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 @@ def __init__( """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 90d4ba4202e258..3996ec6fd3503c 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 @@ class EcobeeSensorEntityDescription( ), EcobeeSensorEntityDescription( key="humidity", - name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, @@ -58,7 +56,6 @@ class 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 @@ class 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 @@ class 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 @@ def __init__( 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 19f379de7d953a..647ea55e3115fd 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 5610cdb2a9c1dd..d38bc82c6f2d04 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 @@ def __init__(self, data, name, index): 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 @@ def get_forecast(self, index, param): 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 @@ def device_info(self) -> DeviceInfo: 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 6fa54fc70fb92b..afba9ba68374fe 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -19,7 +19,6 @@ 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 @@ 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 cf950a3c38c48b..7233d135f2e1cd 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -151,7 +151,7 @@ def hvac_modes(self): 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/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index f1bf7deb502686..ba922a30b84fa6 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -6,7 +6,15 @@ import sucks -from homeassistant.components.vacuum import VacuumEntity, VacuumEntityFeature +from homeassistant.components.vacuum import ( + STATE_CLEANING, + STATE_DOCKED, + STATE_ERROR, + STATE_IDLE, + STATE_RETURNING, + StateVacuumEntity, + VacuumEntityFeature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level @@ -34,7 +42,7 @@ def setup_platform( add_entities(vacuums, True) -class EcovacsVacuum(VacuumEntity): +class EcovacsVacuum(StateVacuumEntity): """Ecovacs Vacuums such as Deebot.""" _attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH] @@ -44,10 +52,9 @@ class EcovacsVacuum(VacuumEntity): | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.CLEAN_SPOT | VacuumEntityFeature.STOP - | VacuumEntityFeature.TURN_OFF - | VacuumEntityFeature.TURN_ON + | VacuumEntityFeature.START | VacuumEntityFeature.LOCATE - | VacuumEntityFeature.STATUS + | VacuumEntityFeature.STATE | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.FAN_SPEED ) @@ -56,14 +63,13 @@ def __init__(self, device: sucks.VacBot) -> None: """Initialize the Ecovacs Vacuum.""" self.device = device self.device.connect_and_wait_until_ready() - if self.device.vacuum.get("nick") is not None: - self._attr_name = str(self.device.vacuum["nick"]) - else: - # In case there is no nickname defined, use the device id - self._attr_name = str(format(self.device.vacuum["did"])) + vacuum = self.device.vacuum + + self.error = None + self._attr_unique_id = vacuum["did"] + self._attr_name = vacuum.get("nick", vacuum["did"]) - self._error = None - _LOGGER.debug("Vacuum initialized: %s", self.name) + _LOGGER.debug("StateVacuum initialized: %s", self.name) async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" @@ -79,9 +85,9 @@ def on_error(self, error): to change, that will come through as a separate on_status event """ if error == "no_error": - self._error = None + self.error = None else: - self._error = error + self.error = error self.hass.bus.fire( "ecovacs_error", {"entity_id": self.entity_id, "error": error} @@ -89,36 +95,24 @@ def on_error(self, error): self.schedule_update_ha_state() @property - def unique_id(self) -> str: - """Return an unique ID.""" - return self.device.vacuum.get("did") - - @property - def is_on(self) -> bool: - """Return true if vacuum is currently cleaning.""" - return self.device.is_cleaning + def state(self) -> str | None: + """Return the state of the vacuum cleaner.""" + if self.error is not None: + return STATE_ERROR - @property - def is_charging(self) -> bool: - """Return true if vacuum is currently charging.""" - return self.device.is_charging + if self.device.is_cleaning: + return STATE_CLEANING - @property - def status(self) -> str | None: - """Return the status of the vacuum cleaner.""" - return self.device.vacuum_status + if self.device.is_charging: + return STATE_DOCKED - def return_to_base(self, **kwargs: Any) -> None: - """Set the vacuum cleaner to return to the dock.""" + if self.device.vacuum_status == sucks.CLEAN_MODE_STOP: + return STATE_IDLE - self.device.run(sucks.Charge()) + if self.device.vacuum_status == sucks.CHARGE_MODE_RETURNING: + return STATE_RETURNING - @property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner.""" - return icon_for_battery_level( - battery_level=self.battery_level, charging=self.is_charging - ) + return None @property def battery_level(self) -> int | None: @@ -126,22 +120,42 @@ def battery_level(self) -> int | None: if self.device.battery_status is not None: return self.device.battery_status * 100 - return super().battery_level + return None + + @property + def battery_icon(self) -> str: + """Return the battery icon for the vacuum cleaner.""" + return icon_for_battery_level( + battery_level=self.battery_level, charging=self.device.is_charging + ) @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" return self.device.fan_speed - def turn_on(self, **kwargs: Any) -> None: + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the device-specific state attributes of this vacuum.""" + data: dict[str, Any] = {} + data[ATTR_ERROR] = self.error + + for key, val in self.device.components.items(): + attr_name = ATTR_COMPONENT_PREFIX + key + data[attr_name] = int(val * 100) + + return data + + def return_to_base(self, **kwargs: Any) -> None: + """Set the vacuum cleaner to return to the dock.""" + + self.device.run(sucks.Charge()) + + def start(self, **kwargs: Any) -> None: """Turn the vacuum on and start cleaning.""" self.device.run(sucks.Clean()) - def turn_off(self, **kwargs: Any) -> None: - """Turn the vacuum off stopping the cleaning and returning home.""" - self.return_to_base() - def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" @@ -159,7 +173,7 @@ def locate(self, **kwargs: Any) -> None: def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" - if self.is_on: + if self.state == STATE_CLEANING: self.device.run(sucks.Clean(mode=self.device.clean_status, speed=fan_speed)) def send_command( @@ -170,15 +184,3 @@ def send_command( ) -> None: """Send a command to a vacuum cleaner.""" self.device.run(sucks.VacBotCommand(command, params)) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the device-specific state attributes of this vacuum.""" - data: dict[str, Any] = {} - data[ATTR_ERROR] = self._error - - for key, val in self.device.components.items(): - attr_name = ATTR_COMPONENT_PREFIX + key - data[attr_name] = int(val * 100) - - return data diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 5bbe22b697232e..8d5411e9e2e6ce 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -125,7 +125,7 @@ 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 @@ 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 @@ def _new_sensor(sensor: EcoWittSensor) -> None: 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 dba5d35ab1a700..b15a88d099f1ed 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/config_flow.py b/homeassistant/components/edl21/config_flow.py index b66a988958b9ba..0bedcc515efd45 100644 --- a/homeassistant/components/edl21/config_flow.py +++ b/homeassistant/components/edl21/config_flow.py @@ -1,10 +1,8 @@ """Config flow for EDL21 integration.""" -from typing import Any import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME from homeassistant.data_entry_flow import FlowResult from .const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN @@ -21,17 +19,6 @@ class EDL21ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - - self._async_abort_entries_match( - {CONF_SERIAL_PORT: import_config[CONF_SERIAL_PORT]} - ) - return self.async_create_entry( - title=import_config[CONF_NAME] or DEFAULT_TITLE, - data=import_config, - ) - async def async_step_user( self, user_input: dict[str, str] | None = None ) -> FlowResult: diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index 5fdda4634591e6..faa471e44b193b 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["sml"], - "requirements": ["pysml==0.0.11"] + "requirements": ["pysml==0.0.12"] } diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index e526f951f1c6b5..3ce42198fbdcd0 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -7,18 +7,15 @@ from sml import SmlGetListResponse from sml.asyncio import SmlProtocol -import voluptuous as vol from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_NAME, DEGREE, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -27,15 +24,12 @@ UnitOfPower, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utcnow from .const import ( @@ -48,13 +42,6 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_SERIAL_PORT): cv.string, - vol.Optional(CONF_NAME, default=""): cv.string, - }, -) - # OBIS format: A-B:C.D.E*F SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # A=1: Electricity @@ -63,41 +50,47 @@ # 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, ), @@ -105,28 +98,28 @@ # 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, ), @@ -134,14 +127,16 @@ # 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, ), @@ -150,7 +145,7 @@ # 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, ), @@ -159,7 +154,7 @@ # 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, ), @@ -168,7 +163,7 @@ # 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, ), @@ -177,7 +172,7 @@ # 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, ), @@ -186,7 +181,7 @@ # 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, ), @@ -195,7 +190,7 @@ # 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, ), @@ -204,7 +199,7 @@ # 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, ), @@ -213,7 +208,7 @@ # 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, ), @@ -222,7 +217,7 @@ # 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, ), @@ -231,7 +226,7 @@ # 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, ), @@ -243,26 +238,40 @@ # 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", ), ) @@ -279,31 +288,6 @@ } -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up EDL21 sensors via configuration.yaml and show deprecation warning.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -341,12 +325,11 @@ def __init__( 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], ) @@ -357,16 +340,14 @@ async def connect(self) -> None: 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 = None - for telegram in message_body.get("valList", []): - if telegram.get("objName") in ("1-0:0.0.9*255", "1-0:96.1.0*255"): - electricity_id = telegram.get("value") - break + electricity_id = message_body["serverId"] if electricity_id is None: - LOGGER.debug("No electricity id found in sml message for %s", self._name) + LOGGER.debug( + "No electricity id found in sml message on %s", self._serial_port + ) return electricity_id = electricity_id.replace(" ", "") @@ -381,15 +362,11 @@ def event(self, message_body) -> None: ) 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, ) @@ -413,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 @@ -425,7 +402,7 @@ def __init__(self, electricity_id, obis, device_name, entity_description, telegr 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 284e8229c59b54..4397864294358b 100644 --- a/homeassistant/components/edl21/strings.json +++ b/homeassistant/components/edl21/strings.json @@ -12,10 +12,98 @@ } } }, - "issues": { - "deprecated_yaml": { - "title": "EDL21 YAML configuration is being removed", - "description": "Configuring EDL21 using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the EDL21 YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + "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 1f544a7a97b151..6fc6eed40f6906 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -25,14 +25,14 @@ 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 @@ ), 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 @@ ), 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 @@ ), 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 924d5a56bcf8a4..3b17bf07f1ae4d 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 b95e24823d65b9..71e01f75d46754 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 361f906133d46d..59523d5a4cbd08 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 @@ def __init__(self, device: ElectraAirConditioner, api: ElectraAPI) -> None: 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, ) @@ -250,7 +253,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: raise ValueError("No target temperature provided") - self._electra_ac_device.set_temperature(temperature) + self._electra_ac_device.set_temperature(int(temperature)) await self._async_operate_electra_ac() def _update_device_attrs(self) -> None: diff --git a/homeassistant/components/electrasmart/manifest.json b/homeassistant/components/electrasmart/manifest.json index a2a3f928eeb48a..405d9ee688ad25 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/electrasmart/translations/en.json b/homeassistant/components/electrasmart/translations/en.json deleted file mode 100644 index c6afd62454025e..00000000000000 --- a/homeassistant/components/electrasmart/translations/en.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Phone number already configured" - }, - "error": { - "cannot_connect": "Failed to connect to Electra API", - "invalid_auth": "Wrong one time password key", - "invalid_phone_number": "Either wrong phone number or unregistered user", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "phone_number": "Phone Number (eg. 0501234567)" - } - }, - "one_time_password": { - "data": { - "one_time_password": "One Time Password (OTP)" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index 97673a79b9a5ce..b05cd532c16eea 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 47da87306a34b5..f74ec04476ff6f 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 371840de013a34..8ed8265705c2b3 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 c5fc016aeb9067..8a2f20f209f8bf 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 570c8567403106..d0094a5b37bc4c 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 @@ def current_humidity(self) -> int | None: 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/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index d7094a2e60b106..ccac1593fa002c 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/elkm1", "iot_class": "local_push", "loggers": ["elkm1_lib"], - "requirements": ["elkm1-lib==2.2.2"] + "requirements": ["elkm1-lib==2.2.5"] } diff --git a/homeassistant/components/elmax/manifest.json b/homeassistant/components/elmax/manifest.json index e6e8d76be91653..dfb90763c83c58 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 872b3cca1e14b6..f90dda7935233f 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 dc7159001d8039..0cf4f0f234687a 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -15,7 +15,7 @@ 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 @@ def __init__( 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 ff3591e00667d5..01dae2dca77275 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 eea3f18adc0ae4..324279db7d9e66 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 960b3d41f6363a..739f3b04ec0c1f 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/__init__.py b/homeassistant/components/energy/__init__.py index 41598b39b7294d..7f86b2458cbd0a 100644 --- a/homeassistant/components/energy/__init__.py +++ b/homeassistant/components/energy/__init__.py @@ -4,13 +4,15 @@ from homeassistant.components import frontend from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from . import websocket_api from .const import DOMAIN from .data import async_get_manager +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def is_configured(hass: HomeAssistant) -> bool: """Return a boolean to indicate if energy is configured.""" diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index b2b29760e5e2f5..ae92ee2de58997 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -226,6 +226,8 @@ class EnergyCostSensor(SensorEntity): """ _attr_entity_registry_visible_default = False + _attr_should_poll = False + _wrong_state_class_reported = False _wrong_unit_reported = False @@ -432,6 +434,7 @@ def async_state_changed_listener(*_: Any) -> None: 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/coordinator.py b/homeassistant/components/energyzero/coordinator.py index 232f14e8f8d363..a30509a3840a1c 100644 --- a/homeassistant/components/energyzero/coordinator.py +++ b/homeassistant/components/energyzero/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR @@ -47,7 +47,7 @@ def __init__(self, hass: HomeAssistant) -> None: async def _async_update_data(self) -> EnergyZeroData: """Fetch data from EnergyZero.""" - today = dt.now().date() + today = dt_util.now().date() gas_today = None energy_tomorrow = None @@ -62,7 +62,7 @@ async def _async_update_data(self) -> EnergyZeroData: except EnergyZeroNoDataError: LOGGER.debug("No data for gas prices for EnergyZero integration") # Energy for tomorrow only after 14:00 UTC - if dt.utcnow().hour >= THRESHOLD_HOUR: + if dt_util.utcnow().hour >= THRESHOLD_HOUR: tomorrow = today + timedelta(days=1) try: energy_tomorrow = await self.energyzero.energy_prices( diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index 75b5fa6fea6fd9..17052dfab57c94 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 @@ class 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 @@ class 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 @@ class 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 ed89e0068d4d61..93fb264b01d4c3 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/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 4fa3b25ed06a00..5d1c0027791528 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -151,6 +151,7 @@ def setup_platform( add_entities(entities) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): """Representation of an EnOcean sensor device such as a power meter.""" @@ -180,6 +181,7 @@ def value_changed(self, packet): """Update the internal state of the sensor.""" +# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanPowerSensor(EnOceanSensor): """Representation of an EnOcean power sensor. @@ -200,6 +202,7 @@ def value_changed(self, packet): self.schedule_update_ha_state() +# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanTemperatureSensor(EnOceanSensor): """Representation of an EnOcean temperature sensor device. @@ -249,6 +252,7 @@ def value_changed(self, packet): self.schedule_update_ha_state() +# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanHumiditySensor(EnOceanSensor): """Representation of an EnOcean humidity sensor device. @@ -267,6 +271,7 @@ def value_changed(self, packet): self.schedule_update_ha_state() +# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanWindowHandle(EnOceanSensor): """Representation of an EnOcean window handle device. diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 447c903430944b..28a8d0ba28a90b 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 2870a61d9a0a50..44ffbcdb497829 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -18,6 +18,7 @@ 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 @@ def __init__( """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 7b93f0b28f4d55..385f973a25a8ae 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 6262a28302fad5..4a8a9dec5874c2 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 e7eceb8dadc75b..987a779d2e84d6 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 @@ class 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 @@ class 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 @@ class ECSensorEntityDescription( ), ECSensorEntityDescription( key="humidity", - name="Humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -89,11 +88,13 @@ class 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 @@ class 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 @@ class 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 @@ class ECSensorEntityDescription( ), ECSensorEntityDescription( key="temperature", - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -146,32 +146,32 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ def _get_aqhi_value(data): 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 4c6d75cfeb62f7..d30124ddf5af69 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 74bf9c8ca54b9a..a9f79907b54a34 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -33,7 +33,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import device_info from .const import DOMAIN @@ -80,7 +80,7 @@ def __init__(self, coordinator, hourly): 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'}" ) @@ -169,7 +169,7 @@ def get_forecast(ec_data, hourly): return None today = { - ATTR_FORECAST_TIME: dt.now().isoformat(), + ATTR_FORECAST_TIME: dt_util.now().isoformat(), ATTR_FORECAST_CONDITION: icon_code_to_condition( int(half_days[0]["icon_code"]) ), @@ -201,7 +201,7 @@ def get_forecast(ec_data, hourly): forecast_array.append( { ATTR_FORECAST_TIME: ( - dt.now() + datetime.timedelta(days=day) + dt_util.now() + datetime.timedelta(days=day) ).isoformat(), ATTR_FORECAST_NATIVE_TEMP: int(half_days[high]["temperature"]), ATTR_FORECAST_NATIVE_TEMP_LOW: int(half_days[low]["temperature"]), diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index a68dd562af1435..fb13e86dd1d64b 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -1,149 +1,48 @@ """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 aioesphomeapi import ( APIClient, - APIConnectionError, - APIVersion, - DeviceInfo as EsphomeDeviceInfo, - EntityCategory as EsphomeEntityCategory, - EntityInfo, - EntityState, - HomeassistantServiceCall, - InvalidAuthAPIError, - InvalidEncryptionKeyAPIError, - ReconnectLogic, - RequiresEncryptionAPIError, - UserService, - UserServiceArgType, - VoiceAssistantEventType, ) -from awesomeversion import AwesomeVersion -import voluptuous as vol -from homeassistant.components import tag, zeroconf +from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_ID, CONF_HOST, - CONF_MODE, CONF_PASSWORD, CONF_PORT, - EVENT_HOMEASSISTANT_STOP, - EntityCategory, __version__ as ha_version, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall, State, callback -from homeassistant.exceptions import TemplateError -from homeassistant.helpers import template +from homeassistant.core import HomeAssistant 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, - async_create_issue, - async_delete_issue, -) -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_NOISE_PSK, + DOMAIN, +) +from .dashboard import 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 +from .manager import ESPHomeManager, cleanup_instance -CONF_DEVICE_NAME = "device_name" -CONF_NOISE_PSK = "noise_psk" -_LOGGER = logging.getLogger(__name__) -_R = TypeVar("_R") +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -STABLE_BLE_VERSION_STR = "2023.4.0" -STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) -PROJECT_URLS = { - "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", -} -DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" - -@callback -def _async_check_firmware_version( - hass: HomeAssistant, device_info: EsphomeDeviceInfo -) -> 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 - # 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) - or AwesomeVersion(device_info.esphome_version) >= STABLE_BLE_VERSION - ): - async_delete_issue(hass, DOMAIN, issue) - return - async_create_issue( - hass, - DOMAIN, - issue, - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url=PROJECT_URLS.get(device_info.project_name, DEFAULT_URL), - translation_key="ble_firmware_outdated", - translation_placeholders={ - "name": device_info.name, - "version": STABLE_BLE_VERSION_STR, - }, - ) - - -@callback -def _async_check_using_api_password( - hass: HomeAssistant, device_info: EsphomeDeviceInfo, has_password: bool -) -> None: - """Create or delete an the api_password_deprecated issue.""" - # ESPHome device_info.mac_address is the unique_id - issue = f"api_password_deprecated-{device_info.mac_address}" - if not has_password: - async_delete_issue(hass, DOMAIN, issue) - return - async_create_issue( - hass, - DOMAIN, - issue, - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url="https://esphome.io/components/api.html", - translation_key="api_password_deprecated", - translation_placeholders={ - "name": device_info.name, - }, - ) +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: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the esphome component.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] password = entry.data[CONF_PASSWORD] noise_psk = entry.data.get(CONF_NOISE_PSK) - device_id: str | None = None zeroconf_instance = await zeroconf.async_get_instance(hass) @@ -161,490 +60,21 @@ async def async_setup_entry( # noqa: C901 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) - async def on_stop(event: Event) -> None: - """Cleanup the socket client on HA stop.""" - await _cleanup_instance(hass, entry) - - # Use async_listen instead of async_listen_once so that we don't deregister - # the callback twice when shutting down Home Assistant. - # "Unable to remove unknown listener - # .onetime_listener>" - entry_data.cleanup_callbacks.append( - hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) - ) - - @callback - def async_on_service_call(service: HomeassistantServiceCall) -> None: - """Call service when user automation in ESPHome config is triggered.""" - domain, service_name = service.service.split(".", 1) - service_data = service.data - - if service.data_template: - try: - data_template = { - key: Template(value) for key, value in service.data_template.items() - } - template.attach(hass, data_template) - service_data.update( - template.render_complex(data_template, service.variables) - ) - except TemplateError as ex: - _LOGGER.error("Error rendering data template for %s: %s", host, ex) - return - - if service.is_event: - # ESPHome uses servicecall packet for both events and service calls - # Ensure the user can only send events of form 'esphome.xyz' - if domain != "esphome": - _LOGGER.error( - "Can only generate events under esphome domain! (%s)", host - ) - return - - # Call native tag scan - if service_name == "tag_scanned" and device_id is not None: - tag_id = service_data["tag_id"] - hass.async_create_task(tag.async_scan_tag(hass, tag_id, device_id)) - return - - hass.bus.async_fire( - service.service, - { - ATTR_DEVICE_ID: device_id, - **service_data, - }, - ) - else: - hass.async_create_task( - hass.services.async_call( - domain, service_name, service_data, blocking=True - ) - ) - - async def _send_home_assistant_state( - entity_id: str, attribute: str | None, state: State | None - ) -> None: - """Forward Home Assistant states to ESPHome.""" - if state is None or (attribute and attribute not in state.attributes): - return - - send_state = state.state - if attribute: - attr_val = state.attributes[attribute] - # ESPHome only handles "on"/"off" for boolean values - if isinstance(attr_val, bool): - send_state = "on" if attr_val else "off" - else: - send_state = attr_val - - await cli.send_home_assistant_state(entity_id, attribute, str(send_state)) - - @callback - def async_on_state_subscription( - entity_id: str, attribute: str | None = None - ) -> None: - """Subscribe and forward states for requested entities.""" - - async def send_home_assistant_state_event(event: Event) -> None: - """Forward Home Assistant states updates to ESPHome.""" - - # Only communicate changes to the state or attribute tracked - if event.data.get("new_state") is None or ( - event.data.get("old_state") is not None - and "new_state" in event.data - and ( - ( - not attribute - and event.data["old_state"].state - == event.data["new_state"].state - ) - or ( - attribute - and attribute in event.data["old_state"].attributes - and attribute in event.data["new_state"].attributes - and event.data["old_state"].attributes[attribute] - == event.data["new_state"].attributes[attribute] - ) - ) - ): - return - - await _send_home_assistant_state( - event.data["entity_id"], attribute, event.data.get("new_state") - ) - - unsub = async_track_state_change_event( - hass, [entity_id], send_home_assistant_state_event - ) - entry_data.disconnect_callbacks.append(unsub) - - # Send initial state - hass.async_create_task( - _send_home_assistant_state(entity_id, attribute, hass.states.get(entity_id)) - ) - - voice_assistant_udp_server: VoiceAssistantUDPServer | None = None - - def _handle_pipeline_event( - event_type: VoiceAssistantEventType, data: dict[str, str] | None - ) -> None: - cli.send_voice_assistant_event(event_type, data) - - def _handle_pipeline_finished() -> None: - nonlocal voice_assistant_udp_server - - entry_data.async_set_assist_pipeline_state(False) - - if voice_assistant_udp_server is not None: - voice_assistant_udp_server.close() - voice_assistant_udp_server = None - - async def _handle_pipeline_start() -> int | None: - """Start a voice assistant pipeline.""" - nonlocal voice_assistant_udp_server - - if voice_assistant_udp_server is not None: - return None - - voice_assistant_udp_server = VoiceAssistantUDPServer( - hass, entry_data, _handle_pipeline_event, _handle_pipeline_finished - ) - port = await voice_assistant_udp_server.start_server() - - hass.async_create_background_task( - voice_assistant_udp_server.run_pipeline(), - "esphome.voice_assistant_udp_server.run_pipeline", - ) - entry_data.async_set_assist_pipeline_state(True) - - return port - - async def _handle_pipeline_stop() -> None: - """Stop a voice assistant pipeline.""" - nonlocal voice_assistant_udp_server - - if voice_assistant_udp_server is not None: - voice_assistant_udp_server.stop() - - async def on_connect() -> None: - """Subscribe to states and list entities on successful API login.""" - nonlocal device_id - try: - device_info = await cli.device_info() - - # Migrate config entry to new unique ID if necessary - # This was changed in 2023.1 - if entry.unique_id != format_mac(device_info.mac_address): - hass.config_entries.async_update_entry( - entry, unique_id=format_mac(device_info.mac_address) - ) - - # Make sure we have the correct device name stored - # so we can map the device to ESPHome Dashboard config - if entry.data.get(CONF_DEVICE_NAME) != device_info.name: - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_DEVICE_NAME: device_info.name} - ) - - entry_data.device_info = device_info - assert cli.api_version is not None - entry_data.api_version = cli.api_version - entry_data.available = True - if entry_data.device_info.name: - reconnect_logic.name = entry_data.device_info.name - - if device_info.bluetooth_proxy_version: - entry_data.disconnect_callbacks.append( - await async_connect_scanner(hass, entry, cli, entry_data) - ) - - device_id = _async_setup_device_registry( - hass, entry, entry_data.device_info - ) - entry_data.async_update_device_state(hass) - - entity_infos, services = await cli.list_entities_services() - await entry_data.async_update_static_infos(hass, entry, entity_infos) - await _setup_services(hass, entry_data, services) - await cli.subscribe_states(entry_data.async_update_state) - await cli.subscribe_service_calls(async_on_service_call) - await cli.subscribe_home_assistant_states(async_on_state_subscription) - - if device_info.voice_assistant_version: - entry_data.disconnect_callbacks.append( - await cli.subscribe_voice_assistant( - _handle_pipeline_start, - _handle_pipeline_stop, - ) - ) - - hass.async_create_task(entry_data.async_save_to_store()) - except APIConnectionError as err: - _LOGGER.warning("Error getting initial data for %s: %s", host, err) - # Re-connection logic will trigger after this - await cli.disconnect() - else: - _async_check_firmware_version(hass, device_info) - _async_check_using_api_password(hass, device_info, bool(password)) - - async def on_disconnect() -> 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) - for disconnect_cb in entry_data.disconnect_callbacks: - disconnect_cb() - entry_data.disconnect_callbacks = [] - entry_data.available = False - # 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 = { - (type(entity_state), key) - for state_dict in entry_data.state.values() - for key, entity_state in state_dict.items() - } - if not hass.is_stopping: - # Avoid marking every esphome entity as unavailable on shutdown - # since it generates a lot of state changed events and database - # writes when we already know we're shutting down and the state - # will be cleared anyway. - entry_data.async_update_device_state(hass) - - async def on_connect_error(err: Exception) -> None: - """Start reauth flow if appropriate connect error type.""" - if isinstance( - err, - ( - RequiresEncryptionAPIError, - InvalidEncryptionKeyAPIError, - InvalidAuthAPIError, - ), - ): - entry.async_start_reauth(hass) - - reconnect_logic = ReconnectLogic( - client=cli, - on_connect=on_connect, - on_disconnect=on_disconnect, - zeroconf_instance=zeroconf_instance, - name=host, - on_connect_error=on_connect_error, + manager = ESPHomeManager( + hass, entry, host, password, cli, zeroconf_instance, domain_data, entry_data ) - - infos, services = await entry_data.async_load_from_store() - await entry_data.async_update_static_infos(hass, entry, infos) - await _setup_services(hass, entry_data, services) - - if entry_data.device_info is not None and entry_data.device_info.name: - reconnect_logic.name = entry_data.device_info.name - if entry.unique_id is None: - hass.config_entries.async_update_entry( - entry, unique_id=format_mac(entry_data.device_info.mac_address) - ) - - await reconnect_logic.start() - entry_data.cleanup_callbacks.append(reconnect_logic.stop_callback) + await manager.async_start() return True -@callback -def _async_setup_device_registry( - hass: HomeAssistant, entry: ConfigEntry, device_info: EsphomeDeviceInfo -) -> str: - """Set up device registry feature for a particular config entry.""" - sw_version = device_info.esphome_version - if device_info.compilation_time: - sw_version += f" ({device_info.compilation_time})" - - configuration_url = None - if device_info.webserver_port > 0: - configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}" - elif dashboard := async_get_dashboard(hass): - configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}" - - manufacturer = "espressif" - if device_info.manufacturer: - manufacturer = device_info.manufacturer - model = device_info.model - hw_version = None - if device_info.project_name: - project_name = device_info.project_name.split(".") - manufacturer = project_name[0] - model = project_name[1] - hw_version = device_info.project_version - - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - configuration_url=configuration_url, - connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}, - name=device_info.friendly_name or device_info.name, - manufacturer=manufacturer, - model=model, - sw_version=sw_version, - hw_version=hw_version, - ) - return device_entry.id - - -class ServiceMetadata(NamedTuple): - """Metadata for services.""" - - validator: Any - example: str - selector: dict[str, Any] - description: str | None = None - - -ARG_TYPE_METADATA = { - UserServiceArgType.BOOL: ServiceMetadata( - validator=cv.boolean, - example="False", - selector={"boolean": None}, - ), - UserServiceArgType.INT: ServiceMetadata( - validator=vol.Coerce(int), - example="42", - selector={"number": {CONF_MODE: "box"}}, - ), - UserServiceArgType.FLOAT: ServiceMetadata( - validator=vol.Coerce(float), - example="12.3", - selector={"number": {CONF_MODE: "box", "step": 1e-3}}, - ), - UserServiceArgType.STRING: ServiceMetadata( - validator=cv.string, - example="Example text", - selector={"text": None}, - ), - UserServiceArgType.BOOL_ARRAY: ServiceMetadata( - validator=[cv.boolean], - description="A list of boolean values.", - example="[True, False]", - selector={"object": {}}, - ), - UserServiceArgType.INT_ARRAY: ServiceMetadata( - validator=[vol.Coerce(int)], - description="A list of integer values.", - example="[42, 34]", - selector={"object": {}}, - ), - UserServiceArgType.FLOAT_ARRAY: ServiceMetadata( - validator=[vol.Coerce(float)], - description="A list of floating point numbers.", - example="[ 12.3, 34.5 ]", - selector={"object": {}}, - ), - UserServiceArgType.STRING_ARRAY: ServiceMetadata( - validator=[cv.string], - description="A list of strings.", - example="['Example text', 'Another example']", - selector={"object": {}}, - ), -} - - -async def _register_service( - hass: HomeAssistant, entry_data: RuntimeEntryData, service: UserService -) -> None: - if entry_data.device_info is None: - raise ValueError("Device Info needs to be fetched first") - service_name = f"{entry_data.device_info.name.replace('-', '_')}_{service.name}" - schema = {} - fields = {} - - for arg in service.args: - if arg.type not in ARG_TYPE_METADATA: - _LOGGER.error( - "Can't register service %s because %s is of unknown type %s", - service_name, - arg.name, - arg.type, - ) - return - metadata = ARG_TYPE_METADATA[arg.type] - schema[vol.Required(arg.name)] = metadata.validator - fields[arg.name] = { - "name": arg.name, - "required": True, - "description": metadata.description, - "example": metadata.example, - "selector": metadata.selector, - } - - async def execute_service(call: ServiceCall) -> None: - await entry_data.client.execute_service(service, call.data) - - hass.services.async_register( - DOMAIN, service_name, execute_service, vol.Schema(schema) - ) - - service_desc = { - "description": ( - f"Calls the service {service.name} of the node" - f" {entry_data.device_info.name}" - ), - "fields": fields, - } - - async_set_service_schema(hass, DOMAIN, service_name, service_desc) - - -async def _setup_services( - hass: HomeAssistant, entry_data: RuntimeEntryData, services: list[UserService] -) -> None: - if entry_data.device_info is None: - # Can happen if device has never connected or .storage cleared - return - old_services = entry_data.services.copy() - to_unregister = [] - to_register = [] - for service in services: - if service.key in old_services: - # Already exists - if (matching := old_services.pop(service.key)) != service: - # Need to re-register - to_unregister.append(matching) - to_register.append(service) - else: - # New service - to_register.append(service) - - for service in old_services.values(): - to_unregister.append(service) - - entry_data.services = {serv.key: serv for serv in services} - - for service in to_unregister: - service_name = f"{entry_data.device_info.name}_{service.name}" - hass.services.async_remove(DOMAIN, service_name) - - for service in to_register: - await _register_service(hass, entry_data, service) - - -async def _cleanup_instance( - hass: HomeAssistant, entry: ConfigEntry -) -> RuntimeEntryData: - """Cleanup the esphome client if it exists.""" - domain_data = DomainData.get(hass) - data = domain_data.pop_entry_data(entry) - data.available = False - for disconnect_cb in data.disconnect_callbacks: - disconnect_cb() - data.disconnect_callbacks = [] - for cleanup_callback in data.cleanup_callbacks: - cleanup_callback() - await data.client.disconnect() - return data - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload an esphome config entry.""" - entry_data = await _cleanup_instance(hass, entry) + entry_data = await cleanup_instance(hass, entry) return await hass.config_entries.async_unload_platforms( entry, entry_data.loaded_platforms ) @@ -653,291 +83,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 00000000000000..639f47272d91e8 --- /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 77ec780acb3571..65a237de4f7b90 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 @@ 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 @@ def is_on(self) -> bool | None: # 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 e62b54655c8845..aea65f9358e65a 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -5,7 +5,7 @@ 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 914021b467ef6f..d452ab8764aef3 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -12,6 +12,7 @@ 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_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 @@ def __init__( 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 @@ async def connect( ) -> 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 @@ async def connect( 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 @@ def _on_bluetooth_connection_state( _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 @@ def mtu_size(self) -> int: @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 @@ async def pair(self, *args: Any, **kwargs: Any) -> bool: @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 @@ async def get_services( # 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 @@ async def clear_cache(self) -> bool: """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 @@ def callback(sender: int, data: bytearray): 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 6151ed30429f74..5013a288dcfaaa 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 @@ def async_on_advertisement(self, adv: BluetoothLEAdvertisement) -> None: 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 0cb577f30c917f..eca8d226c69d77 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.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 390208f689d531..94a9b03b90ce2b 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -13,7 +13,10 @@ 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 e40df234d58427..34043da012e2bc 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -11,6 +11,7 @@ ClimatePreset, ClimateState, ClimateSwingMode, + EntityInfo, ) from homeassistant.components.climate import ( @@ -51,10 +52,14 @@ 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 @@ def supported_features(self) -> ClimateEntityFeature: 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 @@ def hvac_action(self) -> str | None: @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 @@ def target_temperature_high(self) -> float | None: 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 @@ async def async_set_temperature(self, **kwargs: Any) -> None: 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 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: 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 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: 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 acc94bc7ea035e..ecd49718559b2b 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -3,6 +3,7 @@ from collections import OrderedDict from collections.abc import Mapping +import json import logging from typing import Any @@ -20,14 +21,20 @@ 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, + CONF_DEVICE_NAME, + CONF_NOISE_PSK, + 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 +42,8 @@ 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 +153,22 @@ def _name(self, value: str) -> None: 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 +257,9 @@ def _async_get_entry(self) -> FlowResult: 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 +276,7 @@ def _async_get_entry(self) -> FlowResult: return self.async_create_entry( title=self._name, data=config_data, + options=config_options, ) async def async_step_encryption_key( @@ -314,7 +338,10 @@ async def fetch_device_info(self) -> str | None: 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 +352,8 @@ async def fetch_device_info(self) -> str | None: 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 +390,13 @@ async def _retrieve_encryption_key_from_dashboard(self) -> bool: 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 +410,46 @@ async def _retrieve_encryption_key_from_dashboard(self) -> bool: 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 617c817924b22b..f0e3972f197745 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -1,3 +1,19 @@ """ESPHome constants.""" +from awesomeversion import AwesomeVersion DOMAIN = "esphome" + +CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" +CONF_DEVICE_NAME = "device_name" +CONF_NOISE_PSK = "noise_psk" + +DEFAULT_ALLOW_SERVICE_CALLS = True +DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False + + +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" diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 9d82b2852916fc..45ef8a132f9f14 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -3,7 +3,7 @@ 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 @@ 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 @@ def current_cover_tilt_position(self) -> int | None: 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 a8332f8d04000b..35e9cf74555968 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -4,6 +4,7 @@ import asyncio from datetime import timedelta import logging +from typing import Any import aiohttp from awesomeversion import AwesomeVersion @@ -11,62 +12,148 @@ 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__) -@callback -def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboard | None: - """Get an instance of the dashboard if set.""" - return hass.data.get(KEY_DASHBOARD) +KEY_DASHBOARD_MANAGER = "esphome_dashboard_manager" +STORAGE_KEY = "esphome.dashboard" +STORAGE_VERSION = 1 -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] +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) - dashboard = ESPHomeDashboard(hass, addon_slug, url, async_get_clientsession(hass)) - try: + +@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() - except UpdateFailed as err: - logging.getLogger(__name__).error("Ignoring dashboard info: %s", err) - return + 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() - hass.data[KEY_DASHBOARD] = dashboard + self._cancel_shutdown = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, on_hass_stop + ) - async def on_hass_stop(_: Event) -> None: - await dashboard.async_shutdown() + 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) - 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) +@callback +def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboard | None: + """Get an instance of the dashboard if set.""" + 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.""" + 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 @@ def __init__( """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 8de1501bc434a6..292d1921abfce0 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 32d2d1efffff1a..2fc32129d1f998 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -12,10 +12,9 @@ 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 @@ def is_entry_loaded(self, entry: ConfigEntry) -> bool: """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 00000000000000..15c136f17c3df3 --- /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 7ce195d68fce08..3391d02a829aeb 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -2,17 +2,19 @@ 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, CameraInfo, + CameraState, ClimateInfo, CoverInfo, DeviceInfo, @@ -34,18 +36,21 @@ 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 +68,34 @@ } +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 +105,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 +116,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 +149,53 @@ def signal_static_info_updated(self) -> str: """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 +245,25 @@ def _unsubscribe() -> None: 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 +295,21 @@ async def async_update_static_infos( 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) @@ -244,9 +340,10 @@ def async_update_state(self, state: EntityState) -> None: if ( current_state == state and subscription_key not in stale_state + and state_type is not CameraState and not ( - type(state) is SensorState # pylint: disable=unidiomatic-typecheck - and (platform_info := self.info.get(Platform.SENSOR)) + state_type is SensorState # pylint: disable=unidiomatic-typecheck + and (platform_info := self.info.get(SensorInfo)) and (entity_info := platform_info.get(state.key)) and (cast(SensorInfo, entity_info)).force_update ) @@ -266,8 +363,14 @@ def async_update_state(self, state: EntityState) -> None: ) stale_state.discard(subscription_key) current_state_by_type[key] = state - if subscription_key in self.state_subscriptions: - self.state_subscriptions[subscription_key]() + if subscription := self.state_subscriptions.get(subscription_key): + try: + subscription() + except Exception as ex: # pylint: disable=broad-except + # If we allow this exception to raise it will + # make it all the way to data_received in aioesphomeapi + # which will cause the connection to be closed. + _LOGGER.exception("Error while calling subscription: %s", ex) @callback def async_update_device_state(self, hass: HomeAssistant) -> None: @@ -278,43 +381,61 @@ async def async_load_from_store(self) -> tuple[list[EntityInfo], list[UserServic """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 01060630964ae1..c6be200e2b21ad 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -4,7 +4,7 @@ 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 @@ 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 @@ 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 @@ async def _async_set_percentage(self, percentage: int | None) -> None: 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 @@ async def async_turn_on( 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 @@ def speed_count(self) -> int: @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 880d94a5f55124..1ecc99730bfebd 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -3,11 +3,17 @@ 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 @@ 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 @@ async def async_setup_entry( } +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. @@ -122,16 +145,14 @@ def _filter_color_modes( Excluding all values that don't have the requested features. """ - return [mode for mode in supported if mode & features] + return [mode for mode in supported if (mode & features) == features] class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """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 @@ def is_on(self) -> bool | None: 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 @@ async def async_turn_on(self, **kwargs: Any) -> None: # 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 @@ async def async_turn_on(self, **kwargs: Any) -> None: 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 @@ async def async_turn_on(self, **kwargs: Any) -> None: 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 @@ def color_mode(self) -> str | None: @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 @@ def rgbw_color(self) -> tuple[int, int, int, int] | None: @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 @@ def rgbww_color(self) -> tuple[int, int, int, int, int] | None: ) 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 @@ def effect(self) -> str | None: """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 @@ def supported_color_modes(self) -> set[str] | None: 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 947ea4729bb24d..00b94cd15ff581 100644 --- a/homeassistant/components/esphome/lock.py +++ b/homeassistant/components/esphome/lock.py @@ -3,15 +3,19 @@ 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 @@ def is_jammed(self) -> bool | None: 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/manager.py b/homeassistant/components/esphome/manager.py new file mode 100644 index 00000000000000..b87d3ac38992a5 --- /dev/null +++ b/homeassistant/components/esphome/manager.py @@ -0,0 +1,696 @@ +"""Manager for esphome devices.""" +from __future__ import annotations + +import logging +from typing import Any, NamedTuple + +from aioesphomeapi import ( + APIClient, + APIConnectionError, + APIVersion, + DeviceInfo as EsphomeDeviceInfo, + HomeassistantServiceCall, + InvalidAuthAPIError, + InvalidEncryptionKeyAPIError, + ReconnectLogic, + RequiresEncryptionAPIError, + UserService, + UserServiceArgType, + VoiceAssistantEventType, +) +from awesomeversion import AwesomeVersion +import voluptuous as vol + +from homeassistant.components import tag, zeroconf +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_MODE, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import Event, HomeAssistant, ServiceCall, State, callback +from homeassistant.exceptions import TemplateError +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.event import async_track_state_change_event +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) +from homeassistant.helpers.service import async_set_service_schema +from homeassistant.helpers.template import Template + +from .bluetooth import async_connect_scanner +from .const import ( + CONF_ALLOW_SERVICE_CALLS, + CONF_DEVICE_NAME, + DEFAULT_ALLOW_SERVICE_CALLS, + DEFAULT_URL, + DOMAIN, + PROJECT_URLS, + STABLE_BLE_VERSION, + STABLE_BLE_VERSION_STR, +) +from .dashboard import async_get_dashboard +from .domain_data import DomainData + +# Import config flow so that it's added to the registry +from .entry_data import RuntimeEntryData +from .voice_assistant import VoiceAssistantUDPServer + +_LOGGER = logging.getLogger(__name__) + + +@callback +def _async_check_firmware_version( + 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_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) + or AwesomeVersion(device_info.esphome_version) >= STABLE_BLE_VERSION + ): + async_delete_issue(hass, DOMAIN, issue) + return + async_create_issue( + hass, + DOMAIN, + issue, + is_fixable=False, + severity=IssueSeverity.WARNING, + learn_more_url=PROJECT_URLS.get(device_info.project_name, DEFAULT_URL), + translation_key="ble_firmware_outdated", + translation_placeholders={ + "name": device_info.name, + "version": STABLE_BLE_VERSION_STR, + }, + ) + + +@callback +def _async_check_using_api_password( + hass: HomeAssistant, device_info: EsphomeDeviceInfo, has_password: bool +) -> None: + """Create or delete an the api_password_deprecated issue.""" + # ESPHome device_info.mac_address is the unique_id + issue = f"api_password_deprecated-{device_info.mac_address}" + if not has_password: + async_delete_issue(hass, DOMAIN, issue) + return + async_create_issue( + hass, + DOMAIN, + issue, + is_fixable=False, + severity=IssueSeverity.WARNING, + learn_more_url="https://esphome.io/components/api.html", + translation_key="api_password_deprecated", + translation_placeholders={ + "name": device_info.name, + }, + ) + + +class ESPHomeManager: + """Class to manage an ESPHome connection.""" + + __slots__ = ( + "hass", + "host", + "password", + "entry", + "cli", + "device_id", + "domain_data", + "voice_assistant_udp_server", + "reconnect_logic", + "zeroconf_instance", + "entry_data", + ) + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + host: str, + password: str | None, + cli: APIClient, + zeroconf_instance: zeroconf.HaZeroconf, + domain_data: DomainData, + entry_data: RuntimeEntryData, + ) -> None: + """Initialize the esphome manager.""" + self.hass = hass + self.host = host + self.password = password + self.entry = entry + self.cli = cli + self.device_id: str | None = None + self.domain_data = domain_data + self.voice_assistant_udp_server: VoiceAssistantUDPServer | None = None + self.reconnect_logic: ReconnectLogic | None = None + self.zeroconf_instance = zeroconf_instance + self.entry_data = entry_data + + async def on_stop(self, event: Event) -> None: + """Cleanup the socket client on HA stop.""" + await cleanup_instance(self.hass, self.entry) + + @property + def services_issue(self) -> str: + """Return the services issue name for this entry.""" + return f"service_calls_not_enabled-{self.entry.unique_id}" + + @callback + def async_on_service_call(self, service: HomeassistantServiceCall) -> None: + """Call service when user automation in ESPHome config is triggered.""" + hass = self.hass + domain, service_name = service.service.split(".", 1) + service_data = service.data + + if service.data_template: + try: + data_template = { + key: Template(value) for key, value in service.data_template.items() + } + template.attach(hass, data_template) + service_data.update( + template.render_complex(data_template, service.variables) + ) + except TemplateError as ex: + _LOGGER.error("Error rendering data template for %s: %s", self.host, ex) + return + + if service.is_event: + device_id = self.device_id + # 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( + "Can only generate events under esphome domain! (%s)", self.host + ) + return + + # Call native tag scan + if service_name == "tag_scanned" and device_id is not None: + tag_id = service_data["tag_id"] + hass.async_create_task(tag.async_scan_tag(hass, tag_id, device_id)) + return + + hass.bus.async_fire( + service.service, + { + ATTR_DEVICE_ID: device_id, + **service_data, + }, + ) + elif self.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: + device_info = self.entry_data.device_info + assert device_info is not None + async_create_issue( + hass, + DOMAIN, + self.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( + self, entity_id: str, attribute: str | None, state: State | None + ) -> None: + """Forward Home Assistant states to ESPHome.""" + if state is None or (attribute and attribute not in state.attributes): + return + + send_state = state.state + if attribute: + attr_val = state.attributes[attribute] + # ESPHome only handles "on"/"off" for boolean values + if isinstance(attr_val, bool): + send_state = "on" if attr_val else "off" + else: + send_state = attr_val + + await self.cli.send_home_assistant_state(entity_id, attribute, str(send_state)) + + @callback + def async_on_state_subscription( + self, entity_id: str, attribute: str | None = None + ) -> None: + """Subscribe and forward states for requested entities.""" + hass = self.hass + + async def send_home_assistant_state_event(event: Event) -> None: + """Forward Home Assistant states updates to ESPHome.""" + event_data = event.data + new_state: State | None = event_data.get("new_state") + old_state: State | None = event_data.get("old_state") + + if new_state is None or old_state is None: + return + + # Only communicate changes to the state or attribute tracked + if (not attribute and old_state.state == new_state.state) or ( + attribute + and old_state.attributes.get(attribute) + == new_state.attributes.get(attribute) + ): + return + + await self._send_home_assistant_state( + event.data["entity_id"], attribute, new_state + ) + + self.entry_data.disconnect_callbacks.append( + async_track_state_change_event( + hass, [entity_id], send_home_assistant_state_event + ) + ) + + # Send initial state + hass.async_create_task( + self._send_home_assistant_state( + entity_id, attribute, hass.states.get(entity_id) + ) + ) + + def _handle_pipeline_event( + self, event_type: VoiceAssistantEventType, data: dict[str, str] | None + ) -> None: + self.cli.send_voice_assistant_event(event_type, data) + + def _handle_pipeline_finished(self) -> None: + self.entry_data.async_set_assist_pipeline_state(False) + + if self.voice_assistant_udp_server is not None: + self.voice_assistant_udp_server.close() + self.voice_assistant_udp_server = None + + async def _handle_pipeline_start( + self, conversation_id: str, use_vad: bool + ) -> int | None: + """Start a voice assistant pipeline.""" + if self.voice_assistant_udp_server is not None: + return None + + hass = self.hass + voice_assistant_udp_server = VoiceAssistantUDPServer( + hass, + self.entry_data, + self._handle_pipeline_event, + self._handle_pipeline_finished, + ) + port = await voice_assistant_udp_server.start_server() + + assert self.device_id is not None, "Device ID must be set" + hass.async_create_background_task( + voice_assistant_udp_server.run_pipeline( + device_id=self.device_id, + conversation_id=conversation_id or None, + use_vad=use_vad, + ), + "esphome.voice_assistant_udp_server.run_pipeline", + ) + self.entry_data.async_set_assist_pipeline_state(True) + + return port + + async def _handle_pipeline_stop(self) -> None: + """Stop a voice assistant pipeline.""" + if self.voice_assistant_udp_server is not None: + self.voice_assistant_udp_server.stop() + + async def on_connect(self) -> None: + """Subscribe to states and list entities on successful API login.""" + entry = self.entry + entry_data = self.entry_data + reconnect_logic = self.reconnect_logic + hass = self.hass + cli = self.cli + try: + device_info = await cli.device_info() + + # Migrate config entry to new unique ID if necessary + # This was changed in 2023.1 + if entry.unique_id != format_mac(device_info.mac_address): + hass.config_entries.async_update_entry( + entry, unique_id=format_mac(device_info.mac_address) + ) + + # Make sure we have the correct device name stored + # so we can map the device to ESPHome Dashboard config + if entry.data.get(CONF_DEVICE_NAME) != device_info.name: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_DEVICE_NAME: device_info.name} + ) + + entry_data.device_info = device_info + assert cli.api_version is not None + entry_data.api_version = cli.api_version + entry_data.available = True + if entry_data.device_info.name: + assert reconnect_logic is not None, "Reconnect logic must be set" + reconnect_logic.name = entry_data.device_info.name + + if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): + entry_data.disconnect_callbacks.append( + await async_connect_scanner(hass, entry, cli, entry_data) + ) + + self.device_id = _async_setup_device_registry( + hass, entry, entry_data.device_info + ) + entry_data.async_update_device_state(hass) + + entity_infos, services = await cli.list_entities_services() + await entry_data.async_update_static_infos(hass, entry, entity_infos) + await _setup_services(hass, entry_data, services) + await cli.subscribe_states(entry_data.async_update_state) + await cli.subscribe_service_calls(self.async_on_service_call) + await cli.subscribe_home_assistant_states(self.async_on_state_subscription) + + if device_info.voice_assistant_version: + entry_data.disconnect_callbacks.append( + await cli.subscribe_voice_assistant( + self._handle_pipeline_start, + self._handle_pipeline_stop, + ) + ) + + hass.async_create_task(entry_data.async_save_to_store()) + except APIConnectionError as err: + _LOGGER.warning("Error getting initial data for %s: %s", self.host, err) + # Re-connection logic will trigger after this + await cli.disconnect() + else: + _async_check_firmware_version(hass, device_info, entry_data.api_version) + _async_check_using_api_password(hass, device_info, bool(self.password)) + + async def on_disconnect(self, expected_disconnect: bool) -> None: + """Run disconnect callbacks on API disconnect.""" + entry_data = self.entry_data + hass = self.hass + host = self.host + name = entry_data.device_info.name if entry_data.device_info else 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 = { + (type(entity_state), key) + for state_dict in entry_data.state.values() + for key, entity_state in state_dict.items() + } + if not hass.is_stopping: + # Avoid marking every esphome entity as unavailable on shutdown + # since it generates a lot of state changed events and database + # writes when we already know we're shutting down and the state + # will be cleared anyway. + entry_data.async_update_device_state(hass) + + async def on_connect_error(self, err: Exception) -> None: + """Start reauth flow if appropriate connect error type.""" + if isinstance( + err, + ( + RequiresEncryptionAPIError, + InvalidEncryptionKeyAPIError, + InvalidAuthAPIError, + ), + ): + self.entry.async_start_reauth(self.hass) + + async def async_start(self) -> None: + """Start the esphome connection manager.""" + hass = self.hass + entry = self.entry + entry_data = self.entry_data + + if entry.options.get(CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS): + async_delete_issue(hass, DOMAIN, self.services_issue) + + # Use async_listen instead of async_listen_once so that we don't deregister + # the callback twice when shutting down Home Assistant. + # "Unable to remove unknown listener + # .onetime_listener>" + entry_data.cleanup_callbacks.append( + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self.on_stop) + ) + + reconnect_logic = ReconnectLogic( + client=self.cli, + on_connect=self.on_connect, + on_disconnect=self.on_disconnect, + zeroconf_instance=self.zeroconf_instance, + name=self.host, + on_connect_error=self.on_connect_error, + ) + self.reconnect_logic = reconnect_logic + + infos, services = await entry_data.async_load_from_store() + await entry_data.async_update_static_infos(hass, entry, infos) + await _setup_services(hass, entry_data, services) + + if entry_data.device_info is not None and entry_data.device_info.name: + reconnect_logic.name = entry_data.device_info.name + if entry.unique_id is None: + hass.config_entries.async_update_entry( + entry, unique_id=format_mac(entry_data.device_info.mac_address) + ) + + 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) + ) + + +@callback +def _async_setup_device_registry( + hass: HomeAssistant, entry: ConfigEntry, device_info: EsphomeDeviceInfo +) -> str: + """Set up device registry feature for a particular config entry.""" + sw_version = device_info.esphome_version + if device_info.compilation_time: + sw_version += f" ({device_info.compilation_time})" + + configuration_url = None + if device_info.webserver_port > 0: + configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}" + elif dashboard := async_get_dashboard(hass): + configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}" + + manufacturer = "espressif" + if device_info.manufacturer: + manufacturer = device_info.manufacturer + model = device_info.model + hw_version = None + if device_info.project_name: + project_name = device_info.project_name.split(".") + manufacturer = project_name[0] + model = project_name[1] + hw_version = device_info.project_version + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + configuration_url=configuration_url, + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}, + name=device_info.friendly_name or device_info.name, + manufacturer=manufacturer, + model=model, + sw_version=sw_version, + hw_version=hw_version, + ) + return device_entry.id + + +class ServiceMetadata(NamedTuple): + """Metadata for services.""" + + validator: Any + example: str + selector: dict[str, Any] + description: str | None = None + + +ARG_TYPE_METADATA = { + UserServiceArgType.BOOL: ServiceMetadata( + validator=cv.boolean, + example="False", + selector={"boolean": None}, + ), + UserServiceArgType.INT: ServiceMetadata( + validator=vol.Coerce(int), + example="42", + selector={"number": {CONF_MODE: "box"}}, + ), + UserServiceArgType.FLOAT: ServiceMetadata( + validator=vol.Coerce(float), + example="12.3", + selector={"number": {CONF_MODE: "box", "step": 1e-3}}, + ), + UserServiceArgType.STRING: ServiceMetadata( + validator=cv.string, + example="Example text", + selector={"text": None}, + ), + UserServiceArgType.BOOL_ARRAY: ServiceMetadata( + validator=[cv.boolean], + description="A list of boolean values.", + example="[True, False]", + selector={"object": {}}, + ), + UserServiceArgType.INT_ARRAY: ServiceMetadata( + validator=[vol.Coerce(int)], + description="A list of integer values.", + example="[42, 34]", + selector={"object": {}}, + ), + UserServiceArgType.FLOAT_ARRAY: ServiceMetadata( + validator=[vol.Coerce(float)], + description="A list of floating point numbers.", + example="[ 12.3, 34.5 ]", + selector={"object": {}}, + ), + UserServiceArgType.STRING_ARRAY: ServiceMetadata( + validator=[cv.string], + description="A list of strings.", + example="['Example text', 'Another example']", + selector={"object": {}}, + ), +} + + +async def _register_service( + hass: HomeAssistant, entry_data: RuntimeEntryData, service: UserService +) -> None: + if entry_data.device_info is None: + raise ValueError("Device Info needs to be fetched first") + service_name = f"{entry_data.device_info.name.replace('-', '_')}_{service.name}" + schema = {} + fields = {} + + for arg in service.args: + if arg.type not in ARG_TYPE_METADATA: + _LOGGER.error( + "Can't register service %s because %s is of unknown type %s", + service_name, + arg.name, + arg.type, + ) + return + metadata = ARG_TYPE_METADATA[arg.type] + schema[vol.Required(arg.name)] = metadata.validator + fields[arg.name] = { + "name": arg.name, + "required": True, + "description": metadata.description, + "example": metadata.example, + "selector": metadata.selector, + } + + async def execute_service(call: ServiceCall) -> None: + await entry_data.client.execute_service(service, call.data) + + hass.services.async_register( + DOMAIN, service_name, execute_service, vol.Schema(schema) + ) + + service_desc = { + "description": ( + f"Calls the service {service.name} of the node" + f" {entry_data.device_info.name}" + ), + "fields": fields, + } + + async_set_service_schema(hass, DOMAIN, service_name, service_desc) + + +async def _setup_services( + hass: HomeAssistant, entry_data: RuntimeEntryData, services: list[UserService] +) -> None: + if entry_data.device_info is None: + # Can happen if device has never connected or .storage cleared + return + old_services = entry_data.services.copy() + to_unregister = [] + to_register = [] + for service in services: + if service.key in old_services: + # Already exists + if (matching := old_services.pop(service.key)) != service: + # Need to re-register + to_unregister.append(matching) + to_register.append(service) + else: + # New service + to_register.append(service) + + for service in old_services.values(): + to_unregister.append(service) + + entry_data.services = {serv.key: serv for serv in services} + + for service in to_unregister: + service_name = f"{entry_data.device_info.name}_{service.name}" + hass.services.async_remove(DOMAIN, service_name) + + for service in to_register: + await _register_service(hass, entry_data, service) + + +async def cleanup_instance(hass: HomeAssistant, entry: ConfigEntry) -> RuntimeEntryData: + """Cleanup the esphome client if it exists.""" + domain_data = DomainData.get(hass) + data = domain_data.pop_entry_data(entry) + data.available = False + for disconnect_cb in data.disconnect_callbacks: + disconnect_cb() + data.disconnect_callbacks = [] + for cleanup_callback in data.cleanup_callbacks: + cleanup_callback() + await data.async_cleanup() + await data.client.disconnect() + return data diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 49057080469cfa..1acf0f1154e472 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.7.5", - "bluetooth-data-tools==0.4.0", + "aioesphomeapi==15.1.3", + "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 d818e0409656f0..9d00830096676e 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -4,6 +4,7 @@ from typing import Any from aioesphomeapi import ( + EntityInfo, MediaPlayerCommand, MediaPlayerEntityState, MediaPlayerInfo, @@ -21,10 +22,14 @@ 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 @@ def volume_level(self) -> float | None: """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 @@ async def async_play_media( 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 @@ async def async_browse_media( 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 3ca8e0b97281b6..4e3d052e6ef06a 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -3,15 +3,24 @@ 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 @@ async def async_setup_entry( 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): - return None - if self._state.missing_state: + state = self._state + if state.missing_state or math.isnan(state.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 e4cac21dbc84f9..a3464b137dcb58 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 @@ def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None: """Initialize a pipeline selector.""" EsphomeAssistEntity.__init__(self, entry_data) AssistPipelineSelect.__init__(self, hass, self._device_info.mac_address) + + +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 25a0bfaff7f50e..3185a5eb53618c 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -5,6 +5,7 @@ import math from aioesphomeapi import ( + EntityInfo, SensorInfo, SensorState, SensorStateClass as EsphomeSensorStateClass, @@ -19,12 +20,16 @@ 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 +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 @@ async def async_setup_entry( 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): - return None - if self._state.missing_state: - return None - if self.device_class == SensorDeviceClass.TIMESTAMP: - return dt.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: + state = self._state + if math.isnan(state.state) or state.missing_state: 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 81350c2c6537c3..2ec1fe1bc41c07 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 83148542435740..99894b8501ea53 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -3,15 +3,19 @@ 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 @@ def is_on(self) -> bool | None: """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 618e31024b1abe..6f51b9df74415a 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -111,7 +111,11 @@ def available(self) -> bool: """ 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 aaa2dc80a78cfa..6b49549d81257f 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -2,7 +2,8 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterable, Callable +from collections import deque +from collections.abc import AsyncIterable, Callable, MutableSequence, Sequence import logging import socket from typing import cast @@ -14,9 +15,14 @@ from homeassistant.components.assist_pipeline import ( PipelineEvent, PipelineEventType, + PipelineNotFound, async_pipeline_from_audio_stream, select as pipeline_select, ) +from homeassistant.components.assist_pipeline.vad import ( + VadSensitivity, + VoiceCommandSegmenter, +) from homeassistant.components.media_player import async_process_play_media_url from homeassistant.core import Context, HomeAssistant, callback @@ -50,7 +56,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): """Receive UDP packets and forward them to the voice assistant.""" started = False - queue: asyncio.Queue[bytes] | None = None + stopped = False transport: asyncio.DatagramTransport | None = None remote_addr: tuple[str, int] | None = None @@ -60,6 +66,7 @@ def __init__( entry_data: RuntimeEntryData, handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None], handle_finished: Callable[[], None], + audio_timeout: float = 2.0, ) -> None: """Initialize UDP receiver.""" self.context = Context() @@ -68,10 +75,11 @@ def __init__( assert entry_data.device_info is not None self.device_info = entry_data.device_info - self.queue = asyncio.Queue() + self.queue: asyncio.Queue[bytes] = asyncio.Queue() self.handle_event = handle_event self.handle_finished = handle_finished self._tts_done = asyncio.Event() + self.audio_timeout = audio_timeout async def start_server(self) -> int: """Start accepting connections.""" @@ -80,7 +88,7 @@ def accept_connection() -> VoiceAssistantUDPServer: """Accept connection.""" if self.started: raise RuntimeError("Can only start once") - if self.queue is None: + if self.stopped: raise RuntimeError("No longer accepting connections") self.started = True @@ -105,12 +113,11 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None: @callback def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: """Handle incoming UDP packet.""" - if not self.started: + if not self.started or self.stopped: return if self.remote_addr is None: self.remote_addr = addr - if self.queue is not None: - self.queue.put_nowait(data) + self.queue.put_nowait(data) def error_received(self, exc: Exception) -> None: """Handle when a send or receive operation raises an OSError. @@ -123,21 +130,21 @@ def error_received(self, exc: Exception) -> None: @callback def stop(self) -> None: """Stop the receiver.""" - if self.queue is not None: - self.queue.put_nowait(b"") + self.queue.put_nowait(b"") self.started = False + self.stopped = True def close(self) -> None: """Close the receiver.""" - if self.queue is not None: - self.queue = None + self.started = False + self.stopped = True if self.transport is not None: self.transport.close() async def _iterate_packets(self) -> AsyncIterable[bytes]: """Iterate over incoming packets.""" - if self.queue is None: - raise RuntimeError("Already stopped") + if not self.started or self.stopped: + raise RuntimeError("Not running") while data := await self.queue.get(): yield data @@ -152,9 +159,15 @@ def _event_callback(self, event: PipelineEvent) -> None: return data_to_send = None + error = False if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: assert event.data is not None data_to_send = {"text": event.data["stt_output"]["text"]} + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: + assert event.data is not None + data_to_send = { + "conversation_id": event.data["intent_output"]["conversation_id"] or "", + } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: assert event.data is not None data_to_send = {"text": event.data["tts_input"]} @@ -177,19 +190,142 @@ def _event_callback(self, event: PipelineEvent) -> None: "code": event.data["code"], "message": event.data["message"], } - self.handle_finished() + self._tts_done.set() + error = True self.handle_event(event_type, data_to_send) + if error: + self.handle_finished() + + async def _wait_for_speech( + self, + segmenter: VoiceCommandSegmenter, + chunk_buffer: MutableSequence[bytes], + ) -> bool: + """Buffer audio chunks until speech is detected. + + Raises asyncio.TimeoutError if no audio data is retrievable from the queue (device stops sending packets / networking issue). + + Returns True if speech was detected + Returns False if the connection was stopped gracefully (b"" put onto the queue). + """ + # Timeout if no audio comes in for a while. + async with async_timeout.timeout(self.audio_timeout): + chunk = await self.queue.get() + + while chunk: + segmenter.process(chunk) + # Buffer the data we have taken from the queue + chunk_buffer.append(chunk) + if segmenter.in_command: + return True + + async with async_timeout.timeout(self.audio_timeout): + chunk = await self.queue.get() + + # If chunk is falsey, `stop()` was called + return False + + async def _segment_audio( + self, + segmenter: VoiceCommandSegmenter, + chunk_buffer: Sequence[bytes], + ) -> AsyncIterable[bytes]: + """Yield audio chunks until voice command has finished. + + Raises asyncio.TimeoutError if no audio data is retrievable from the queue. + """ + # Buffered chunks first + for buffered_chunk in chunk_buffer: + yield buffered_chunk + + # Timeout if no audio comes in for a while. + async with async_timeout.timeout(self.audio_timeout): + chunk = await self.queue.get() + + while chunk: + if not segmenter.process(chunk): + # Voice command is finished + break + + yield chunk + + async with async_timeout.timeout(self.audio_timeout): + chunk = await self.queue.get() + + async def _iterate_packets_with_vad( + self, pipeline_timeout: float, silence_seconds: float + ) -> Callable[[], AsyncIterable[bytes]] | None: + segmenter = VoiceCommandSegmenter(silence_seconds=silence_seconds) + chunk_buffer: deque[bytes] = deque(maxlen=100) + try: + async with async_timeout.timeout(pipeline_timeout): + speech_detected = await self._wait_for_speech(segmenter, chunk_buffer) + if not speech_detected: + _LOGGER.debug( + "Device stopped sending audio before speech was detected" + ) + self.handle_finished() + return None + except asyncio.TimeoutError: + self.handle_event( + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, + { + "code": "speech-timeout", + "message": "Timed out waiting for speech", + }, + ) + self.handle_finished() + return None + + async def _stream_packets() -> AsyncIterable[bytes]: + try: + async for chunk in self._segment_audio(segmenter, chunk_buffer): + yield chunk + except asyncio.TimeoutError: + self.handle_event( + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, + { + "code": "speech-timeout", + "message": "No speech detected", + }, + ) + self.handle_finished() + + return _stream_packets async def run_pipeline( self, + device_id: str, + conversation_id: str | None, + use_vad: bool = False, pipeline_timeout: float = 30.0, ) -> None: """Run the Voice Assistant pipeline.""" - try: - tts_audio_output = ( - "raw" if self.device_info.voice_assistant_version >= 2 else "mp3" + + tts_audio_output = ( + "raw" if self.device_info.voice_assistant_version >= 2 else "mp3" + ) + + if use_vad: + stt_stream = await self._iterate_packets_with_vad( + pipeline_timeout, + silence_seconds=VadSensitivity.to_seconds( + pipeline_select.get_vad_sensitivity( + self.hass, + DOMAIN, + self.device_info.mac_address, + ) + ), ) + # Error or timeout occurred and was handled already + if stt_stream is None: + return + else: + stt_stream = self._iterate_packets + + _LOGGER.debug("Starting pipeline") + try: async with async_timeout.timeout(pipeline_timeout): await async_pipeline_from_audio_stream( self.hass, @@ -203,10 +339,12 @@ async def run_pipeline( sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ), - stt_stream=self._iterate_packets(), + stt_stream=stt_stream(), pipeline_id=pipeline_select.get_chosen_pipeline( self.hass, DOMAIN, self.device_info.mac_address ), + conversation_id=conversation_id, + device_id=device_id, tts_audio_output=tts_audio_output, ) @@ -214,7 +352,23 @@ async def run_pipeline( await self._tts_done.wait() _LOGGER.debug("Pipeline finished") + except PipelineNotFound: + self.handle_event( + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, + { + "code": "pipeline not found", + "message": "Selected pipeline timeout", + }, + ) + _LOGGER.warning("Pipeline not found") except asyncio.TimeoutError: + self.handle_event( + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, + { + "code": "pipeline-timeout", + "message": "Pipeline timeout", + }, + ) _LOGGER.warning("Pipeline timeout") finally: self.handle_finished() diff --git a/homeassistant/components/eufylife_ble/manifest.json b/homeassistant/components/eufylife_ble/manifest.json index ad70dd97d584b4..c3a2357ebca849 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 e57b83687a627e..741f71b34d201e 100644 --- a/homeassistant/components/eufylife_ble/sensor.py +++ b/homeassistant/components/eufylife_ble/sensor.py @@ -86,7 +86,7 @@ async def async_added_to_hass(self) -> None: 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 @@ -111,10 +111,11 @@ def suggested_unit_of_measurement(self) -> str | None: return UnitOfMass.KILOGRAMS +# pylint: disable-next=hass-invalid-inheritance # needs fixing 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 @@ -171,10 +172,11 @@ async def async_added_to_hass(self) -> None: ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing 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 a045d84771e33b..5f7924f4cbd521 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 d4ee8f3d5dfa91..3bee1d6062ea2f 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 @@ async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> No ) @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 @@ async def _set_tcs_mode(self, mode: str, until: dt | None = None) -> None: ) @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 05857abbac7bec..9386a407acb491 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -35,6 +35,8 @@ ATTR_TYPE_CLOUD: [ Platform.BINARY_SENSOR, Platform.CAMERA, + Platform.LIGHT, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 0456e7ade9e6d7..60a332446cef36 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -17,7 +17,11 @@ ) from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, discovery_flow +from homeassistant.helpers import ( + config_validation as cv, + discovery_flow, + issue_registry as ir, +) from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, @@ -303,3 +307,13 @@ def perform_set_alarm_detection_sensibility( ) except (HTTPError, PyEzvizError) as err: raise PyEzvizError("Cannot set detection sensitivity level") from err + + ir.async_create_issue( + self.hass, + DOMAIN, + "service_depreciation_detection_sensibility", + breaks_in_ha_version="2023.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 1c966c7f82e8ad..ccf273a970bb2b 100644 --- a/homeassistant/components/ezviz/entity.py +++ b/homeassistant/components/ezviz/entity.py @@ -38,3 +38,32 @@ def __init__( 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 00000000000000..38007962e4e6f3 --- /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 219f4c87d13bf5..53976bf3002a64 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 new file mode 100644 index 00000000000000..074685c69f978e --- /dev/null +++ b/homeassistant/components/ezviz/number.py @@ -0,0 +1,142 @@ +"""Support for EZVIZ number controls.""" +from __future__ import annotations + +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 +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizBaseEntity + +SCAN_INTERVAL = timedelta(seconds=3600) +PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) + + +@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"], +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up EZVIZ sensors based on a config entry.""" + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + async_add_entities( + [ + EzvizSensor(coordinator, camera, 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(EzvizBaseEntity, NumberEntity): + """Representation of a EZVIZ number entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + value: str, + config_entry_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, serial) + 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.""" + 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: + 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/sensor.py b/homeassistant/components/ezviz/sensor.py index 8e617aa3b3eb11..11412c1fc70d07 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -25,7 +25,6 @@ device_class=SensorDeviceClass.BATTERY, ), "alarm_sound_mod": SensorEntityDescription(key="alarm_sound_mod"), - "detection_sensibility": SensorEntityDescription(key="detection_sensibility"), "last_alarm_time": SensorEntityDescription(key="last_alarm_time"), "Seconds_Last_Trigger": SensorEntityDescription( key="Seconds_Last_Trigger", diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 5e258e42705748..5711aff2a4a036 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -58,5 +58,11 @@ } } } + }, + "issues": { + "service_depreciation_detection_sensibility": { + "title": "Ezviz Detection sensitivity service is being removed", + "description": "Ezviz Detection sensitivity service is deprecated and will be removed in Home Assistant 2023.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 d4bd5f2e419d3d..920f970185b408 100644 --- a/homeassistant/components/fan/device_condition.py +++ b/homeassistant/components/fan/device_condition.py @@ -28,7 +28,7 @@ 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 cfc44029e2340d..52d5aca070aa20 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/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 4f00dd5a543906..b3d5f66ae8c887 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -24,6 +24,7 @@ async def async_setup_platform( async_add_entities([SpeedtestSensor(hass.data[FASTDOTCOM_DOMAIN])]) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class SpeedtestSensor(RestoreEntity, SensorEntity): """Implementation of a FAst.com sensor.""" diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 3313e51ce7596a..dc7be9f1e69d37 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -8,7 +8,6 @@ from pyfibaro.fibaro_client import FibaroClient from pyfibaro.fibaro_device import DeviceModel -from pyfibaro.fibaro_scene import SceneModel from requests.exceptions import HTTPError from homeassistant.config_entries import ConfigEntry @@ -278,24 +277,18 @@ def get_device_info(self, device: Any) -> DeviceInfo: return self._device_infos[device.parent_fibaro_id] return DeviceInfo(identifiers={(DOMAIN, self.hub_serial)}) + def get_room_name(self, room_id: int) -> str | None: + """Get the room name by room id.""" + assert self._room_map + room = self._room_map.get(room_id) + return room.name if room else None + def _read_scenes(self): scenes = self._client.read_scenes() for device in scenes: device.fibaro_controller = self - if device.room_id == 0: - room_name = "Unknown" - else: - room_name = self._room_map[device.room_id].name - device.room_name = room_name - device.friendly_name = f"{room_name} {device.name}" - device.ha_id = ( - f"scene_{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}" - ) - device.unique_id_str = ( - f"{slugify(self.hub_serial)}.scene.{device.fibaro_id}" - ) self.fibaro_devices[Platform.SCENE].append(device) - _LOGGER.debug("%s scene -> %s", device.ha_id, device) + _LOGGER.debug("Scene -> %s", device) def _read_devices(self): """Read and process the device list.""" @@ -425,7 +418,7 @@ class FibaroDevice(Entity): _attr_should_poll = False - def __init__(self, fibaro_device: DeviceModel | SceneModel) -> None: + def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the device.""" self.fibaro_device = fibaro_device self.controller = fibaro_device.fibaro_controller @@ -433,8 +426,7 @@ def __init__(self, fibaro_device: DeviceModel | SceneModel) -> None: self._attr_name = fibaro_device.friendly_name self._attr_unique_id = fibaro_device.unique_id_str - if isinstance(fibaro_device, DeviceModel): - self._attr_device_info = self.controller.get_device_info(fibaro_device) + self._attr_device_info = self.controller.get_device_info(fibaro_device) # propagate hidden attribute set in fibaro home center to HA if not fibaro_device.visible: self._attr_entity_registry_visible_default = False @@ -519,14 +511,18 @@ def extra_state_attributes(self): """Return the state attributes of the device.""" attr = {"fibaro_id": self.fibaro_device.fibaro_id} - if isinstance(self.fibaro_device, DeviceModel): - if self.fibaro_device.has_battery_level: - attr[ATTR_BATTERY_LEVEL] = self.fibaro_device.battery_level - if self.fibaro_device.has_armed: - attr[ATTR_ARMED] = self.fibaro_device.armed + if self.fibaro_device.has_battery_level: + attr[ATTR_BATTERY_LEVEL] = self.fibaro_device.battery_level + if self.fibaro_device.has_armed: + attr[ATTR_ARMED] = self.fibaro_device.armed return attr + def update(self) -> None: + """Update the available state of the entity.""" + if isinstance(self.fibaro_device, DeviceModel) and self.fibaro_device.has_dead: + self._attr_available = not self.fibaro_device.dead + class FibaroConnectFailed(HomeAssistantError): """Error to indicate we cannot connect to fibaro home center.""" diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index 14f0a6a162c31e..57b3bc99b4f402 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -82,6 +82,7 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: def update(self) -> None: """Get the latest data and update the state.""" + super().update() if self._fibaro_sensor_type == "com.fibaro.accelerometer": # Accelerator sensors have values for the three axis x, y and z moving_values = self._get_moving_values() diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index f4b1cd0c1f589e..a56056ade03727 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -263,7 +263,7 @@ def fibaro_op_mode(self) -> str | int: 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/fibaro/light.py b/homeassistant/components/fibaro/light.py index 577c661255295c..6a918f64f86b21 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -176,6 +176,7 @@ async def async_update(self) -> None: def _update(self): """Really update the state.""" + super().update() # Brightness handling if brightness_supported(self.supported_color_modes): self._attr_brightness = scaleto255(self.fibaro_device.value.int_value()) diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index 0fa1337e3e35ad..503407bc28f2bd 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -52,4 +52,5 @@ def unlock(self, **kwargs: Any) -> None: def update(self) -> None: """Update device state.""" + super().update() self._attr_is_locked = self.current_binary_state diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py index 0023b8e3fba73e..43baa0e4efd4ed 100644 --- a/homeassistant/components/fibaro/scene.py +++ b/homeassistant/components/fibaro/scene.py @@ -11,8 +11,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify -from . import FIBARO_DEVICES, FibaroDevice +from . import FIBARO_DEVICES, FibaroController from .const import DOMAIN @@ -33,18 +34,30 @@ async def async_setup_entry( ) -class FibaroScene(FibaroDevice, Scene): +class FibaroScene(Scene): """Representation of a Fibaro scene entity.""" - def __init__(self, fibaro_device: SceneModel) -> None: + def __init__(self, fibaro_scene: SceneModel) -> None: """Initialize the Fibaro scene.""" - super().__init__(fibaro_device) + self._fibaro_scene = fibaro_scene + controller: FibaroController = fibaro_scene.fibaro_controller + room_name = controller.get_room_name(fibaro_scene.room_id) + if not room_name: + room_name = "Unknown" + + self._attr_name = f"{room_name} {fibaro_scene.name}" + self._attr_unique_id = ( + f"{slugify(controller.hub_serial)}.scene.{fibaro_scene.fibaro_id}" + ) + self._attr_extra_state_attributes = {"fibaro_id": fibaro_scene.fibaro_id} + # propagate hidden attribute set in fibaro home center to HA + self._attr_entity_registry_visible_default = fibaro_scene.visible # All scenes are shown on hub device self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.controller.hub_serial)} + identifiers={(DOMAIN, controller.hub_serial)} ) def activate(self, **kwargs: Any) -> None: """Activate the scene.""" - self.fibaro_device.start() + self._fibaro_scene.start() diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index 6bb8291bbbb06f..c41c4afe312ce9 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -146,6 +146,7 @@ def __init__( def update(self) -> None: """Update the state.""" + super().update() with suppress(TypeError): self._attr_native_value = self.fibaro_device.value.float_value() @@ -170,6 +171,7 @@ def __init__( def update(self) -> None: """Update the state.""" + super().update() with suppress(KeyError, ValueError): self._attr_native_value = convert( self.fibaro_device.properties[self.entity_description.key], diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index d5c6eebeee50c2..6ca770ab2d1c5b 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -52,4 +52,5 @@ def turn_off(self, **kwargs: Any) -> None: def update(self) -> None: """Update device state.""" + super().update() self._attr_is_on = self.current_binary_state diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index 9112351ce06fa2..b7942056a2ced0 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -235,11 +235,10 @@ async def async_update(self) -> None: 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/file_upload/__init__.py b/homeassistant/components/file_upload/__init__.py index 73f8465b1df7bf..8c594f7f85caa0 100644 --- a/homeassistant/components/file_upload/__init__.py +++ b/homeassistant/components/file_upload/__init__.py @@ -17,6 +17,7 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import raise_if_invalid_filename from homeassistant.util.ulid import ulid_hex @@ -27,6 +28,8 @@ MAX_SIZE = 100 * ONE_MEGABYTE TEMP_DIR_NAME = f"home-assistant-{DOMAIN}" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @contextmanager def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Iterator[Path]: diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 6f7e31a1e67964..0b5c39f3629403 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -34,17 +34,17 @@ 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 @@ ), 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 90c286e7088800..3323c3411b26b2 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/filter/sensor.py b/homeassistant/components/filter/sensor.py index 9b1e2250a28c46..a1470baa4d2fcb 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -18,10 +18,8 @@ from homeassistant.components.recorder import get_instance, history from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - DEVICE_CLASSES as SENSOR_DEVICE_CLASSES, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, - STATE_CLASSES as SENSOR_STATE_CLASSES, SensorDeviceClass, SensorEntity, ) @@ -41,6 +39,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.util.decorator import Registry import homeassistant.util.dt as dt_util @@ -273,22 +272,15 @@ def _update_filter_sensor_state( self._state = temp_state.state - if self._attr_icon is None: - self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) + self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) + self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) + self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) - if ( - self._attr_device_class is None - and new_state.attributes.get(ATTR_DEVICE_CLASS) in SENSOR_DEVICE_CLASSES + if self._attr_native_unit_of_measurement != new_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT ): - self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) - - if ( - self._attr_state_class is None - and new_state.attributes.get(ATTR_STATE_CLASS) in SENSOR_STATE_CLASSES - ): - self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) - - if self._attr_native_unit_of_measurement is None: + for filt in self._filters: + filt.reset() self._attr_native_unit_of_measurement = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT ) @@ -360,11 +352,16 @@ async def async_added_to_hass(self) -> None: if state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, None]: self._update_filter_sensor_state(state, False) - self.async_on_remove( - async_track_state_change_event( - self.hass, [self._entity], self._update_filter_sensor_state_event + @callback + def _async_hass_started(hass: HomeAssistant) -> None: + """Delay source entity tracking.""" + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._entity], self._update_filter_sensor_state_event + ) ) - ) + + self.async_on_remove(async_at_started(self.hass, _async_hass_started)) @property def native_value(self) -> datetime | StateType: @@ -460,6 +457,10 @@ def skip_processing(self) -> bool: """Return whether the current filter_state should be skipped.""" return self._skip_processing + def reset(self) -> None: + """Reset filter.""" + self.states.clear() + def _filter_state(self, new_state: FilterState) -> FilterState: """Implement filter.""" raise NotImplementedError() diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 479e59d9cdf2ac..3b9610545448fe 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -3,7 +3,6 @@ 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.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 9e4d5b123f5a61..27f2c4a4526a69 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 @@ def __init__( """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 @@ def icon(self) -> str: 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 1484ff7f1543a1..797e39e99cdc08 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -23,24 +23,22 @@ async def async_setup_entry( async_add_entities([IncidentsSensor(client)]) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class IncidentsSensor(RestoreEntity, SensorEntity): """Representation of FireServiceRota incidents sensor.""" _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.""" @@ -57,11 +55,6 @@ def native_value(self) -> str: """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 7c60b4382641e2..7b4bd583b63435 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 49c6d577b30ea2..7409b2e53b4669 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 @@ def is_on(self) -> bool: """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 c53c01c84a75c4..11946c421730a0 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -442,14 +442,13 @@ def update(self) -> None: 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 7b0ae2e2758586..93adda2b4fd428 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 f3f253fe530e41..153732d2ce500e 100644 --- a/homeassistant/components/fivem/binary_sensor.py +++ b/homeassistant/components/fivem/binary_sensor.py @@ -10,8 +10,8 @@ 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 00000000000000..e7fa4c426dbb29 --- /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 00000000000000..53c357162760f0 --- /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 9afe5890162e09..1c4e4b77c45a97 100644 --- a/homeassistant/components/fivem/sensor.py +++ b/homeassistant/components/fivem/sensor.py @@ -7,7 +7,6 @@ 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 @@ 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 4378ef535bdd19..2ffb401f8c06dd 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 044d17662cffaf..e867e624e8ab76 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.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 @@ class UnableToConnect(HomeAssistantError): _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 @@ def detection_callback( 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 0ea5c1669dbd6d..8b641013eb4735 100644 --- a/homeassistant/components/fjaraskupan/binary_sensor.py +++ b/homeassistant/components/fjaraskupan/binary_sensor.py @@ -17,7 +17,8 @@ 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 @@ def _constructor(coordinator: Coordinator) -> list[Entity]: 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 00000000000000..16e8157b09474f --- /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 c856a94fa074b2..e19a0965524d2d 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -23,7 +23,8 @@ 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 b6028e017d4f87..f4aa8c5a2dcd9f 100644 --- a/homeassistant/components/fjaraskupan/light.py +++ b/homeassistant/components/fjaraskupan/light.py @@ -12,7 +12,8 @@ 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 @@ async def async_turn_on(self, **kwargs: Any) -> None: 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 a7f9226b57a1e9..46c5f6db90b85d 100644 --- a/homeassistant/components/fjaraskupan/number.py +++ b/homeassistant/components/fjaraskupan/number.py @@ -9,7 +9,8 @@ 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 @@ def _constructor(coordinator: Coordinator) -> list[Entity]: 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 e06790bf9acd5a..e9bf84e0ed06d8 100644 --- a/homeassistant/components/fjaraskupan/sensor.py +++ b/homeassistant/components/fjaraskupan/sensor.py @@ -16,7 +16,8 @@ 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 @@ def __init__( 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 c6d5edd02d497e..d91cc47dea137b 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/flexit/climate.py b/homeassistant/components/flexit/climate.py index ac8f4b4da8c3d1..838d2c934f9619 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -192,7 +192,7 @@ async def _async_read_temp_from_register( result = float( await self._async_read_int16_from_register(register_type, register) ) - if result == -1: + if not result: return -1 return result / 10.0 @@ -200,6 +200,6 @@ async def _async_write_int16_to_register(self, register: int, value: int) -> boo result = await self._hub.async_pymodbus_call( self._slave, register, value, CALL_TYPE_WRITE_REGISTER ) - if result == -1: + if not result: return False return True diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index 18daaa9d4e240d..a0844fe6cdb7c9 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -8,7 +8,7 @@ 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 @@ 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,14 @@ class FlickPricingSensor(SensorEntity): _attr_attribution = "Data provided by Flick Electric" _attr_native_unit_of_measurement = f"{CURRENCY_CENT}/{UnitOfEnergy.KILO_WATT_HOUR}" + _attr_has_entity_name = True + _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 cb8382539b4e95..8b55bef939e53c 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 f02911e30d5e28..9eba320672002e 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -15,7 +15,7 @@ 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 @@ def __init__( 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 646e260bd6023c..76385167d385dc 100644 --- a/homeassistant/components/flipr/binary_sensor.py +++ b/homeassistant/components/flipr/binary_sensor.py @@ -16,12 +16,12 @@ 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 570442891103de..078e581edda489 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -18,39 +18,38 @@ 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 55feaa691f72b3..24557ff177b36b 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 f5c051d1a70461..d61f67cc623029 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 39ad57f5c03339..e9d02432598781 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 2c89123dac15ba..f0aca366cfbca2 100644 --- a/homeassistant/components/flo/sensor.py +++ b/homeassistant/components/flo/sensor.py @@ -22,14 +22,6 @@ 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 @@ def native_value(self) -> float | None: 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 d6e3212b4eab64..fadfc304fce415 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 84c37bb4987ca1..cd522ed177d5f8 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 0d7b7b5dd568c6..453e259bf4668e 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -34,8 +34,7 @@ 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 f3b2bacbafe76f..17a2b0b53be873 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 b656f5e9715576..fc08fee476c5a7 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 @@ 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 @@ def native_value(self): 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 080f10deee1cfa..2c1a900c091842 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 94f50caa1a2cdd..100d63d8bf7fa7 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -14,7 +14,11 @@ 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 @@ 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 ead25930c7798d..eb3a7341d4db1d 100644 --- a/homeassistant/components/flux_led/button.py +++ b/homeassistant/components/flux_led/button.py @@ -10,7 +10,7 @@ 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 @@ _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 @@ def __init__( """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 ef9038b1435bcd..85600dd4dabf54 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 @@ def __init__( 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 202f0f95e23a28..d880d517f1ab6c 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -22,7 +22,6 @@ 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 a6e8183bcdb998..13f7ba36bcd146 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 a5324efe6804be..ac23fbe64b51f4 100644 --- a/homeassistant/components/flux_led/number.py +++ b/homeassistant/components/flux_led/number.py @@ -18,7 +18,7 @@ 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 @@ def __init__( 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 @@ def _pixels_and_segments_fit_in_music_mode(self) -> bool: 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 @@ async def _async_set_native_value(self) -> None: class FluxSegmentsNumber(FluxConfigNumber): """Defines a flux_led segments number.""" + _attr_translation_key = "segments" _attr_icon = "mdi:segment" @property @@ -245,6 +236,7 @@ def available(self) -> bool: 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 @@ async def _async_set_native_value(self) -> None: 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 2b23f695b154be..cca86e5a216ea9 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 @@ def __init__( ) -> 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 @@ async def async_select_option(self, option: str) -> None: 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 @@ async def async_select_option(self, option: str) -> None: 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 @@ async def async_select_option(self, option: str) -> None: 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 @@ def __init__( ) -> 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 3cff6d017f029a..9a19c62938300b 100644 --- a/homeassistant/components/flux_led/sensor.py +++ b/homeassistant/components/flux_led/sensor.py @@ -3,7 +3,7 @@ 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/services.yaml b/homeassistant/components/flux_led/services.yaml index b17d81f9174076..5d880370818bb2 100644 --- a/homeassistant/components/flux_led/services.yaml +++ b/homeassistant/components/flux_led/services.yaml @@ -1,4 +1,5 @@ set_custom_effect: + name: Set custom effect description: Set a custom light effect. target: entity: @@ -37,6 +38,7 @@ set_custom_effect: - "jump" - "strobe" set_zones: + name: Set zones description: Set strip zones for Addressable v3 controllers (0xA3). target: entity: @@ -78,6 +80,7 @@ set_zones: - "jump" - "breathing" set_music_mode: + name: Set music mode description: Configure music mode on Controller RGB with MIC (0x08), Addressable v2 (0xA2), and Addressable v3 (0xA3) devices that have a built-in microphone. target: entity: diff --git a/homeassistant/components/flux_led/strings.json b/homeassistant/components/flux_led/strings.json index 09d9ed399ffb51..51edd207e95073 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 d89f0020ba339f..58aee1322168bd 100644 --- a/homeassistant/components/flux_led/switch.py +++ b/homeassistant/components/flux_led/switch.py @@ -9,7 +9,7 @@ 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 @@ def __init__( ) -> 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 @@ def icon(self) -> str: 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 a517f1fea6fa03..890cd95784cc5a 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 0e47fa9701b48d..e566733413be65 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_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 ac6a3f7c3083e2..94b603e108c654 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 af9b6125713b58..00000000000000 --- 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 681e04f434f0e3..2858bff098ea04 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.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/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py index 95a418ae40faaa..d941375c8a3e17 100644 --- a/homeassistant/components/fortios/device_tracker.py +++ b/homeassistant/components/fortios/device_tracker.py @@ -43,7 +43,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> FortiOSDeviceScanner fgt = FortiOSAPI() try: - fgt.tokenlogin(host, token, verify_ssl) + fgt.tokenlogin(host, token, verify_ssl, None, 12, "root") except ConnectionError as ex: _LOGGER.error("ConnectionError to FortiOS API: %s", ex) return None @@ -77,7 +77,12 @@ def __init__(self, fgt) -> None: def update(self): """Update clients from the device.""" - clients_json = self._fgt.monitor("user/device/query", "") + clients_json = self._fgt.monitor( + "user/device/query", + "", + parameters={"filter": "format=master_mac|hostname|is_online"}, + ) + self._clients_json = clients_json self._clients = [] @@ -85,8 +90,12 @@ def update(self): if clients_json: try: for client in clients_json["results"]: - if client["is_online"]: - self._clients.append(client["mac"].upper()) + if ( + "is_online" in client + and "master_mac" in client + and client["is_online"] + ): + self._clients.append(client["master_mac"].upper()) except KeyError as kex: _LOGGER.error("Key not found in clients: %s", kex) @@ -106,17 +115,10 @@ def get_device_name(self, device): return None for client in data["results"]: - if client["mac"] == device: - try: + if "master_mac" in client and client["master_mac"] == device: + if "hostname" in client: name = client["hostname"] - _LOGGER.debug("Getting device name=%s", name) - return name - except KeyError as kex: - _LOGGER.debug( - "No hostname found for %s in client data: %s", - device, - kex, - ) - return device.replace(":", "_") - + else: + name = client["master_mac"].replace(":", "_") + return name return None diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py new file mode 100644 index 00000000000000..aabd07366b40f7 --- /dev/null +++ b/homeassistant/components/freebox/binary_sensor.py @@ -0,0 +1,100 @@ +"""Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" +from __future__ import annotations + +import logging +from typing import Any + +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.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .router import FreeboxRouter + +_LOGGER = logging.getLogger(__name__) + +RAID_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="raid_degraded", + name="degraded", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the binary sensors.""" + router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + + _LOGGER.debug("%s - %s - %s raid(s)", router.name, router.mac, len(router.raids)) + + binary_entities = [ + FreeboxRaidDegradedSensor(router, raid, description) + for raid in router.raids.values() + for description in RAID_SENSORS + ] + + if binary_entities: + async_add_entities(binary_entities, True) + + +class FreeboxRaidDegradedSensor(BinarySensorEntity): + """Representation of a Freebox raid sensor.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + router: FreeboxRouter, + raid: dict[str, Any], + description: BinarySensorEntityDescription, + ) -> None: + """Initialize a Freebox raid degraded sensor.""" + self.entity_description = description + self._router = router + self._attr_device_info = router.device_info + self._raid = raid + self._attr_name = f"Raid array {raid['id']} {description.name}" + self._attr_unique_id = ( + f"{router.mac} {description.key} {raid['name']} {raid['id']}" + ) + + @callback + def async_update_state(self) -> None: + """Update the Freebox Raid sensor.""" + self._raid = self._router.raids[self._raid["id"]] + + @property + def is_on(self) -> bool: + """Return true if degraded.""" + return self._raid["degraded"] + + @callback + def async_on_demand_update(self): + """Update state.""" + self.async_update_state() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register state update callback.""" + self.async_update_state() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._router.signal_sensor_update, + self.async_on_demand_update, + ) + ) diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 767cb94de48afc..5a7c7863b4e0b8 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -20,6 +20,7 @@ Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, + Platform.BINARY_SENSOR, Platform.SWITCH, Platform.CAMERA, ] diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/home_base.py index f10a9e047f73ac..c74f072a5be412 100644 --- a/homeassistant/components/freebox/home_base.py +++ b/homeassistant/components/freebox/home_base.py @@ -30,8 +30,8 @@ def __init__( self._node = node self._sub_node = sub_node self._id = node["id"] - self._device_name = node["label"].strip() - self._attr_name = self._device_name + self._attr_name = node["label"].strip() + self._device_name = self._attr_name self._attr_unique_id = f"{self._router.mac}-node_{self._id}" if sub_node is not None: diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 5622da48e673a0..4a9c22847aebfe 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -72,6 +72,7 @@ def __init__( self.devices: dict[str, dict[str, Any]] = {} self.disks: dict[int, dict[str, Any]] = {} + self.raids: dict[int, dict[str, Any]] = {} self.sensors_temperature: dict[str, int] = {} self.sensors_connection: dict[str, float] = {} self.call_list: list[dict[str, Any]] = [] @@ -145,6 +146,8 @@ async def update_sensors(self) -> None: await self._update_disks_sensors() + await self._update_raids_sensors() + async_dispatcher_send(self.hass, self.signal_sensor_update) async def _update_disks_sensors(self) -> None: @@ -155,6 +158,14 @@ async def _update_disks_sensors(self) -> None: for fbx_disk in fbx_disks: self.disks[fbx_disk["id"]] = fbx_disk + async def _update_raids_sensors(self) -> None: + """Update Freebox raids.""" + # None at first request + fbx_raids: list[dict[str, Any]] = await self._api.storage.get_raids() or [] + + for fbx_raid in fbx_raids: + self.raids[fbx_raid["id"]] = fbx_raid + async def update_home_devices(self) -> None: """Update Home devices (alarm, light, sensor, switch, remote ...).""" if not self.home_granted: diff --git a/homeassistant/components/freedompro/__init__.py b/homeassistant/components/freedompro/__init__.py index 5e1f8e0b577d2b..78871bc99bfb75 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 c56d3cb2ad882a..9f397d32899f4e 100644 --- a/homeassistant/components/freedompro/binary_sensor.py +++ b/homeassistant/components/freedompro/binary_sensor.py @@ -11,8 +11,8 @@ 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 @@ def __init__( }, 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 0ec08f0fdd0487..8a0a706c0d93d7 100644 --- a/homeassistant/components/freedompro/climate.py +++ b/homeassistant/components/freedompro/climate.py @@ -22,8 +22,8 @@ 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 @@ def __init__( 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 @@ def __init__( }, 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 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: 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 00000000000000..c896f5ec203406 --- /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 3839415d31b8a0..59e58d75c43b8e 100644 --- a/homeassistant/components/freedompro/cover.py +++ b/homeassistant/components/freedompro/cover.py @@ -18,8 +18,8 @@ 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 @@ def __init__( 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 @@ def __init__( }, 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 036c6c91471f3c..68149b65fd73bd 100644 --- a/homeassistant/components/freedompro/fan.py +++ b/homeassistant/components/freedompro/fan.py @@ -15,8 +15,8 @@ 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 @@ def __init__( 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 @@ def __init__( }, 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 7dc573f922517d..2a101d5c82a762 100644 --- a/homeassistant/components/freedompro/light.py +++ b/homeassistant/components/freedompro/light.py @@ -20,8 +20,8 @@ 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 @@ def __init__( 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 d803354c255663..e1e8ee44b2dbea 100644 --- a/homeassistant/components/freedompro/lock.py +++ b/homeassistant/components/freedompro/lock.py @@ -13,8 +13,8 @@ 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 @@ def __init__( 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 @@ def __init__( }, 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 286a528013ac53..85d70c3095658c 100644 --- a/homeassistant/components/freedompro/sensor.py +++ b/homeassistant/components/freedompro/sensor.py @@ -13,8 +13,8 @@ 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 @@ def __init__( }, 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 4a7ed80de1e29a..97f0a968cff4f2 100644 --- a/homeassistant/components/freedompro/switch.py +++ b/homeassistant/components/freedompro/switch.py @@ -13,8 +13,8 @@ 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 @@ def __init__( 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 @@ def __init__( }, 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 f732e32b75ab9c..d76279a0f14b5a 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -46,7 +46,6 @@ class FritzButtonDescription(ButtonEntityDescription, FritzButtonDescriptionMixi ), 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 d43ba2eda6219f..1ce21081f9c8b5 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -31,6 +31,7 @@ class MeshRoles(StrEnum): 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 e32ee1527969dc..d4ba53aa6a2664 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -21,9 +21,6 @@ _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 00000000000000..597dd8ddb5390f --- /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 b117218e23dec9..54419d5ae3fddf 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/services.yaml b/homeassistant/components/fritz/services.yaml index 3c7ed6438417a6..95527257ea9c48 100644 --- a/homeassistant/components/fritz/services.yaml +++ b/homeassistant/components/fritz/services.yaml @@ -1,4 +1,5 @@ reconnect: + name: Reconnect description: Reconnects your FRITZ!Box internet connection fields: device_id: @@ -11,6 +12,7 @@ reconnect: entity: device_class: connectivity reboot: + name: Reboot description: Reboots your FRITZ!Box fields: device_id: @@ -24,6 +26,7 @@ reboot: device_class: connectivity cleanup: + name: Remove stale device tracker entities description: Remove FRITZ!Box stale device_tracker entities fields: device_id: diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 45262d6f8ac1d7..fcaa56424f1b65 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/fritz/switch.py b/homeassistant/components/fritz/switch.py index 5b8c40485306cf..1352d9cb42e08e 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -518,7 +518,6 @@ def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None: default_manufacturer="AVM", default_model="FRITZ!Box Tracked device", default_name=device.hostname, - identifiers={(DOMAIN, self._mac)}, via_device=( DOMAIN, avm_wrapper.unique_id, diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index f87beb34079c9e..dc56bc0473e589 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -42,8 +42,8 @@ class 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 31cdac47ec2ce7..7c84678963772e 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -101,7 +101,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: 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 7922224e195feb..46fa1a265611aa 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 @@ def value_scheduled_preset(device: FritzhomeDevice) -> str: 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 @@ def value_scheduled_preset(device: FritzhomeDevice) -> str: 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 @@ def value_scheduled_preset(device: FritzhomeDevice) -> str: 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 0b4becd6ff7e49..d5607aa3090078 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 cde955caa1e6cc..d445c12e4dab4f 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 7120530c9736db..ecf3f81b38043f 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 4e1e0a74fe9ba7..07c5585833dd20 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230503.3"] + "requirements": ["home-assistant-frontend==20230705.1"] } diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 5179b02bbc3c70..2274b1cdb4499c 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -16,7 +16,7 @@ from homeassistant import config_entries from homeassistant.components import ssdp -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.data_entry_flow import FlowResult from .const import ( @@ -61,42 +61,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _webfsapi_url: str _reauth_entry: config_entries.ConfigEntry | None = None # Only used in reauth flows - async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: - """Handle the import of legacy configuration.yaml entries.""" - - device_url = f"http://{import_info[CONF_HOST]}:{import_info[CONF_PORT]}/device" - try: - webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) - except FSConnectionError: - return self.async_abort(reason="cannot_connect") - except Exception as exception: # pylint: disable=broad-except - _LOGGER.exception(exception) - return self.async_abort(reason="unknown") - - afsapi = AFSAPI(webfsapi_url, import_info[CONF_PIN]) - try: - unique_id = await afsapi.get_radio_id() - except NotImplementedException: - unique_id = None # Not all radios have this call implemented - except FSConnectionError: - return self.async_abort(reason="cannot_connect") - except InvalidPinException: - return self.async_abort(reason="invalid_auth") - except Exception as exception: # pylint: disable=broad-except - _LOGGER.exception(exception) - return self.async_abort(reason="unknown") - - await self.async_set_unique_id(unique_id, raise_on_progress=False) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=import_info[CONF_NAME] or "Radio", - data={ - CONF_WEBFSAPI_URL: webfsapi_url, - CONF_PIN: import_info[CONF_PIN], - }, - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 54c17429b56e43..04b689ae917ccd 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -10,10 +10,8 @@ NotImplementedException as FSNotImplementedException, PlayState, ) -import voluptuous as vol from homeassistant.components.media_player import ( - PLATFORM_SCHEMA, BrowseError, BrowseMedia, MediaPlayerEntity, @@ -21,62 +19,16 @@ MediaPlayerState, MediaType, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .browse_media import browse_node, browse_top_level -from .const import CONF_PIN, DEFAULT_PIN, DEFAULT_PORT, DOMAIN, MEDIA_CONTENT_ID_PRESET +from .const import DOMAIN, MEDIA_CONTENT_ID_PRESET _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_PASSWORD, default=DEFAULT_PIN): cv.string, - vol.Optional(CONF_NAME): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Frontier Silicon platform. - - YAML is deprecated, and imported automatically. - """ - - ir.async_create_issue( - hass, - DOMAIN, - "remove_yaml", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - - await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_NAME: config.get(CONF_NAME), - CONF_HOST: config.get(CONF_HOST), - CONF_PORT: config.get(CONF_PORT, DEFAULT_PORT), - CONF_PIN: config.get(CONF_PASSWORD, DEFAULT_PIN), - }, - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index 193ca7123f4563..a10c3f535a1053 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -30,11 +30,5 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } - }, - "issues": { - "removed_yaml": { - "title": "The Frontier Silicon YAML configuration has been removed", - "description": "Configuring Frontier Silicon using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/fully_kiosk/__init__.py b/homeassistant/components/fully_kiosk/__init__.py index e417d7c0bcb0f9..8b350433858db7 100644 --- a/homeassistant/components/fully_kiosk/__init__.py +++ b/homeassistant/components/fully_kiosk/__init__.py @@ -2,6 +2,8 @@ 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 from .coordinator import FullyKioskDataUpdateCoordinator @@ -16,6 +18,16 @@ 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.""" + + await async_setup_services(hass) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Fully Kiosk Browser from a config entry.""" @@ -26,8 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - await async_setup_services(hass) + coordinator.async_update_listeners() return True diff --git a/homeassistant/components/fully_kiosk/binary_sensor.py b/homeassistant/components/fully_kiosk/binary_sensor.py index f7371f4caedd00..5eebf8a77ab3d2 100644 --- a/homeassistant/components/fully_kiosk/binary_sensor.py +++ b/homeassistant/components/fully_kiosk/binary_sensor.py @@ -18,18 +18,18 @@ 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 65b44262c8b65d..9f4d60e9574803 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 b4fe90e01eba5a..3db33d21ef0ed5 100644 --- a/homeassistant/components/fully_kiosk/const.py +++ b/homeassistant/components/fully_kiosk/const.py @@ -25,6 +25,9 @@ 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 0e4329a5917877..d1f98c5afff680 100644 --- a/homeassistant/components/fully_kiosk/entity.py +++ b/homeassistant/components/fully_kiosk/entity.py @@ -9,6 +9,18 @@ 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 @@ def __init__(self, coordinator: FullyKioskDataUpdateCoordinator) -> None: 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 8c73d47dd74368..0984d6a220ff7e 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 11f5bd2745298d..298a58e2a112ad 100644 --- a/homeassistant/components/fully_kiosk/number.py +++ b/homeassistant/components/fully_kiosk/number.py @@ -16,7 +16,7 @@ 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 @@ ), 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 @@ ), 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 @@ ), 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 60009eb6ae4dce..dd775e7d55a8f1 100644 --- a/homeassistant/components/fully_kiosk/sensor.py +++ b/homeassistant/components/fully_kiosk/sensor.py @@ -3,6 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -12,7 +13,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -26,72 +27,86 @@ def round_storage(value: int) -> float: return round(value * 0.000001, 1) +def truncate_url(value: StateType) -> tuple[StateType, dict[str, Any]]: + """Truncate URL if longer than 256.""" + url = str(value) + truncated = len(url) > 256 + extra_state_attributes = { + "full_url": url, + "truncated": truncated, + } + if truncated: + return (url[0:255], extra_state_attributes) + return (url, extra_state_attributes) + + @dataclass class FullySensorEntityDescription(SensorEntityDescription): """Fully Kiosk Browser sensor description.""" - state_fn: Callable[[int], float] | None = None + round_state_value: bool = False + state_fn: Callable[[StateType], tuple[StateType, dict[str, Any]]] | None = None SENSORS: tuple[FullySensorEntityDescription, ...] = ( FullySensorEntityDescription( key="batteryLevel", - name="Battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), FullySensorEntityDescription( - key="screenOrientation", - name="Screen orientation", + key="currentPage", + translation_key="current_page", entity_category=EntityCategory.DIAGNOSTIC, + state_fn=truncate_url, ), FullySensorEntityDescription( - key="foregroundApp", - name="Foreground app", + key="screenOrientation", + translation_key="screen_orientation", entity_category=EntityCategory.DIAGNOSTIC, ), FullySensorEntityDescription( - key="currentPage", - name="Current page", + key="foregroundApp", + 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, state_class=SensorStateClass.MEASUREMENT, - state_fn=round_storage, + round_state_value=True, ), 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, state_class=SensorStateClass.MEASUREMENT, - state_fn=round_storage, + round_state_value=True, ), 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, state_class=SensorStateClass.MEASUREMENT, - state_fn=round_storage, + round_state_value=True, ), 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, state_class=SensorStateClass.MEASUREMENT, - state_fn=round_storage, + round_state_value=True, ), ) @@ -129,13 +144,19 @@ def __init__( super().__init__(coordinator) - @property - def native_value(self) -> StateType: - """Return the state of the sensor.""" - if (value := self.coordinator.data.get(self.entity_description.key)) is None: - return None + @callback + def _handle_coordinator_update(self) -> None: + extra_state_attributes: dict[str, Any] = {} + value = self.coordinator.data.get(self.entity_description.key) + + if value is not None: + if self.entity_description.state_fn is not None: + value, extra_state_attributes = self.entity_description.state_fn(value) + + if self.entity_description.round_state_value: + value = round_storage(value) - if self.entity_description.state_fn is not None: - return self.entity_description.state_fn(value) + self._attr_native_value = value + self._attr_extra_state_attributes = extra_state_attributes - return value # type: ignore[no-any-return] + self.async_write_ha_state() diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py index 3fca92287359e2..5106fd2e06eed3 100644 --- a/homeassistant/components/fully_kiosk/services.py +++ b/homeassistant/components/fully_kiosk/services.py @@ -1,74 +1,85 @@ """Services for the Fully Kiosk Browser integration.""" from __future__ import annotations -from collections.abc import Callable -from typing import Any - -from fullykiosk import FullyKiosk import voluptuous as vol +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr from .const import ( ATTR_APPLICATION, + ATTR_KEY, ATTR_URL, + ATTR_VALUE, DOMAIN, - LOGGER, SERVICE_LOAD_URL, + SERVICE_SET_CONFIG, SERVICE_START_APPLICATION, ) +from .coordinator import FullyKioskDataUpdateCoordinator async def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the Fully Kiosk Browser integration.""" - async def execute_service( - call: ServiceCall, - fully_method: Callable, - *args: list[str], - **kwargs: dict[str, Any], - ) -> None: - """Execute a Fully service call. - - :param call: {ServiceCall} HA service call. - :param fully_method: {Callable} A method of the FullyKiosk class. - :param args: Arguments for fully_method. - :param kwargs: Key-word arguments for fully_method. - :return: None - """ - LOGGER.debug( - "Calling Fully service %s with args: %s, %s", ServiceCall, args, kwargs - ) + async def collect_coordinators( + device_ids: list[str], + ) -> list[FullyKioskDataUpdateCoordinator]: + config_entries = list[ConfigEntry]() registry = dr.async_get(hass) - for target in call.data[ATTR_DEVICE_ID]: + for target in device_ids: device = registry.async_get(target) if device: - for key in device.config_entries: - entry = hass.config_entries.async_get_entry(key) - if not entry: - continue - if entry.domain != DOMAIN: - continue - coordinator = hass.data[DOMAIN][key] - # fully_method(coordinator.fully, *args, **kwargs) would make - # test_services.py fail. - await getattr(coordinator.fully, fully_method.__name__)( - *args, **kwargs + device_entries = list[ConfigEntry]() + for entry_id in device.config_entries: + entry = hass.config_entries.async_get_entry(entry_id) + if entry and entry.domain == DOMAIN: + device_entries.append(entry) + if not device_entries: + raise HomeAssistantError( + f"Device '{target}' is not a {DOMAIN} device" ) - break + config_entries.extend(device_entries) + else: + raise HomeAssistantError( + f"Device '{target}' not found in device registry" + ) + coordinators = list[FullyKioskDataUpdateCoordinator]() + for config_entry in config_entries: + if config_entry.state != ConfigEntryState.LOADED: + raise HomeAssistantError(f"{config_entry.title} is not loaded") + coordinators.append(hass.data[DOMAIN][config_entry.entry_id]) + return coordinators async def async_load_url(call: ServiceCall) -> None: """Load a URL on the Fully Kiosk Browser.""" - await execute_service(call, FullyKiosk.loadUrl, call.data[ATTR_URL]) + for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + await coordinator.fully.loadUrl(call.data[ATTR_URL]) async def async_start_app(call: ServiceCall) -> None: """Start an app on the device.""" - await execute_service( - call, FullyKiosk.startApplication, call.data[ATTR_APPLICATION] - ) + 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 = [ @@ -89,3 +100,18 @@ async def async_start_app(call: ServiceCall) -> 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 88178e35809a0d..1f75e4a0347368 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 a6442085683bea..c10b6162859a08 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 1bd2a10fd21fa1..500e154abd8339 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 @@ class 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 @@ class 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 @@ class 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 86904e3e9bc427..b6fb3d8cee33ed 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/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 6563e26368ab70..e1535037d35375 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DEFAULT_ICON, DOMAIN, FEED @@ -93,10 +93,12 @@ def _update_from_status_info(self, status_info): """Update the internal state from the provided information.""" self._status = status_info.status self._last_update = ( - dt.as_utc(status_info.last_update) if status_info.last_update else None + dt_util.as_utc(status_info.last_update) if status_info.last_update else None ) if status_info.last_update_successful: - self._last_update_successful = dt.as_utc(status_info.last_update_successful) + self._last_update_successful = dt_util.as_utc( + status_info.last_update_successful + ) else: self._last_update_successful = None self._last_timestamp = status_info.last_timestamp diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index b039b32d73d959..234795e9014c31 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -200,7 +200,7 @@ async def async_camera_image( 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 94a885a7c5d31b..34fc571327111c 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -9,10 +9,10 @@ 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 @@ async def async_step_init( # 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 693959561d2f14..134ce00ef70846 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/generic", "iot_class": "local_push", - "requirements": ["ha-av==10.0.0", "pillow==9.5.0"] + "requirements": ["ha-av==10.1.0", "Pillow==9.5.0"] } diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index a6e76330f29b31..959b0a8e8dfcdd 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -9,6 +9,7 @@ MODE_AWAY, MODE_NORMAL, PLATFORM_SCHEMA, + HumidifierAction, HumidifierDeviceClass, HumidifierEntity, HumidifierEntityFeature, @@ -158,6 +159,7 @@ def __init__( self._is_away = False if not self._device_class: self._device_class = HumidifierDeviceClass.HUMIDIFIER + self._attr_action = HumidifierAction.IDLE async def async_added_to_hass(self): """Run when entity about to be added.""" @@ -248,6 +250,11 @@ def is_on(self): """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.""" @@ -356,6 +363,15 @@ def _async_switch_changed(self, entity_id, old_state, new_state): """Handle humidifier switch state changes.""" if new_state is None: return + + if new_state.state == STATE_ON: + if self._device_class == HumidifierDeviceClass.DEHUMIDIFIER: + self._attr_action = HumidifierAction.DRYING + else: + self._attr_action = HumidifierAction.HUMIDIFYING + else: + self._attr_action = HumidifierAction.IDLE + self.async_schedule_update_ha_state() async def _async_update_humidity(self, humidity): @@ -430,17 +446,14 @@ async def _async_operate(self, time=None, force=False): 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 22a3f98a9f0525..e3eed8866c8d58 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -494,16 +494,15 @@ async def _async_control_heating(self, time=None, force=False): 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 c2b32582cef56a..bafda44501b7d1 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -66,17 +66,17 @@ def icon(self) -> str: 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 def8f77994eff7..b922d98f25e1e2 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 b02339eb20acb6..9f77f9b112e177 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 3ed5418fa0f7b0..bdf8f1266802a2 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 134877d7509bfb..d1be775e3706e5 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 @@ class 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 @@ class 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 7c8547805d13a3..6dc2fe8ec1ca66 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 74ca640678291b..9ed59b2bc9749b 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_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index 8fb2ff8535b445..e69ba6eb005c64 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DOMAIN, FEED @@ -94,10 +94,12 @@ def _update_from_status_info(self, status_info): """Update the internal state from the provided information.""" self._status = status_info.status self._last_update = ( - dt.as_utc(status_info.last_update) if status_info.last_update else None + dt_util.as_utc(status_info.last_update) if status_info.last_update else None ) if status_info.last_update_successful: - self._last_update_successful = dt.as_utc(status_info.last_update_successful) + self._last_update_successful = dt_util.as_utc( + status_info.last_update_successful + ) else: self._last_update_successful = None self._last_timestamp = status_info.last_timestamp diff --git a/homeassistant/components/geonetnz_volcano/manifest.json b/homeassistant/components/geonetnz_volcano/manifest.json index c6cffad477d214..6e9503e0243aa2 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/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index 33a879eeb255bb..583b75a24eb309 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import DistanceConverter from .const import ( @@ -124,9 +124,9 @@ def _update_from_feed(self, feed_entry, last_update, last_update_successful): self._alert_level = feed_entry.alert_level self._activity = feed_entry.activity self._hazards = feed_entry.hazards - self._feed_last_update = dt.as_utc(last_update) if last_update else None + self._feed_last_update = dt_util.as_utc(last_update) if last_update else None self._feed_last_update_successful = ( - dt.as_utc(last_update_successful) if last_update_successful else None + dt_util.as_utc(last_update_successful) if last_update_successful else None ) @property diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index f078cc074e9c4c..641194362308ec 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -91,7 +91,6 @@ class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKey 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 @@ class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKey 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 @@ class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKey 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 @@ class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKey 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 @@ class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKey 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 5387c043fc3f52..ee0f50ef40cb53 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 af6e5e2ca4ac64..edcdd8c057b85a 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 @@ class GitHubSensorEntityDescription(BaseEntityDescription, BaseEntityDescription ), 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 @@ class GitHubSensorEntityDescription(BaseEntityDescription, BaseEntityDescription ), 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 @@ class GitHubSensorEntityDescription(BaseEntityDescription, BaseEntityDescription ), 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 @@ class GitHubSensorEntityDescription(BaseEntityDescription, BaseEntityDescription ), 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 @@ class GitHubSensorEntityDescription(BaseEntityDescription, BaseEntityDescription ), 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 @@ class GitHubSensorEntityDescription(BaseEntityDescription, BaseEntityDescription ), 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 @@ class GitHubSensorEntityDescription(BaseEntityDescription, BaseEntityDescription ), 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 @@ class GitHubSensorEntityDescription(BaseEntityDescription, BaseEntityDescription ), 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 @@ class GitHubSensorEntityDescription(BaseEntityDescription, BaseEntityDescription ), 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 @@ class GitHubSensorEntityDescription(BaseEntityDescription, BaseEntityDescription ), 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 @@ class GitHubSensorEntityDescription(BaseEntityDescription, BaseEntityDescription ), 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 fa981d3dcb5d1b..7b7ae91b9fd6ef 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/const.py b/homeassistant/components/glances/const.py index b704ab326f41be..37da60bdea80ec 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -1,5 +1,6 @@ """Constants for Glances component.""" +from datetime import timedelta import sys DOMAIN = "glances" @@ -8,7 +9,7 @@ DEFAULT_HOST = "localhost" DEFAULT_PORT = 61208 DEFAULT_VERSION = 3 -DEFAULT_SCAN_INTERVAL = 60 +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) SUPPORTED_VERSIONS = [2, 3] diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 01e498a88979af..24a2e23a013f75 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -1,5 +1,4 @@ """Coordinator for Glances integration.""" -from datetime import timedelta import logging from typing import Any @@ -10,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -30,7 +29,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: Glances) -> Non hass, _LOGGER, name=f"{DOMAIN} - {self.host}", - update_interval=timedelta(seconds=60), + update_interval=DEFAULT_SCAN_INTERVAL, ) async def _async_update_data(self) -> dict[str, Any]: diff --git a/homeassistant/components/glances/manifest.json b/homeassistant/components/glances/manifest.json index 767a27ffdfdc1d..d90f7b8274cbe5 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/glances/sensor.py b/homeassistant/components/glances/sensor.py index 8b836fba3eaa38..e952164792fe74 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -223,17 +223,17 @@ class GlancesSensorEntityDescription( icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - ("raid", "used"): GlancesSensorEntityDescription( - key="used", + ("raid", "available"): GlancesSensorEntityDescription( + key="available", type="raid", - name_suffix="Raid used", + name_suffix="Raid available", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - ("raid", "available"): GlancesSensorEntityDescription( - key="available", + ("raid", "used"): GlancesSensorEntityDescription( + key="used", type="raid", - name_suffix="Raid available", + name_suffix="Raid used", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), @@ -269,36 +269,36 @@ def _migrate_old_unique_ids( if sensor_type in ["fs", "sensors", "raid"]: for sensor_label, params in sensors.items(): for param in params: - sensor_description = SENSOR_TYPES[(sensor_type, param)] + if sensor_description := SENSOR_TYPES.get((sensor_type, param)): + _migrate_old_unique_ids( + hass, + f"{coordinator.host}-{name} {sensor_label} {sensor_description.name_suffix}", + f"{sensor_label}-{sensor_description.key}", + ) + entities.append( + GlancesSensor( + coordinator, + name, + sensor_label, + sensor_description, + ) + ) + else: + for sensor in sensors: + if sensor_description := SENSOR_TYPES.get((sensor_type, sensor)): _migrate_old_unique_ids( hass, - f"{coordinator.host}-{name} {sensor_label} {sensor_description.name_suffix}", - f"{sensor_label}-{sensor_description.key}", + f"{coordinator.host}-{name} {sensor_description.name_suffix}", + f"-{sensor_description.key}", ) entities.append( GlancesSensor( coordinator, name, - sensor_label, + "", sensor_description, ) ) - else: - for sensor in sensors: - sensor_description = SENSOR_TYPES[(sensor_type, sensor)] - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {sensor_description.name_suffix}", - f"-{sensor_description.key}", - ) - entities.append( - GlancesSensor( - coordinator, - name, - "", - sensor_description, - ) - ) async_add_entities(entities) @@ -328,6 +328,18 @@ def __init__( ) self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{sensor_name_prefix}-{description.key}" + @property + def available(self) -> bool: + """Set sensor unavailable when native value is invalid.""" + if super().available: + return ( + not self._numeric_state_expected + or isinstance(value := self.native_value, (int, float)) + or isinstance(value, str) + and value.isnumeric() + ) + return False + @property def native_value(self) -> StateType: """Return the state of the resources.""" diff --git a/homeassistant/components/goalfeed/__init__.py b/homeassistant/components/goalfeed/__init__.py deleted file mode 100644 index f452b858e799aa..00000000000000 --- a/homeassistant/components/goalfeed/__init__.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Component for the Goalfeed service.""" -import json - -import pysher -import requests -import voluptuous as vol - -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType - -# Version downgraded due to regression in library -# For details: https://github.com/nlsdfnbch/Pysher/issues/38 -DOMAIN = "goalfeed" - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - -GOALFEED_HOST = "feed.goalfeed.ca" -GOALFEED_AUTH_ENDPOINT = "https://goalfeed.ca/feed/auth" -GOALFEED_APP_ID = "bfd4ed98c1ff22c04074" - - -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Goalfeed component.""" - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - - def goal_handler(data): - """Handle goal events.""" - goal = json.loads(json.loads(data)) - - hass.bus.fire("goal", event_data=goal) - - def connect_handler(data): - """Handle connection.""" - post_data = { - "username": username, - "password": password, - "connection_info": data, - } - resp = requests.post(GOALFEED_AUTH_ENDPOINT, post_data, timeout=30).json() - - channel = pusher.subscribe("private-goals", resp["auth"]) - channel.bind("goal", goal_handler) - - pusher = pysher.Pusher( - GOALFEED_APP_ID, secure=False, port=8080, custom_host=GOALFEED_HOST - ) - - pusher.connection.bind("pusher:connection_established", connect_handler) - pusher.connect() - - return True diff --git a/homeassistant/components/goalfeed/manifest.json b/homeassistant/components/goalfeed/manifest.json deleted file mode 100644 index 077596b01853f8..00000000000000 --- a/homeassistant/components/goalfeed/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "goalfeed", - "name": "Goalfeed", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/goalfeed", - "iot_class": "cloud_push", - "loggers": ["pysher"], - "requirements": ["pysher==1.0.7"] -} diff --git a/homeassistant/components/goalzero/manifest.json b/homeassistant/components/goalzero/manifest.json index 88bcdd4987b9c8..f1bfc7de876748 100644 --- a/homeassistant/components/goalzero/manifest.json +++ b/homeassistant/components/goalzero/manifest.json @@ -16,5 +16,5 @@ "iot_class": "local_polling", "loggers": ["goalzero"], "quality_scale": "silver", - "requirements": ["goalzero==0.2.1"] + "requirements": ["goalzero==0.2.2"] } diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 61ec45a98f9d02..9001824d678d80 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -72,7 +72,7 @@ name="Wh stored", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="volts", diff --git a/homeassistant/components/gogogate2/manifest.json b/homeassistant/components/gogogate2/manifest.json index ec2834d00d8818..faebcf7e35328a 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/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index d76d6202832928..4a4296bc526187 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -243,7 +243,7 @@ def async_reset(self, now): In contrast to "total" sensors, these "daily" sensors need to be reset to 0 on midnight. """ if not self.coordinator.last_update_success: - self.coordinator.reset_sensor(self._sensor.id) + self.coordinator.reset_sensor(self._sensor.id_) self.async_write_ha_state() _LOGGER.debug("Goodwe reset %s to 0", self.name) next_midnight = dt_util.start_of_local_day( diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index 47aa32dcd11cde..a3a5b7246b6160 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -24,7 +24,7 @@ async_track_point_in_utc_time, async_track_time_interval, ) -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( CONF_CALENDAR_ACCESS, @@ -51,7 +51,9 @@ class DeviceAuth(AuthImplementation): async def async_resolve_external_data(self, external_data: Any) -> dict: """Resolve a Google API Credentials object to Home Assistant token.""" creds: Credentials = external_data[DEVICE_AUTH_CREDS] - delta = creds.token_expiry.replace(tzinfo=datetime.timezone.utc) - dt.utcnow() + delta = ( + creds.token_expiry.replace(tzinfo=datetime.timezone.utc) - dt_util.utcnow() + ) _LOGGER.debug( "Token expires at %s (in %s)", creds.token_expiry, delta.total_seconds() ) @@ -108,7 +110,9 @@ def creds(self) -> Credentials | None: def async_start_exchange(self) -> None: """Start the device auth exchange flow polling.""" _LOGGER.debug("Starting exchange flow") - max_timeout = dt.utcnow() + datetime.timedelta(seconds=EXCHANGE_TIMEOUT_SECONDS) + max_timeout = dt_util.utcnow() + datetime.timedelta( + seconds=EXCHANGE_TIMEOUT_SECONDS + ) # For some reason, oauth.step1_get_device_and_user_codes() returns a datetime # object without tzinfo. For the comparison below to work, it needs one. user_code_expiry = self._device_flow_info.user_code_expiry.replace( diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 3a0315a59312d7..37f3a2c3edc282 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -28,10 +28,10 @@ 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 bf511f8eaebb95..6ec8ca5d747fba 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -170,6 +170,7 @@ (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, @@ -186,7 +187,7 @@ SOURCE_CLOUD = "cloud" SOURCE_LOCAL = "local" -NOT_EXPOSE_LOCAL = {TYPE_ALARM, TYPE_LOCK} +NOT_EXPOSE_LOCAL = {TYPE_ALARM, TYPE_LOCK, TYPE_THERMOSTAT} FAN_SPEEDS = { "5/5": ["High", "Max", "Fast", "5"], diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index e194242df91ac3..49d130d665692c 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -21,7 +21,7 @@ CONF_NAME, STATE_UNAVAILABLE, ) -from homeassistant.core import Context, HomeAssistant, State, callback +from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, State, callback from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -86,19 +86,19 @@ def _get_registry_entries( class AbstractConfig(ABC): """Hold the configuration for Google Assistant.""" + _store: GoogleConfigStore _unsub_report_state: Callable[[], None] | None = None - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize abstract config.""" self.hass = hass - self._store = None - self._google_sync_unsub = {} + self._google_sync_unsub: dict[str, CALLBACK_TYPE] = {} self._local_sdk_active = False self._local_last_active: datetime | None = None self._local_sdk_version_warn = False self.is_supported_cache: dict[str, tuple[int | None, bool]] = {} - async def async_initialize(self): + async def async_initialize(self) -> None: """Perform async initialization of config.""" self._store = GoogleConfigStore(self.hass) await self._store.async_initialize() @@ -195,7 +195,7 @@ async def async_report_state_all(self, message): await gather(*jobs) @callback - def async_enable_report_state(self): + def async_enable_report_state(self) -> None: """Enable proactive mode.""" # Circular dep # pylint: disable-next=import-outside-toplevel @@ -205,7 +205,7 @@ def async_enable_report_state(self): self._unsub_report_state = async_enable_report_state(self.hass, self) @callback - def async_disable_report_state(self): + def async_disable_report_state(self) -> None: """Disable report state.""" if self._unsub_report_state is not None: self._unsub_report_state() @@ -220,7 +220,7 @@ async def async_sync_entities(self, agent_user_id: str): await self.async_disconnect_agent_user(agent_user_id) return status - async def async_sync_entities_all(self): + async def async_sync_entities_all(self) -> int: """Sync all entities to Google for all registered agents.""" if not self._store.agent_user_ids: return 204 @@ -249,7 +249,7 @@ async def _schedule_callback(_now): ) @callback - def async_schedule_google_sync_all(self): + def async_schedule_google_sync_all(self) -> None: """Schedule a sync for all registered agents.""" for agent_user_id in self._store.agent_user_ids: self.async_schedule_google_sync(agent_user_id) @@ -279,7 +279,7 @@ async def async_disconnect_agent_user(self, agent_user_id: str): self._store.pop_agent_user_id(agent_user_id) @callback - def async_enable_local_sdk(self): + def async_enable_local_sdk(self) -> None: """Enable the local SDK.""" setup_successful = True setup_webhook_ids = [] @@ -323,7 +323,7 @@ def async_enable_local_sdk(self): self._local_sdk_active = setup_successful @callback - def async_disable_local_sdk(self): + def async_disable_local_sdk(self) -> None: """Disable the local SDK.""" if not self._local_sdk_active: return @@ -500,7 +500,7 @@ def __init__( self.hass = hass self.config = config self.state = state - self._traits = None + self._traits: list[trait._Trait] | None = None @property def entity_id(self): @@ -508,7 +508,7 @@ def entity_id(self): return self.state.entity_id @callback - def traits(self): + def traits(self) -> list[trait._Trait]: """Return traits for entity.""" if self._traits is not None: return self._traits diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 1b1b443baac001..b8c57812540cfe 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 3752574f31f138..36660820efbc2c 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1,6 +1,7 @@ """Implement the Google Smart Home traits.""" from __future__ import annotations +from abc import ABC, abstractmethod import logging from typing import Any, TypeVar @@ -67,7 +68,7 @@ ) from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.helpers.network import get_url -from homeassistant.util import color as color_util, dt +from homeassistant.util import color as color_util, dt as dt_util from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -196,9 +197,10 @@ def _next_selected(items: list[str], selected: str | None) -> str | None: return items[next_item] -class _Trait: +class _Trait(ABC): """Represents a Trait inside Google Assistant skill.""" + name: str commands: list[str] = [] @staticmethod @@ -206,6 +208,11 @@ def might_2fa(domain, features, device_class): """Return if the trait might ask for 2FA.""" return False + @staticmethod + @abstractmethod + def supported(domain, features, device_class, attributes): + """Test if state is supported.""" + def __init__(self, hass, state, config): """Initialize a trait for a state.""" self.hass = hass @@ -915,9 +922,28 @@ def climate_google_modes(self): 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 @@ -982,24 +1008,22 @@ def query_attributes(self): ), 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 @@ -1190,9 +1214,12 @@ def query_attributes(self): 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 @@ -2211,7 +2238,7 @@ async def execute(self, command, data, params, challenge): rel_position = params["relativePositionMs"] / 1000 seconds_since = 0 # Default to 0 seconds if self.state.state == STATE_PLAYING: - now = dt.utcnow() + now = dt_util.utcnow() upd_at = self.state.attributes.get( media_player.ATTR_MEDIA_POSITION_UPDATED_AT, now ) @@ -2386,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.""" @@ -2394,20 +2438,44 @@ def supported(cls, domain, features, device_class, _): 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 7a9ca70bf14197..db2a8d9512ed4a 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -18,18 +18,11 @@ ) 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 @@ }, ) +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 send_text_command(call: ServiceCall) -> 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 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: 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 @@ def attribution(self): @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 @@ async def async_process( 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 b93a3be93f2fde..b4f617ca029258 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.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 @@ async def async_step_init( 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 c9f86160bb4c13..d63aec0ebd5309 100644 --- a/homeassistant/components/google_assistant_sdk/const.py +++ b/homeassistant/components/google_assistant_sdk/const.py @@ -5,7 +5,6 @@ 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 d4c85be91e50c0..66a2b975b5e15a 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_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 89da3433815bd4..c8f6869f6e4e72 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -241,7 +241,7 @@ def default_options(self): CONF_TEXT_TYPE: self._text_type, } - async def async_get_tts_audio(self, message, language, options=None): + async def async_get_tts_audio(self, message, language, options): """Load TTS from google.""" options_schema = vol.Schema( { diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index 52de921553544e..65d9e0b38943fc 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 a24d5c1787451b..15c4192ccf5cf3 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -7,8 +7,7 @@ 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 @@ 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 202fa5b56b65fe..ffa33deae14b63 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 8023b9222a04d4..a65e845095cea2 100644 --- a/homeassistant/components/google_mail/sensor.py +++ b/homeassistant/components/google_mail/sensor.py @@ -21,7 +21,7 @@ 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 eb44bffb13488e..2f76806dfd351a 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_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index 803b737283b331..590c7bd0c90295 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -7,13 +7,18 @@ from google.auth.exceptions import RefreshError from google.oauth2.credentials import Credentials from gspread import Client +from gspread.exceptions import APIError from gspread.utils import ValueInputOption import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, @@ -93,6 +98,9 @@ def _append_to_sheet(call: ServiceCall, entry: ConfigEntry) -> None: except RefreshError as ex: entry.async_start_reauth(hass) raise ex + except APIError as ex: + raise HomeAssistantError("Failed to write data") from ex + worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title)) row_data = {"created": str(datetime.now())} | call.data[DATA] columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), []) diff --git a/homeassistant/components/google_translate/__init__.py b/homeassistant/components/google_translate/__init__.py index f7860c57d993fc..ac6b07bd4b37be 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 00000000000000..3996d41df35e05 --- /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 e6361c1025ef68..0bb8663119b044 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 @@ "ko", "la", "lv", + "lt", "mk", "ml", "mr", diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json index 5321d13c5d617a..7074d0ed444198 100644 --- a/homeassistant/components/google_translate/manifest.json +++ b/homeassistant/components/google_translate/manifest.json @@ -1,7 +1,8 @@ { "domain": "google_translate", - "name": "Google Translate Text-to-Speech", + "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 00000000000000..a83e61f01f9a68 --- /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 b720498b4f1b4e..45288e81996f2d 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 .const import MAP_LANG_TLD, SUPPORT_LANGUAGES, SUPPORT_TLD +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 ( + 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): @@ -59,13 +146,13 @@ def supported_options(self): """Return a list of supported options.""" return SUPPORT_OPTIONS - def get_tts_audio(self, message, language, options=None): + def get_tts_audio(self, message, language, options): """Load TTS from google.""" tld = self._tld if language in MAP_LANG_TLD: tld = MAP_LANG_TLD[language].tld language = MAP_LANG_TLD[language].lang - if options is not None and "tld" in options: + if "tld" in options: tld = options["tld"] tts = gTTS(text=message, lang=language, tld=tld) mp3_data = BytesIO() diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 9fe264219ecef2..6bf552b824be80 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -23,7 +23,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle, dt +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -212,7 +212,7 @@ def data_format(self): elif attr_key == ATTR_UPTIME: sensor_value = round(sensor_value / (3600 * 24), 2) elif attr_key == ATTR_LAST_RESTART: - last_restart = dt.now() - timedelta(seconds=sensor_value) + last_restart = dt_util.now() - timedelta(seconds=sensor_value) sensor_value = last_restart.strftime("%Y-%m-%d %H:%M:%S") elif attr_key == ATTR_STATUS: if sensor_value: diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py index 5331f6e7029092..9f00e2cb52d9c4 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -12,7 +12,6 @@ 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 @@ def _id(value: str) -> str: ) -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 01f98b996ddddb..68c11ad6e1f458 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -3,7 +3,7 @@ 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 @@ def __init__(self, coordinator, description: GreeSwitchEntityDescription) -> Non """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 fcf4d004d26565..33a4947c01d230 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 4543bf79d523d7..9480fa3ce1712b 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/config_flow.py b/homeassistant/components/group/config_flow.py index 53a8fd0626414b..6cdc47f9e85627 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -24,14 +24,14 @@ from .const import CONF_HIDE_MEMBERS, CONF_IGNORE_NON_NUMERIC _STATISTIC_MEASURES = [ - selector.SelectOptionDict(value="min", label="Minimum"), - selector.SelectOptionDict(value="max", label="Maximum"), - selector.SelectOptionDict(value="mean", label="Arithmetic mean"), - selector.SelectOptionDict(value="median", label="Median"), - selector.SelectOptionDict(value="last", label="Most recently updated"), - selector.SelectOptionDict(value="range", label="Statistical range"), - selector.SelectOptionDict(value="sum", label="Sum"), - selector.SelectOptionDict(value="product", label="Product"), + "min", + "max", + "mean", + "median", + "last", + "range", + "sum", + "product", ] @@ -80,13 +80,17 @@ async def binary_sensor_options_schema(handler: SchemaCommonFlowHandler) -> vol. SENSOR_CONFIG_EXTENDS = { vol.Required(CONF_TYPE): selector.SelectSelector( - selector.SelectSelectorConfig(options=_STATISTIC_MEASURES), + selector.SelectSelectorConfig( + options=_STATISTIC_MEASURES, translation_key=CONF_TYPE + ), ), } SENSOR_OPTIONS = { vol.Optional(CONF_IGNORE_NON_NUMERIC, default=False): selector.BooleanSelector(), vol.Required(CONF_TYPE): selector.SelectSelector( - selector.SelectSelectorConfig(options=_STATISTIC_MEASURES), + selector.SelectSelectorConfig( + options=_STATISTIC_MEASURES, translation_key=CONF_TYPE + ), ), } diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index 378a7852343bc9..2747ba55ee15b2 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -66,7 +66,7 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None: 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 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None: 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/group/strings.json b/homeassistant/components/group/strings.json index 9f5054546812a2..192823cef6515b 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -176,5 +176,19 @@ } } } + }, + "selector": { + "type": { + "options": { + "min": "Minimum", + "max": "Maximum", + "mean": "Arithmetic mean", + "median": "Median", + "last": "Most recently updated", + "range": "Statistical range", + "sum": "Sum", + "product": "Product" + } + } } } diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index f4150068399fba..87822227cefe93 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import Throttle, dt +from homeassistant.util import Throttle, dt as dt_util from .const import ( CONF_PLANT_ID, @@ -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 @@ def __init__( 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" @@ -234,10 +235,10 @@ def update(self): sorted_keys = sorted(mix_chart_entries) # Create datetime from the latest entry - date_now = dt.now().date() - last_updated_time = dt.parse_time(str(sorted_keys[-1])) + date_now = dt_util.now().date() + last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) mix_detail["lastdataupdate"] = datetime.datetime.combine( - date_now, last_updated_time, dt.DEFAULT_TIME_ZONE + date_now, last_updated_time, dt_util.DEFAULT_TIME_ZONE ) # Dashboard data is largely inaccurate for mix system but it is the only diff --git a/homeassistant/components/growatt_server/sensor_types/inverter.py b/homeassistant/components/growatt_server/sensor_types/inverter.py index 746e4880cef787..cfacadce528fb0 100644 --- a/homeassistant/components/growatt_server/sensor_types/inverter.py +++ b/homeassistant/components/growatt_server/sensor_types/inverter.py @@ -16,7 +16,7 @@ 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 76d37f4d193709..e9722abda11399 100644 --- a/homeassistant/components/growatt_server/sensor_types/mix.py +++ b/homeassistant/components/growatt_server/sensor_types/mix.py @@ -15,21 +15,21 @@ # 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ # 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 @@ ), 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 @@ # 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 @@ # 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 @@ # 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 d1305aa879d918..4b60a73c979ffa 100644 --- a/homeassistant/components/growatt_server/sensor_types/storage.py +++ b/homeassistant/components/growatt_server/sensor_types/storage.py @@ -16,14 +16,14 @@ 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 9c9bbd488d52b6..645b32db9d0fdb 100644 --- a/homeassistant/components/growatt_server/sensor_types/tlx.py +++ b/homeassistant/components/growatt_server/sensor_types/tlx.py @@ -20,7 +20,7 @@ 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 @@ ), 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 2056443af3dc87..5945ad20e40466 100644 --- a/homeassistant/components/growatt_server/sensor_types/total.py +++ b/homeassistant/components/growatt_server/sensor_types/total.py @@ -9,33 +9,33 @@ 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 @@ ), 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 695b8a08c1c0f5..d2c196dbfdd395 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 9fac4d0192671e..6f8daf2918d765 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 @@ def update(self) -> None: # 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 f9e8819a6afb79..7114d33f93a663 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 @@ class ValveControllerBinarySensorDescription( 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 7680707641c096..c6363c9bcec798 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 c46f6e221b2e59..c5fc77cc8f910f 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 @@ class ValveControllerSensorDescription( 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 @@ class ValveControllerSensorDescription( ), 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 683f13c8d36cc0..dc3e6f4c17df50 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 fe6ff937b84a77..4e2be5ae17965c 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 @@ async def _async_open_valve(client: Client) -> None: ), 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/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index e085167301f785..d9e0fb227c0b3b 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -24,7 +24,7 @@ SensorType = namedtuple("SensorType", ["name", "icon", "unit", "path"]) SENSORS_TYPES = { - "name": SensorType("Name", None, "", ["profile", "name"]), + "name": SensorType("Name", None, None, ["profile", "name"]), "hp": SensorType("HP", "mdi:heart", "HP", ["stats", "hp"]), "maxHealth": SensorType("max HP", "mdi:heart", "HP", ["stats", "maxHealth"]), "mp": SensorType("Mana", "mdi:auto-fix", "MP", ["stats", "mp"]), @@ -35,7 +35,7 @@ "Lvl", "mdi:arrow-up-bold-circle-outline", "Lvl", ["stats", "lvl"] ), "gp": SensorType("Gold", "mdi:circle-multiple", "Gold", ["stats", "gp"]), - "class": SensorType("Class", "mdi:sword", "", ["stats", "class"]), + "class": SensorType("Class", "mdi:sword", None, ["stats", "class"]), } TASKS_TYPES = { diff --git a/homeassistant/components/hardware/__init__.py b/homeassistant/components/hardware/__init__.py index a1198534213170..2e00771199cd92 100644 --- a/homeassistant/components/hardware/__init__.py +++ b/homeassistant/components/hardware/__init__.py @@ -2,11 +2,14 @@ from __future__ import annotations from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import websocket_api from .const import DOMAIN +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Hardware.""" diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index 1482c8aaa4dc44..c1e85c867874ea 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -216,9 +216,8 @@ async def async_turn_on(self, **kwargs: Any) -> None: 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 c8f4b69d429d9d..8c7f86700e7a07 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -61,6 +61,7 @@ ATTR_FOLDERS, ATTR_HOMEASSISTANT, ATTR_INPUT, + ATTR_LOCATION, ATTR_PASSWORD, ATTR_REPOSITORY, ATTR_SLUG, @@ -158,9 +159,14 @@ 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( + cv.string, lambda v: None if v == "/backup" else v + ), } ) @@ -295,7 +301,7 @@ def get_supervisor_info(hass: HomeAssistant) -> dict[str, Any] | None: @callback @bind_hass -def get_addons_info(hass): +def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None: """Return Addons info. Async friendly. @@ -363,6 +369,16 @@ def get_core_info(hass: HomeAssistant) -> dict[str, Any] | None: return hass.data.get(DATA_CORE_INFO) +@callback +@bind_hass +def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None: + """Return Supervisor issues info. + + Async friendly. + """ + return hass.data.get(DATA_KEY_SUPERVISOR_ISSUES) + + @callback @bind_hass def is_hassio(hass: HomeAssistant) -> bool: @@ -774,7 +790,7 @@ async def _async_update_data(self) -> dict[str, Any]: new_data: dict[str, Any] = {} supervisor_info = get_supervisor_info(self.hass) or {} - addons_info = get_addons_info(self.hass) + addons_info = get_addons_info(self.hass) or {} addons_stats = get_addons_stats(self.hass) addons_changelogs = get_addons_changelogs(self.hass) store_data = get_store(self.hass) or {} diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 1dfd5ce53cd603..2bc314f169a492 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -1,5 +1,5 @@ """Hass.io const variables.""" -from enum import Enum +from homeassistant.backports.enum import StrEnum DOMAIN = "hassio" @@ -61,6 +61,7 @@ ATTR_VERSION_LATEST = "version_latest" ATTR_CPU_PERCENT = "cpu_percent" ATTR_CHANGELOG = "changelog" +ATTR_LOCATION = "location" ATTR_MEMORY_PERCENT = "memory_percent" ATTR_SLUG = "slug" ATTR_STATE = "state" @@ -76,9 +77,12 @@ DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues" PLACEHOLDER_KEY_REFERENCE = "reference" +PLACEHOLDER_KEY_COMPONENTS = "components" +ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" -class SupervisorEntityModel(str, Enum): + +class SupervisorEntityModel(StrEnum): """Supervisor entity model.""" ADDON = "Home Assistant Add-on" @@ -86,3 +90,17 @@ class SupervisorEntityModel(str, Enum): CORE = "Home Assistant Core" SUPERVIOSR = "Home Assistant Supervisor" HOST = "Home Assistant Host" + + +class SupervisorIssueContext(StrEnum): + """Context for supervisor issues.""" + + ADDON = "addon" + CORE = "core" + DNS_SERVER = "dns_server" + MOUNT = "mount" + OS = "os" + PLUGIN = "plugin" + SUPERVISOR = "supervisor" + STORE = "store" + SYSTEM = "system" diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index ac6af7f3489ee2..0bbd89aab86852 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -35,8 +35,10 @@ EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, EVENT_SUPPORTED_CHANGED, + ISSUE_KEY_SYSTEM_DOCKER_CONFIG, PLACEHOLDER_KEY_REFERENCE, UPDATE_KEY_SUPERVISOR, + SupervisorIssueContext, ) from .handler import HassIO, HassioAPIError @@ -85,8 +87,10 @@ # Keys (type + context) of issues that when found should be made into a repair ISSUE_KEYS_FOR_REPAIRS = { + "issue_mount_mount_failed", "issue_system_multiple_data_disks", "issue_system_reboot_required", + ISSUE_KEY_SYSTEM_DOCKER_CONFIG, } _LOGGER = logging.getLogger(__name__) @@ -106,22 +110,22 @@ class Suggestion: """Suggestion from Supervisor which resolves an issue.""" uuid: str - type_: str - context: str + type: str + context: SupervisorIssueContext reference: str | None = None @property def key(self) -> str: """Get key for suggestion (combination of context and type).""" - return f"{self.context}_{self.type_}" + return f"{self.context}_{self.type}" @classmethod def from_dict(cls, data: SuggestionDataType) -> Suggestion: """Convert from dictionary representation.""" return cls( uuid=data["uuid"], - type_=data["type"], - context=data["context"], + type=data["type"], + context=SupervisorIssueContext(data["context"]), reference=data["reference"], ) @@ -141,15 +145,15 @@ class Issue: """Issue from Supervisor.""" uuid: str - type_: str - context: str + type: str + context: SupervisorIssueContext reference: str | None = None suggestions: list[Suggestion] = field(default_factory=list, compare=False) @property def key(self) -> str: """Get key for issue (combination of context and type).""" - return f"issue_{self.context}_{self.type_}" + return f"issue_{self.context}_{self.type}" @classmethod def from_dict(cls, data: IssueDataType) -> Issue: @@ -157,8 +161,8 @@ def from_dict(cls, data: IssueDataType) -> Issue: suggestions: list[SuggestionDataType] = data.get("suggestions", []) return cls( uuid=data["uuid"], - type_=data["type"], - context=data["context"], + type=data["type"], + context=SupervisorIssueContext(data["context"]), reference=data["reference"], suggestions=[ Suggestion.from_dict(suggestion) for suggestion in suggestions @@ -241,6 +245,11 @@ def unsupported_reasons(self, reasons: set[str]) -> None: self._unsupported_reasons = reasons + @property + def issues(self) -> set[Issue]: + """Get issues.""" + return set(self._issues.values()) + def add_issue(self, issue: Issue) -> None: """Add or update an issue in the list. Create or update a repair if necessary.""" if issue.key in ISSUE_KEYS_FOR_REPAIRS: @@ -262,25 +271,16 @@ def add_issue(self, issue: Issue) -> None: async def add_issue_from_data(self, data: IssueDataType) -> None: """Add issue from data to list after getting latest suggestions.""" try: - suggestions = (await self._client.get_suggestions_for_issue(data["uuid"]))[ - ATTR_SUGGESTIONS - ] - self.add_issue( - Issue( - uuid=data["uuid"], - type_=data["type"], - context=data["context"], - reference=data["reference"], - suggestions=[ - Suggestion.from_dict(suggestion) for suggestion in suggestions - ], - ) - ) + data["suggestions"] = ( + await self._client.get_suggestions_for_issue(data["uuid"]) + )[ATTR_SUGGESTIONS] 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.""" @@ -306,7 +306,11 @@ async def setup(self) -> None: async def update(self) -> None: """Update issues from Supervisor resolution center.""" - data = await self._client.get_resolution_info() + try: + data = await self._client.get_resolution_info() + except HassioAPIError as err: + _LOGGER.error("Failed to update supervisor issues: %r", err) + return self.unhealthy_reasons = set(data[ATTR_UNHEALTHY]) self.unsupported_reasons = set(data[ATTR_UNSUPPORTED]) diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 50a9b087a7cde5..d5e26d4670f44f 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -10,12 +10,24 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult -from .const import DATA_KEY_SUPERVISOR_ISSUES, PLACEHOLDER_KEY_REFERENCE +from . import get_addons_info, get_issues_info +from .const import ( + ISSUE_KEY_SYSTEM_DOCKER_CONFIG, + PLACEHOLDER_KEY_COMPONENTS, + PLACEHOLDER_KEY_REFERENCE, + SupervisorIssueContext, +) from .handler import HassioAPIError, async_apply_suggestion -from .issues import Issue, Suggestion, SupervisorIssues +from .issues import Issue, Suggestion SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"} +EXTRA_PLACEHOLDERS = { + "issue_mount_mount_failed": { + "storage_url": "/config/storage", + } +} + class SupervisorIssueRepairFlow(RepairsFlow): """Handler for an issue fixing flow.""" @@ -31,10 +43,8 @@ def __init__(self, issue_id: str) -> None: @property def issue(self) -> Issue | None: """Get associated issue.""" - if not self._issue: - supervisor_issues: SupervisorIssues = self.hass.data[ - DATA_KEY_SUPERVISOR_ISSUES - ] + supervisor_issues = get_issues_info(self.hass) + if not self._issue and supervisor_issues: self._issue = supervisor_issues.get_issue(self._issue_id) return self._issue @@ -42,11 +52,13 @@ def issue(self) -> Issue | None: @property def description_placeholders(self) -> dict[str, str] | None: """Get description placeholders for steps.""" - return ( - {PLACEHOLDER_KEY_REFERENCE: self.issue.reference} - if self.issue and self.issue.reference - else None - ) + placeholders = {} + if self.issue: + placeholders = EXTRA_PLACEHOLDERS.get(self.issue.key, {}) + if self.issue.reference: + placeholders |= {PLACEHOLDER_KEY_REFERENCE: self.issue.reference} + + return placeholders or None def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult: """Return form for suggestion.""" @@ -113,10 +125,49 @@ async def _async_step( return _async_step +class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow): + """Handler for docker config issue fixing flow.""" + + @property + def description_placeholders(self) -> dict[str, str] | None: + """Get description placeholders for steps.""" + placeholders = {PLACEHOLDER_KEY_COMPONENTS: ""} + supervisor_issues = get_issues_info(self.hass) + if supervisor_issues and self.issue: + addons = get_addons_info(self.hass) or {} + components: list[str] = [] + for issue in supervisor_issues.issues: + if issue.key == self.issue.key or issue.type != self.issue.type: + continue + + if issue.context == SupervisorIssueContext.CORE: + components.insert(0, "Home Assistant") + elif issue.context == SupervisorIssueContext.ADDON: + components.append( + next( + ( + info["name"] + for slug, info in addons.items() + if slug == issue.reference + ), + issue.reference or "", + ) + ) + + placeholders[PLACEHOLDER_KEY_COMPONENTS] = "\n- ".join(components) + + return placeholders + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, str | int | float | None] | None, ) -> RepairsFlow: """Create flow.""" + supervisor_issues = get_issues_info(hass) + issue = supervisor_issues and supervisor_issues.get_issue(issue_id) + if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG: + return DockerConfigIssueRepairFlow(issue_id) + return SupervisorIssueRepairFlow(issue_id) diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index e526074b1a9bf9..60b547354932f6 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -88,6 +88,12 @@ backup_full: default: true selector: boolean: + location: + name: Location + description: Name of a backup network storage to put backup (or /backup) + example: my_backup_mount + selector: + backup_location: backup_partial: name: Create a partial backup. @@ -128,6 +134,12 @@ backup_partial: default: true selector: boolean: + location: + name: Location + description: Name of a backup network storage to put backup (or /backup) + example: my_backup_mount + selector: + backup_location: restore_full: name: Restore from full backup. diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 078aac39a5ba5e..f9c212f946c912 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -17,16 +17,46 @@ } }, "issues": { + "issue_mount_mount_failed": { + "title": "Network storage device failed", + "fix_flow": { + "step": { + "fix_menu": { + "description": "Could not connect to `{reference}`. Check host logs for errors from the mount service for more details.\n\nUse reload to try to connect again. If you need to update `{reference}`, go to [storage]({storage_url}).", + "menu_options": { + "mount_execute_reload": "Reload", + "mount_execute_remove": "Remove" + } + } + }, + "abort": { + "apply_suggestion_fail": "Could not apply the fix. Check the Supervisor logs for more details." + } + } + }, + "issue_system_docker_config": { + "title": "Restart(s) required", + "fix_flow": { + "step": { + "system_execute_rebuild": { + "description": "The default configuration for add-ons and Home Assistant has changed. To update the configuration with the new defaults, a restart is required for the following:\n\n- {components}" + } + }, + "abort": { + "apply_suggestion_fail": "One or more of the restarts failed. Check the Supervisor logs for more details." + } + } + }, "issue_system_multiple_data_disks": { "title": "Multiple data disks detected", "fix_flow": { "step": { "system_rename_data_disk": { - "description": "'{reference}' is a filesystem with the name 'hassos-data' and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the fix option to rename the filesystem to prevent this. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system." + "description": "`{reference}` is a filesystem with the name hassos-data and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the fix option to rename the filesystem to prevent this. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system." } }, "abort": { - "apply_suggestion_fail": "Could not rename the filesystem. Check the supervisor logs for more details." + "apply_suggestion_fail": "Could not rename the filesystem. Check the Supervisor logs for more details." } } }, @@ -39,7 +69,7 @@ } }, "abort": { - "apply_suggestion_fail": "Could not reboot the system. Check the supervisor logs for more details." + "apply_suggestion_fail": "Could not reboot the system. Check the Supervisor logs for more details." } } }, diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 8a9a145f2d6f6e..c8fefe65e1f79e 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -41,9 +41,14 @@ # 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/__init__.py b/homeassistant/components/here_travel_time/__init__.py index 2ce91946f86ec6..1c728bcc12c31a 100644 --- a/homeassistant/components/here_travel_time/__init__.py +++ b/homeassistant/components/here_travel_time/__init__.py @@ -4,7 +4,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( CONF_ARRIVAL_TIME, @@ -32,8 +32,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Set up HERE Travel Time from a config entry.""" api_key = config_entry.data[CONF_API_KEY] - arrival = dt.parse_time(config_entry.options.get(CONF_ARRIVAL_TIME, "")) - departure = dt.parse_time(config_entry.options.get(CONF_DEPARTURE_TIME, "")) + arrival = dt_util.parse_time(config_entry.options.get(CONF_ARRIVAL_TIME, "")) + departure = dt_util.parse_time(config_entry.options.get(CONF_DEPARTURE_TIME, "")) here_travel_time_config = HERETravelTimeConfig( destination_latitude=config_entry.data.get(CONF_DESTINATION_LATITUDE), diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index ae8bfc34a42f58..dbb17b58336e5a 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -29,7 +29,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.location import find_coordinates from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import DistanceConverter from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, ROUTE_MODE_FASTEST @@ -336,7 +336,7 @@ def build_hass_attribution(sections: list[dict[str, Any]]) -> str | None: def next_datetime(simple_time: time) -> datetime: """Take a time like 08:00:00 and combine it with the current date.""" - combined = datetime.combine(dt.start_of_local_day(), simple_time) + combined = datetime.combine(dt_util.start_of_local_day(), simple_time) if combined < datetime.now(): combined = combined + timedelta(days=1) return combined diff --git a/homeassistant/components/here_travel_time/manifest.json b/homeassistant/components/here_travel_time/manifest.json index f024b55d009dd1..19c5c4d73d9d67 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 91abfbd765240b..537f782ad09a56 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 @@ def __init__( name=name, manufacturer="HERE Technologies", ) - self._attr_has_entity_name = True async def _async_restore_state(self) -> None: """Restore state.""" @@ -174,7 +175,7 @@ def __init__( ) -> None: """Initialize the sensor.""" sensor_description = SensorEntityDescription( - name="Origin", + translation_key="origin", icon="mdi:store-marker", key=ATTR_ORIGIN_NAME, ) @@ -202,7 +203,7 @@ def __init__( ) -> 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 dab135efc82807..2c031dc0a02881 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 1e175a2a0df8f9..e37e149ccdab76 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 38a45ccf7095e1..efd2a9b34dd483 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -62,28 +62,27 @@ async def async_update(self) -> None: 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 06ecd4fe3ef0ba..edb26c3622ef1e 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -11,6 +11,18 @@ "python_version": { "title": "Support for Python {current_python_version} is being removed", "description": "Support for running Home Assistant in the current used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking." + }, + "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." + }, + "no_platform_setup": { + "title": "Unused YAML configuration for the {platform} integration", + "description": "It's not possible to configure {platform} {domain} by adding `{platform_key}` to the {domain} configuration. Please check the documentation for more information on how to set up this integration.\n\nTo resolve this:\n1. Remove `{platform_key}` occurences from the `{domain}:` configuration in your YAML configuration file.\n2. Restart Home Assistant.\n\nExample that should be removed:\n{yaml_example}\n" } }, "system_health": { diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index 234f5ae4fed9df..8241c1712654e7 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -12,6 +12,7 @@ from homeassistant.components.hassio import get_supervisor_info, is_hassio from homeassistant.const import EVENT_COMPONENT_LOADED, __version__ from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.issue_registry import ( @@ -28,6 +29,8 @@ UPDATE_INTERVAL = timedelta(hours=3) _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up alerts.""" diff --git a/homeassistant/components/homeassistant_hardware/__init__.py b/homeassistant/components/homeassistant_hardware/__init__.py index f3a63a7f76779c..057cdd3b0db821 100644 --- a/homeassistant/components/homeassistant_hardware/__init__.py +++ b/homeassistant/components/homeassistant_hardware/__init__.py @@ -2,8 +2,12 @@ from __future__ import annotations from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +DOMAIN = "homeassistant_hardware" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the component.""" diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 8c502f080f6ce6..e4d9902346c167 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -5,7 +5,7 @@ import asyncio import dataclasses import logging -from typing import Any +from typing import Any, Protocol import voluptuous as vol import yarl @@ -19,12 +19,19 @@ hostname_from_addon_slug, is_hassio, ) -from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN -from homeassistant.components.zha.radio_manager import ZhaMultiPANMigrationHelper from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.integration_platform import ( + async_process_integration_platforms, +) +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store from .const import LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG @@ -39,17 +46,165 @@ CONF_ADDON_DEVICE = "device" CONF_ENABLE_MULTI_PAN = "enable_multi_pan" +DEFAULT_CHANNEL = 15 +DEFAULT_CHANNEL_CHANGE_DELAY = 5 * 60 # Thread recommendation + +STORAGE_KEY = "homeassistant_hardware.silabs" +STORAGE_VERSION_MAJOR = 1 +STORAGE_VERSION_MINOR = 1 +SAVE_DELAY = 10 + @singleton(DATA_ADDON_MANAGER) -@callback -def get_addon_manager(hass: HomeAssistant) -> AddonManager: +async def get_addon_manager(hass: HomeAssistant) -> MultiprotocolAddonManager: """Get the add-on manager.""" - return AddonManager( - hass, - LOGGER, - "Silicon Labs Multiprotocol", - SILABS_MULTIPROTOCOL_ADDON_SLUG, - ) + manager = MultiprotocolAddonManager(hass) + await manager.async_setup() + return manager + + +class MultiprotocolAddonManager(AddonManager): + """Silicon Labs Multiprotocol add-on manager.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the manager.""" + super().__init__( + hass, + LOGGER, + "Silicon Labs Multiprotocol", + SILABS_MULTIPROTOCOL_ADDON_SLUG, + ) + self._channel: int | None = None + self._platforms: dict[str, MultipanProtocol] = {} + self._store: Store[dict[str, Any]] = Store( + hass, + STORAGE_VERSION_MAJOR, + STORAGE_KEY, + atomic_writes=True, + minor_version=STORAGE_VERSION_MINOR, + ) + + async def async_setup(self) -> None: + """Set up the manager.""" + await async_process_integration_platforms( + self._hass, "silabs_multiprotocol", self._register_multipan_platform + ) + await self.async_load() + + async def _register_multipan_platform( + self, hass: HomeAssistant, integration_domain: str, platform: MultipanProtocol + ) -> None: + """Register a multipan platform.""" + self._platforms[integration_domain] = platform + + channel = await platform.async_get_channel(hass) + using_multipan = await platform.async_using_multipan(hass) + + _LOGGER.info( + "Registering new multipan platform '%s', using multipan: %s, channel: %s", + integration_domain, + using_multipan, + channel, + ) + + if self._channel is not None or not using_multipan: + return + + if channel is None: + return + + _LOGGER.info( + "Setting multipan channel to %s (source: '%s')", + channel, + integration_domain, + ) + self.async_set_channel(channel) + + async def async_change_channel( + self, channel: int, delay: float + ) -> list[asyncio.Task]: + """Change the channel and notify platforms.""" + self.async_set_channel(channel) + + tasks = [] + + for platform in self._platforms.values(): + if not await platform.async_using_multipan(self._hass): + continue + task = await platform.async_change_channel(self._hass, channel, delay) + if not task: + continue + tasks.append(task) + + return tasks + + async def async_active_platforms(self) -> list[str]: + """Return a list of platforms using the multipan radio.""" + active_platforms: list[str] = [] + + for integration_domain, platform in self._platforms.items(): + if not await platform.async_using_multipan(self._hass): + continue + active_platforms.append(integration_domain) + + return active_platforms + + @callback + def async_get_channel(self) -> int | None: + """Get the channel.""" + return self._channel + + @callback + def async_set_channel(self, channel: int) -> None: + """Set the channel without notifying platforms. + + This must only be called when first initializing the manager. + """ + self._channel = channel + self.async_schedule_save() + + async def async_load(self) -> None: + """Load the store.""" + data = await self._store.async_load() + + if data is not None: + self._channel = data["channel"] + + @callback + def async_schedule_save(self) -> None: + """Schedule saving the store.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, list[dict[str, str | None]]]: + """Return data to store in a file.""" + data: dict[str, Any] = {} + data["channel"] = self._channel + return data + + +class MultipanProtocol(Protocol): + """Define the format of multipan platforms.""" + + async def async_change_channel( + self, hass: HomeAssistant, channel: int, delay: float + ) -> asyncio.Task | None: + """Set the channel to be used. + + Does nothing if not configured or the multiprotocol add-on is not used. + """ + + async def async_get_channel(self, hass: HomeAssistant) -> int | None: + """Return the channel. + + Returns None if not configured or the multiprotocol add-on is not used. + """ + + async def async_using_multipan(self, hass: HomeAssistant) -> bool: + """Return if the multiprotocol device is used. + + Returns False if not configured. + """ @dataclasses.dataclass @@ -82,6 +237,11 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Set up the options flow.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha.radio_manager import ( + ZhaMultiPANMigrationHelper, + ) + # If we install the add-on we should uninstall it on entry remove. self.install_task: asyncio.Task | None = None self.start_task: asyncio.Task | None = None @@ -117,7 +277,7 @@ def flow_manager(self) -> config_entries.OptionsFlowManager: async def _async_get_addon_info(self) -> AddonInfo: """Return and cache Silicon Labs Multiprotocol add-on info.""" - addon_manager: AddonManager = get_addon_manager(self.hass) + addon_manager: AddonManager = await get_addon_manager(self.hass) try: addon_info: AddonInfo = await addon_manager.async_get_addon_info() except AddonError as err: @@ -128,7 +288,7 @@ async def _async_get_addon_info(self) -> AddonInfo: async def _async_set_addon_config(self, config: dict) -> None: """Set Silicon Labs Multiprotocol add-on config.""" - addon_manager: AddonManager = get_addon_manager(self.hass) + addon_manager: AddonManager = await get_addon_manager(self.hass) try: await addon_manager.async_set_addon_options(config) except AddonError as err: @@ -137,7 +297,7 @@ async def _async_set_addon_config(self, config: dict) -> None: async def _async_install_addon(self) -> None: """Install the Silicon Labs Multiprotocol add-on.""" - addon_manager: AddonManager = get_addon_manager(self.hass) + addon_manager: AddonManager = await get_addon_manager(self.hass) try: await addon_manager.async_schedule_install_addon() finally: @@ -213,6 +373,19 @@ async def async_step_configure_addon( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Configure the Silicon Labs Multiprotocol add-on.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN + + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha.radio_manager import ( + ZhaMultiPANMigrationHelper, + ) + + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha.silabs_multiprotocol import ( + async_get_channel as async_get_zha_channel, + ) + addon_info = await self._async_get_addon_info() addon_config = addon_info.options @@ -224,6 +397,8 @@ async def async_step_configure_addon( **dataclasses.asdict(serial_port_settings), } + multipan_channel = DEFAULT_CHANNEL + # Initiate ZHA migration zha_entries = self.hass.config_entries.async_entries(ZHA_DOMAIN) @@ -247,6 +422,13 @@ async def async_step_configure_addon( _LOGGER.exception("Unexpected exception during ZHA migration") raise AbortFlow("zha_migration_failed") from err + if (zha_channel := await async_get_zha_channel(self.hass)) is not None: + multipan_channel = zha_channel + + # Initialize the shared channel + multipan_manager = await get_addon_manager(self.hass) + multipan_manager.async_set_channel(multipan_channel) + if new_addon_config != addon_config: # Copy the add-on config to keep the objects separate. self.original_addon_config = dict(addon_config) @@ -283,7 +465,7 @@ async def async_step_start_failed( async def _async_start_addon(self) -> None: """Start Silicon Labs Multiprotocol add-on.""" - addon_manager: AddonManager = get_addon_manager(self.hass) + addon_manager: AddonManager = await get_addon_manager(self.hass) try: await addon_manager.async_schedule_start_addon() finally: @@ -319,9 +501,92 @@ async def async_step_addon_installed( serial_device = (await self._async_serial_port_settings()).device if addon_info.options.get(CONF_ADDON_DEVICE) == serial_device: - return await self.async_step_show_revert_guide() + return await self.async_step_show_addon_menu() return await self.async_step_addon_installed_other_device() + async def async_step_show_addon_menu( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Show menu options for the addon.""" + return self.async_show_menu( + step_id="addon_menu", + menu_options=[ + "reconfigure_addon", + "uninstall_addon", + ], + ) + + async def async_step_reconfigure_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Reconfigure the addon.""" + multipan_manager = await get_addon_manager(self.hass) + active_platforms = await multipan_manager.async_active_platforms() + if set(active_platforms) != {"otbr", "zha"}: + return await self.async_step_notify_unknown_multipan_user() + return await self.async_step_change_channel() + + async def async_step_notify_unknown_multipan_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Notify that there may be unknown multipan platforms.""" + if user_input is None: + return self.async_show_form( + step_id="notify_unknown_multipan_user", + ) + return await self.async_step_change_channel() + + async def async_step_change_channel( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Change the channel.""" + multipan_manager = await get_addon_manager(self.hass) + if user_input is None: + channels = [str(x) for x in range(11, 27)] + suggested_channel = DEFAULT_CHANNEL + if (channel := multipan_manager.async_get_channel()) is not None: + suggested_channel = channel + data_schema = vol.Schema( + { + vol.Required( + "channel", + description={"suggested_value": str(suggested_channel)}, + ): SelectSelector( + SelectSelectorConfig( + options=channels, mode=SelectSelectorMode.DROPDOWN + ) + ) + } + ) + return self.async_show_form( + step_id="change_channel", data_schema=data_schema + ) + + # Change the shared channel + await multipan_manager.async_change_channel( + int(user_input["channel"]), DEFAULT_CHANNEL_CHANGE_DELAY + ) + return await self.async_step_notify_channel_change() + + async def async_step_notify_channel_change( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Notify that the channel change will take about five minutes.""" + if user_input is None: + return self.async_show_form( + step_id="notify_channel_change", + description_placeholders={ + "delay_minutes": str(DEFAULT_CHANNEL_CHANGE_DELAY // 60) + }, + ) + return self.async_create_entry(title="", data={}) + + async def async_step_uninstall_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Uninstall the addon (not implemented).""" + return await self.async_step_show_revert_guide() + async def async_step_show_revert_guide( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -348,7 +613,7 @@ async def check_multi_pan_addon(hass: HomeAssistant) -> None: if not is_hassio(hass): return - addon_manager: AddonManager = get_addon_manager(hass) + addon_manager: AddonManager = await get_addon_manager(hass) try: addon_info: AddonInfo = await addon_manager.async_get_addon_info() except AddonError as err: @@ -375,7 +640,7 @@ async def multi_pan_addon_using_device(hass: HomeAssistant, device_path: str) -> if not is_hassio(hass): return False - addon_manager: AddonManager = get_addon_manager(hass) + addon_manager: AddonManager = await get_addon_manager(hass) addon_info: AddonInfo = await addon_manager.async_get_addon_info() if addon_info.state != AddonState.RUNNING: diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 47549794fc8f97..06221fc7b97f91 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -12,15 +12,41 @@ "addon_installed_other_device": { "title": "Multiprotocol support is already enabled for another device" }, + "addon_menu": { + "menu_options": { + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" + } + }, + "change_channel": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "data": { + "channel": "Channel" + } + }, "install_addon": { "title": "The Silicon Labs Multiprotocol add-on installation has started" }, + "notify_channel_change": { + "title": "Channel change initiated", + "description": "A Zigbee and Thread channel change has been initiated and will finish in {delay_minutes} minutes." + }, + "notify_unknown_multipan_user": { + "title": "Manual configuration may be needed", + "description": "Home Assistant can automatically change the channels for otbr and zha. If you have configured another integration to use the radio, for example Zigbee2MQTT, you will have to reconfigure the channel in that integration after completing this guide." + }, + "reconfigure_addon": { + "title": "Reconfigure IEEE 802.15.4 radio multiprotocol support" + }, "show_revert_guide": { "title": "Multiprotocol support is enabled for this device", "description": "If you want to change to Zigbee only firmware, please complete the following manual steps:\n\n * Remove the Silicon Labs Multiprotocol addon\n\n * Flash the Zigbee only firmware, follow the guide at https://github.com/NabuCasa/silabs-firmware/wiki/Flash-Silicon-Labs-radio-firmware-manually.\n\n * Reconfigure ZHA to migrate settings to the reflashed radio" }, "start_addon": { "title": "The Silicon Labs Multiprotocol add-on is starting." + }, + "uninstall_addon": { + "title": "Remove IEEE 802.15.4 radio multiprotocol support." } }, "error": { diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 0f7ec70471550a..5f17069f5d5e0c 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 7bc514d5615302..5ac44f3f290d30 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -58,6 +58,7 @@ async def _async_serial_port_settings( ) -> 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/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 970f9d97a4c174..047130e787cac1 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -11,15 +11,41 @@ "addon_installed_other_device": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]" }, + "addon_menu": { + "menu_options": { + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::reconfigure_addon%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]" + } + }, + "change_channel": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::title%]", + "data": { + "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::data::channel%]" + } + }, "install_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" }, + "notify_channel_change": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]" + }, + "notify_unknown_multipan_user": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_unknown_multipan_user::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_unknown_multipan_user::description%]" + }, + "reconfigure_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]" + }, "show_revert_guide": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]", "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]" }, "start_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]" + }, + "uninstall_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" } }, "error": { diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index d97b01c7c843f8..617e61336a5ef1 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -11,6 +11,18 @@ "addon_installed_other_device": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]" }, + "addon_menu": { + "menu_options": { + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::reconfigure_addon%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]" + } + }, + "change_channel": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::title%]", + "data": { + "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::data::channel%]" + } + }, "hardware_settings": { "title": "Configure hardware settings", "data": { @@ -22,12 +34,20 @@ "install_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" }, + "notify_channel_change": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]" + }, "main_menu": { "menu_options": { "hardware_settings": "[%key:component::homeassistant_yellow::options::step::hardware_settings::title%]", "multipan_settings": "Configure IEEE 802.15.4 radio multiprotocol support" } }, + "notify_unknown_multipan_user": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_unknown_multipan_user::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_unknown_multipan_user::description%]" + }, "reboot_menu": { "title": "Reboot required", "description": "The settings have changed, but the new settings will not take effect until the system is rebooted", @@ -36,12 +56,18 @@ "reboot_now": "Reboot now" } }, + "reconfigure_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]" + }, "show_revert_guide": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]", "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]" }, "start_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]" + }, + "uninstall_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" } }, "error": { diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 2b56a056821ce0..514c218b1010b8 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -28,7 +28,6 @@ ) 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 @@ def _has_all_unique_names_and_ports( ), 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 @@ def __init__( 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 @@ def __init__( 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 @@ def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: str) -> None: 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 dc8a2a7c639b3c..00168ef3898fff 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -281,7 +281,7 @@ def __init__( display_name=cleanup_name_for_homekit(name), aid=aid, iid_manager=HomeIIDManager(driver.iid_storage), - *args, + *args, # noqa: B026 **kwargs, ) self.config = config or {} @@ -626,10 +626,10 @@ def __init__( @pyhap_callback # type: ignore[misc] def pair( - self, client_uuid: UUID, client_public: str, client_permissions: int + self, client_username_bytes: bytes, client_public: str, client_permissions: int ) -> bool: """Override super function to dismiss setup message if paired.""" - success = super().pair(client_uuid, client_public, client_permissions) + success = super().pair(client_username_bytes, client_public, client_permissions) if success: async_dismiss_setup_message(self.hass, self._entry_id) return cast(bool, success) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 746b097e99acb7..245dbd0a19e487 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["pyhap"], "requirements": [ - "HAP-python==4.6.0", + "HAP-python==4.7.0", "fnv-hash-fast==0.3.1", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index eb2cd5d34ad4dc..ee737e01ff49e0 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -47,10 +47,12 @@ def __init__( 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 ecd8113a2bbfaf..ed9b8ca4622002 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -16,16 +16,18 @@ 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 db85dbda3d57b5..b937e7f2e0b0a7 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -272,7 +272,7 @@ async def async_setup(self) -> None: 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 @@ def _async_start_polling(self) -> None: 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 @@ async def async_update(self, now=None): 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 @@ async def async_update(self, now=None): 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 @@ async def async_update(self, now=None): 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 @@ async def async_update(self, now=None): 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/cover.py b/homeassistant/components/homekit_controller/cover.py index fbe6f08bc75fdc..73eb699007c7fe 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -216,6 +216,26 @@ def current_cover_tilt_position(self) -> int: tilt_position = self.service.value( CharacteristicsTypes.HORIZONTAL_TILT_CURRENT ) + # Recalculate to convert from arcdegree scale to percentage scale. + if self.is_vertical_tilt: + scale = 0.9 + if ( + self.service[CharacteristicsTypes.VERTICAL_TILT_CURRENT].minValue == -90 + and self.service[CharacteristicsTypes.VERTICAL_TILT_CURRENT].maxValue + == 0 + ): + scale = -0.9 + tilt_position = int(tilt_position / scale) + elif self.is_horizontal_tilt: + scale = 0.9 + if ( + self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].minValue + == -90 + and self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].maxValue + == 0 + ): + scale = -0.9 + tilt_position = int(tilt_position / scale) return tilt_position async def async_stop_cover(self, **kwargs: Any) -> None: @@ -241,10 +261,29 @@ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" tilt_position = kwargs[ATTR_TILT_POSITION] if self.is_vertical_tilt: + # Recalculate to convert from percentage scale to arcdegree scale. + scale = 0.9 + if ( + self.service[CharacteristicsTypes.VERTICAL_TILT_TARGET].minValue == -90 + and self.service[CharacteristicsTypes.VERTICAL_TILT_TARGET].maxValue + == 0 + ): + scale = -0.9 + tilt_position = int(tilt_position * scale) await self.async_put_characteristics( {CharacteristicsTypes.VERTICAL_TILT_TARGET: tilt_position} ) elif self.is_horizontal_tilt: + # Recalculate to convert from percentage scale to arcdegree scale. + scale = 0.9 + if ( + self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].minValue + == -90 + and self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].maxValue + == 0 + ): + scale = -0.9 + tilt_position = int(tilt_position * scale) await self.async_put_characteristics( {CharacteristicsTypes.HORIZONTAL_TILT_TARGET: tilt_position} ) diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py index e396b3c9c972f8..cd2cf4022e71fa 100644 --- a/homeassistant/components/homekit_controller/humidifier.py +++ b/homeassistant/components/homekit_controller/humidifier.py @@ -73,6 +73,11 @@ def target_humidity(self) -> int | None: 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 @@ def target_humidity(self) -> int | None: 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 9db26d4c8e0739..d0a88bf8249afc 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": [ { @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==2.6.3"], + "requirements": ["aiohomekit==2.6.5"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index 2291f66d88a547..7420ef7f3f9cfa 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 a52966752921f9..7a6e7c18e1375d 100644 --- a/homeassistant/components/homematicip_cloud/generic_entity.py +++ b/homeassistant/components/homematicip_cloud/generic_entity.py @@ -164,7 +164,7 @@ def async_remove_from_registries(self) -> None: 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 @@ def name(self) -> str: 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 770687ef50ddcc..65919033801fc7 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -4,6 +4,7 @@ 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 665406499e1a87..96fe1b157f86c0 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 0451aed973956c..d51d180edb1826 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 @@ def native_value(self) -> float | None: 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 6462c281346267..d8cc72ce45eeae 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -38,6 +38,7 @@ 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 @@ class 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 @@ def __init__( 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 53eeafe99e1dd7..7bb4b16c710e3f 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 1edb9e1e6056d5..cddcabc841ec2b 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 @@ class HomeWizardSwitchEntityDescription( ), 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 @@ class HomeWizardSwitchEntityDescription( ), 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 dd33da562978b0..db31baa53a6cdf 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 8f3b66ddeacb8f..16b07e91446553 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 4d6ea4d9528eb4..ae4ede2a079b23 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 @@ class 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 73986920b8a361..b0cd2a52c1be4d 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/__init__.py b/homeassistant/components/http/__init__.py index fda8717c3ddead..f559b09a1ff992 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -18,6 +18,11 @@ from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection from aiohttp.web_log import AccessLogger from aiohttp.web_protocol import RequestHandler +from aiohttp.web_urldispatcher import ( + AbstractResource, + UrlDispatcher, + UrlMappingMatchInfo, +) from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -303,6 +308,10 @@ def __init__( "max_field_size": MAX_LINE_SIZE, }, ) + # By default aiohttp does a linear search for routing rules, + # we have a lot of routes, so use a dict lookup with a fallback + # to the linear search. + self.app._router = FastUrlDispatcher() # pylint: disable=protected-access self.hass = hass self.ssl_certificate = ssl_certificate self.ssl_peer_certificate = ssl_peer_certificate @@ -565,3 +574,45 @@ async def start_http_server_and_save_config( ] store.async_delay_save(lambda: conf, SAVE_DELAY) + + +class FastUrlDispatcher(UrlDispatcher): + """UrlDispatcher that uses a dict lookup for resolving.""" + + def __init__(self) -> None: + """Initialize the dispatcher.""" + super().__init__() + self._resource_index: dict[str, list[AbstractResource]] = {} + + def register_resource(self, resource: AbstractResource) -> None: + """Register a resource.""" + super().register_resource(resource) + canonical = resource.canonical + if "{" in canonical: # strip at the first { to allow for variables + canonical = canonical.split("{")[0].rstrip("/") + # There may be multiple resources for a canonical path + # so we use a list to avoid falling back to a full linear search + self._resource_index.setdefault(canonical, []).append(resource) + + async def resolve(self, request: web.Request) -> UrlMappingMatchInfo: + """Resolve a request.""" + url_parts = request.rel_url.raw_parts + resource_index = self._resource_index + + # Walk the url parts looking for candidates + for i in range(len(url_parts), 1, -1): + url_part = "/" + "/".join(url_parts[1:i]) + if (resource_candidates := resource_index.get(url_part)) is not None: + for candidate in resource_candidates: + if ( + match_dict := (await candidate.resolve(request))[0] + ) is not None: + return match_dict + # Next try the index view if we don't have a match + if (index_view_candidates := resource_index.get("/")) is not None: + for candidate in index_view_candidates: + if (match_dict := (await candidate.resolve(request))[0]) is not None: + return match_dict + + # Finally, fallback to the linear search + return await super().resolve(request) diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index bce425adbdb6e9..dec1b9485b6921 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 f6c3b69ddeb614..6d7b0b9bb1158f 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -8,7 +8,6 @@ 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 @@ 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 f63cc4aac397d1..133b569c75159e 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 369c6eba0750ff..8bc86d423a12cf 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): 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 169b9a0e901c18..de112f7519f733 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 0bc7e242d55cbd..79effa6f0c2d9d 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -29,7 +29,9 @@ 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 @@ 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 @@ def state_attributes(self) -> dict[str, Any]: """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 @@ def state_attributes(self) -> dict[str, Any]: 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 1f802f7fa36baf..35601cf2b1f04e 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_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 1c027ba22e62e9..f1f25101e93342 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -3,7 +3,11 @@ 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 @@ 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_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 05812e35a36281..c2c0378a746588 100644 --- a/homeassistant/components/humidifier/device_condition.py +++ b/homeassistant/components/humidifier/device_condition.py @@ -3,7 +3,10 @@ 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 @@ 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 5fbb248a8bc901..0d689a35318f65 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -26,14 +26,27 @@ 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 @@ 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 afaa05df462cbd..f06bf7ccd598a7 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": "[%key:common::state::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 85192e0b7e40e0..b36457324e1da8 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -70,7 +70,7 @@ class PowerviewSensorDescription( 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/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 36b1b5f927fa14..a50b2c4d09b65d 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -88,7 +88,7 @@ async def async_update_data(): so entities can quickly look up their data. """ - payload = {"station": station} + payload = {"station": {"id": station["id"], "type": station["type"]}} try: async with async_timeout.timeout(10): diff --git a/homeassistant/components/hvv_departures/manifest.json b/homeassistant/components/hvv_departures/manifest.json index c6d3060b4ee7b1..c18777613e81a5 100644 --- a/homeassistant/components/hvv_departures/manifest.json +++ b/homeassistant/components/hvv_departures/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hvv_departures", "iot_class": "cloud_polling", "loggers": ["pygti"], - "requirements": ["pygti==0.9.3"] + "requirements": ["pygti==0.9.4"] } diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index dfc69e5171011d..c58ae6e3931420 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 @@ -84,8 +84,10 @@ async def async_update(self, **kwargs: Any) -> None: departure_time_tz_berlin = departure_time.astimezone(BERLIN_TIME_ZONE) + station = self.config_entry.data[CONF_STATION] + payload = { - "station": self.config_entry.data[CONF_STATION], + "station": {"id": station["id"], "type": station["type"]}, "time": { "date": departure_time_tz_berlin.strftime("%d.%m.%Y"), "time": departure_time_tz_berlin.strftime("%H:%M"), diff --git a/homeassistant/components/hvv_departures/strings.json b/homeassistant/components/hvv_departures/strings.json index 90b53bc0e6443f..8f9c06f53fbb9b 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/__init__.py b/homeassistant/components/hydrawise/__init__.py index ba561a14f82de8..e09cabb74fc7fc 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -1,5 +1,6 @@ """Support for Hydrawise cloud.""" + from hydrawiser.core import Hydrawiser from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol @@ -8,19 +9,10 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType -from .const import ( - DATA_HYDRAWISE, - DOMAIN, - LOGGER, - NOTIFICATION_ID, - NOTIFICATION_TITLE, - SCAN_INTERVAL, - SIGNAL_UPDATE_HYDRAWISE, -) +from .const import DOMAIN, LOGGER, NOTIFICATION_ID, NOTIFICATION_TITLE, SCAN_INTERVAL +from .coordinator import HydrawiseDataUpdateCoordinator CONFIG_SCHEMA = vol.Schema( { @@ -35,40 +27,36 @@ ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Hunter Hydrawise component.""" conf = config[DOMAIN] access_token = conf[CONF_ACCESS_TOKEN] scan_interval = conf.get(CONF_SCAN_INTERVAL) try: - hydrawise = Hydrawiser(user_token=access_token) - hass.data[DATA_HYDRAWISE] = HydrawiseHub(hydrawise) + hydrawise = await hass.async_add_executor_job(Hydrawiser, access_token) except (ConnectTimeout, HTTPError) as ex: LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) - persistent_notification.create( - hass, - f"Error: {ex}
You will need to restart hass after fixing.", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) + _show_failure_notification(hass, str(ex)) return False - def hub_refresh(event_time): - """Call Hydrawise hub to refresh information.""" - LOGGER.debug("Updating Hydrawise Hub component") - hass.data[DATA_HYDRAWISE].data.update_controller_info() - dispatcher_send(hass, SIGNAL_UPDATE_HYDRAWISE) + if not hydrawise.current_controller: + LOGGER.error("Failed to fetch Hydrawise data") + _show_failure_notification(hass, "Failed to fetch Hydrawise data.") + return False - # Call the Hydrawise API to refresh updates - track_time_interval(hass, hub_refresh, scan_interval) + hass.data[DOMAIN] = HydrawiseDataUpdateCoordinator(hass, hydrawise, scan_interval) - return True + # NOTE: We don't need to call async_config_entry_first_refresh() because + # data is fetched when the Hydrawiser object is instantiated. + return True -class HydrawiseHub: - """Representation of a base Hydrawise device.""" - def __init__(self, data): - """Initialize the entity.""" - self.data = data +def _show_failure_notification(hass: HomeAssistant, error: str) -> None: + persistent_notification.create( + hass, + f"Error: {error}
You will need to restart hass after fixing.", + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID, + ) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 93594d7143631f..2986bbb170eafd 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -1,6 +1,7 @@ """Support for Hydrawise sprinkler binary sensors.""" from __future__ import annotations +from hydrawiser.core import Hydrawiser import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -10,12 +11,13 @@ BinarySensorEntityDescription, ) from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DATA_HYDRAWISE, LOGGER +from .const import DOMAIN, LOGGER +from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity BINARY_SENSOR_STATUS = BinarySensorEntityDescription( @@ -52,24 +54,30 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a sensor for a Hydrawise device.""" - hydrawise = hass.data[DATA_HYDRAWISE].data + coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] + hydrawise: Hydrawiser = coordinator.api monitored_conditions = config[CONF_MONITORED_CONDITIONS] entities = [] if BINARY_SENSOR_STATUS.key in monitored_conditions: entities.append( - HydrawiseBinarySensor(hydrawise.current_controller, BINARY_SENSOR_STATUS) + HydrawiseBinarySensor( + data=hydrawise.current_controller, + coordinator=coordinator, + description=BINARY_SENSOR_STATUS, + ) ) # create a sensor for each zone - entities.extend( - [ - HydrawiseBinarySensor(zone, description) - for zone in hydrawise.relays - for description in BINARY_SENSOR_TYPES - if description.key in monitored_conditions - ] - ) + for zone in hydrawise.relays: + for description in BINARY_SENSOR_TYPES: + if description.key not in monitored_conditions: + continue + entities.append( + HydrawiseBinarySensor( + data=zone, coordinator=coordinator, description=description + ) + ) add_entities(entities, True) @@ -77,12 +85,13 @@ def setup_platform( class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): """A sensor implementation for Hydrawise device.""" - def update(self) -> None: + @callback + def _handle_coordinator_update(self) -> None: """Get the latest data and updates the state.""" LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name) - mydata = self.hass.data[DATA_HYDRAWISE].data if self.entity_description.key == "status": - self._attr_is_on = mydata.status == "All good!" + self._attr_is_on = self.coordinator.api.status == "All good!" elif self.entity_description.key == "is_watering": - relay_data = mydata.relays[self.data["relay"] - 1] + relay_data = self.coordinator.api.relays[self.data["relay"] - 1] self._attr_is_on = relay_data["timestr"] == "Now" + super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index 5a046530f014c6..515fdaec2b1524 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -11,7 +11,6 @@ NOTIFICATION_ID = "hydrawise_notification" NOTIFICATION_TITLE = "Hydrawise Setup" -DATA_HYDRAWISE = "hydrawise" DOMAIN = "hydrawise" DEFAULT_WATERING_TIME = 15 diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py new file mode 100644 index 00000000000000..ea2e2dd2c4c017 --- /dev/null +++ b/homeassistant/components/hydrawise/coordinator.py @@ -0,0 +1,29 @@ +"""DataUpdateCoordinator for the Hydrawise integration.""" + +from __future__ import annotations + +from datetime import timedelta + +from hydrawiser.core import Hydrawiser + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[None]): + """The Hydrawise Data Update Coordinator.""" + + def __init__( + self, hass: HomeAssistant, api: Hydrawiser, scan_interval: timedelta + ) -> None: + """Initialize HydrawiseDataUpdateCoordinator.""" + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval) + self.api = api + + async def _async_update_data(self) -> None: + """Fetch the latest data from Hydrawise.""" + result = await self.hass.async_add_executor_job(self.api.update_controller_info) + if not result: + raise UpdateFailed("Failed to refresh Hydrawise data") diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 5c54c1ee58086b..98b66069913b85 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -1,37 +1,33 @@ """Base classes for Hydrawise entities.""" +from __future__ import annotations -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity, EntityDescription +from typing import Any -from .const import SIGNAL_UPDATE_HYDRAWISE +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .coordinator import HydrawiseDataUpdateCoordinator -class HydrawiseEntity(Entity): + +class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): """Entity class for Hydrawise devices.""" _attr_attribution = "Data provided by hydrawise.com" - def __init__(self, data, description: EntityDescription) -> None: + def __init__( + self, + *, + data: dict[str, Any], + coordinator: HydrawiseDataUpdateCoordinator, + description: EntityDescription, + ) -> None: """Initialize the Hydrawise entity.""" - self.entity_description = description + super().__init__(coordinator=coordinator) self.data = data + self.entity_description = description self._attr_name = f"{self.data['name']} {description.name}" - async def async_added_to_hass(self): - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_HYDRAWISE, self._update_callback - ) - ) - - @callback - def _update_callback(self): - """Call update method.""" - self.async_schedule_update_ha_state(True) - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return {"identifier": self.data.get("relay")} diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 2489317a6a2342..fc88c08b27ae02 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/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 2cec1309ec9577..d1334143375d53 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -1,6 +1,7 @@ """Support for Hydrawise sprinkler sensors.""" from __future__ import annotations +from hydrawiser.core import Hydrawiser import voluptuous as vol from homeassistant.components.sensor import ( @@ -10,13 +11,14 @@ SensorEntityDescription, ) from homeassistant.const import CONF_MONITORED_CONDITIONS, UnitOfTime -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt +from homeassistant.util import dt as dt_util -from .const import DATA_HYDRAWISE, LOGGER +from .const import DOMAIN, LOGGER +from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( @@ -54,11 +56,12 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a sensor for a Hydrawise device.""" - hydrawise = hass.data[DATA_HYDRAWISE].data + coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] + hydrawise: Hydrawiser = coordinator.api monitored_conditions = config[CONF_MONITORED_CONDITIONS] entities = [ - HydrawiseSensor(zone, description) + HydrawiseSensor(data=zone, coordinator=coordinator, description=description) for zone in hydrawise.relays for description in SENSOR_TYPES if description.key in monitored_conditions @@ -70,11 +73,11 @@ def setup_platform( class HydrawiseSensor(HydrawiseEntity, SensorEntity): """A sensor implementation for Hydrawise device.""" - def update(self) -> None: + @callback + def _handle_coordinator_update(self) -> None: """Get the latest data and updates the states.""" - mydata = self.hass.data[DATA_HYDRAWISE].data LOGGER.debug("Updating Hydrawise sensor: %s", self.name) - relay_data = mydata.relays[self.data["relay"] - 1] + relay_data = self.coordinator.api.relays[self.data["relay"] - 1] if self.entity_description.key == "watering_time": if relay_data["timestr"] == "Now": self._attr_native_value = int(relay_data["run"] / 60) @@ -83,6 +86,7 @@ def update(self) -> None: else: # _sensor_type == 'next_cycle' next_cycle = min(relay_data["time"], TWO_YEAR_SECONDS) LOGGER.debug("New cycle time: %s", next_cycle) - self._attr_native_value = dt.utc_from_timestamp( - dt.as_timestamp(dt.now()) + next_cycle + self._attr_native_value = dt_util.utc_from_timestamp( + dt_util.as_timestamp(dt_util.now()) + next_cycle ) + super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index ac9b0d27025c19..00089bb8774f75 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -3,6 +3,7 @@ from typing import Any +from hydrawiser.core import Hydrawiser import voluptuous as vol from homeassistant.components.switch import ( @@ -12,7 +13,7 @@ SwitchEntityDescription, ) from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -20,10 +21,11 @@ from .const import ( ALLOWED_WATERING_TIME, CONF_WATERING_TIME, - DATA_HYDRAWISE, DEFAULT_WATERING_TIME, + DOMAIN, LOGGER, ) +from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( @@ -60,12 +62,18 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a sensor for a Hydrawise device.""" - hydrawise = hass.data[DATA_HYDRAWISE].data - monitored_conditions = config[CONF_MONITORED_CONDITIONS] - default_watering_timer = config[CONF_WATERING_TIME] + coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] + hydrawise: Hydrawiser = coordinator.api + monitored_conditions: list[str] = config[CONF_MONITORED_CONDITIONS] + default_watering_timer: int = config[CONF_WATERING_TIME] entities = [ - HydrawiseSwitch(zone, description, default_watering_timer) + HydrawiseSwitch( + data=zone, + coordinator=coordinator, + description=description, + default_watering_timer=default_watering_timer, + ) for zone in hydrawise.relays for description in SWITCH_TYPES if description.key in monitored_conditions @@ -78,38 +86,41 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): """A switch implementation for Hydrawise device.""" def __init__( - self, data, description: SwitchEntityDescription, default_watering_timer + self, + *, + data: dict[str, Any], + coordinator: HydrawiseDataUpdateCoordinator, + description: SwitchEntityDescription, + default_watering_timer: int, ) -> None: """Initialize a switch for Hydrawise device.""" - super().__init__(data, description) + super().__init__(data=data, coordinator=coordinator, description=description) self._default_watering_timer = default_watering_timer def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" relay_data = self.data["relay"] - 1 if self.entity_description.key == "manual_watering": - self.hass.data[DATA_HYDRAWISE].data.run_zone( - self._default_watering_timer, relay_data - ) + self.coordinator.api.run_zone(self._default_watering_timer, relay_data) elif self.entity_description.key == "auto_watering": - self.hass.data[DATA_HYDRAWISE].data.suspend_zone(0, relay_data) + self.coordinator.api.suspend_zone(0, relay_data) def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" relay_data = self.data["relay"] - 1 if self.entity_description.key == "manual_watering": - self.hass.data[DATA_HYDRAWISE].data.run_zone(0, relay_data) + self.coordinator.api.run_zone(0, relay_data) elif self.entity_description.key == "auto_watering": - self.hass.data[DATA_HYDRAWISE].data.suspend_zone(365, relay_data) + self.coordinator.api.suspend_zone(365, relay_data) - def update(self) -> None: + @callback + def _handle_coordinator_update(self) -> None: """Update device state.""" relay_data = self.data["relay"] - 1 - mydata = self.hass.data[DATA_HYDRAWISE].data LOGGER.debug("Updating Hydrawise switch: %s", self.name) + timestr = self.coordinator.api.relays[relay_data]["timestr"] if self.entity_description.key == "manual_watering": - self._attr_is_on = mydata.relays[relay_data]["timestr"] == "Now" + self._attr_is_on = timestr == "Now" elif self.entity_description.key == "auto_watering": - self._attr_is_on = (mydata.relays[relay_data]["timestr"] != "") and ( - mydata.relays[relay_data]["timestr"] != "Now" - ) + self._attr_is_on = timestr not in {"", "Now"} + super()._handle_coordinator_update() diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 6c2842c190e45d..ea038b3b408535 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -19,7 +19,6 @@ 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 97e97cd835de34..52c4647f11c480 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -447,7 +447,7 @@ async def async_step_init( ) -> 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 e7e4e7f70a48dc..4585b8bedaac5a 100644 --- a/homeassistant/components/hyperion/const.py +++ b/homeassistant/components/hyperion/const.py @@ -21,8 +21,6 @@ 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 @@ 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 ba1dbfbafc2d9f..e14c395315eacd 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -41,24 +41,19 @@ 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 @@ 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 @@ def instance_add(instance_num: int, instance_name: str) -> None: async_add_entities( [ HyperionLight(*args), - HyperionPriorityLight(*args), ] ) @@ -110,19 +103,18 @@ def instance_add(instance_num: int, instance_name: str) -> None: 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 @@ def __init__( 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 @@ def __init__( 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 @@ def hs_color(self) -> tuple[float, float]: 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 @@ def _get_option(self, key: str) -> Any: } 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 @@ async def async_turn_on(self, **kwargs: Any) -> None: ): 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 @@ async def async_turn_on(self, **kwargs: Any) -> None: } ): 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 @@ def _update_adjustment(self, _: dict[str, Any] | None = None) -> None: 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 @@ async def async_will_remove_from_hass(self) -> None: """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 e0b381d2362dbd..5735e1ab421c35 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -6,7 +6,7 @@ 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 @@ def device_info(self) -> DeviceInfo: 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 4c9337e54ce57b..8e194ac27b110c 100644 --- a/homeassistant/components/ibeacon/device_tracker.py +++ b/homeassistant/components/ibeacon/device_tracker.py @@ -48,6 +48,8 @@ def _async_device_new( 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 a805277cb71fd7..6f00f63b0909af 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 c0b9e92decc238..b3895ce23b402c 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 @@ class IBeaconSensorEntityDescription(SensorEntityDescription, IBeaconRequiredKey ), 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 @@ class IBeaconSensorEntityDescription(SensorEntityDescription, IBeaconRequiredKey ), 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 @@ class IBeaconSensorEntityDescription(SensorEntityDescription, IBeaconRequiredKey ), 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 b91ba459bd7299..be3f7020cbe918 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 6cdde2249c8d2f..4be5487f755cb5 100644 --- a/homeassistant/components/icloud/config_flow.py +++ b/homeassistant/components/icloud/config_flow.py @@ -263,13 +263,12 @@ async def async_step_verification_code(self, user_input=None, errors=None): 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 0fc69a7ba192ef..6eeea6b4a02bcc 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 089317ca7d733e..b469cb54aee162 100644 --- a/homeassistant/components/ihc/light.py +++ b/homeassistant/components/ihc/light.py @@ -96,29 +96,26 @@ async def async_turn_on(self, **kwargs: Any) -> None: """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 00000000000000..8daea2cdd46051 --- /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 00000000000000..d262bb460f701e --- /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 00000000000000..0335710a30bb48 --- /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 00000000000000..5c14122088100c --- /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 00000000000000..ea7ecd1695627b --- /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 17c40cfc8753f4..569df9c65e408c 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -8,16 +8,16 @@ 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 from homeassistant.components.http.view import HomeAssistantView from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import collection +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util @@ -38,6 +38,8 @@ vol.Optional("name"): vol.All(str, vol.Length(min=1)), } +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Image integration.""" diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index 947c3cb67d565f..48c57fb5d036fd 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/__init__.py b/homeassistant/components/imap/__init__.py index 468181be5f7c24..04069d42d7d2ab 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator_class = ImapPollingDataUpdateCoordinator coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = ( - coordinator_class(hass, imap_client) + coordinator_class(hass, imap_client, entry) ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 8724dbf97c01cc..00be545fb67ddb 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -10,19 +10,29 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME -from homeassistant.core import callback +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( + BooleanSelector, SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TemplateSelector, + TemplateSelectorConfig, ) from homeassistant.util.ssl import SSLCipherList from .const import ( CONF_CHARSET, + CONF_CUSTOM_EVENT_DATA_TEMPLATE, CONF_FOLDER, CONF_MAX_MESSAGE_SIZE, CONF_SEARCH, @@ -36,6 +46,7 @@ from .coordinator import connect_to_server from .errors import InvalidAuth, InvalidFolder +BOOLEAN_SELECTOR = BooleanSelector() CIPHER_SELECTOR = SelectSelector( SelectSelectorConfig( options=list(SSLCipherList), @@ -43,6 +54,7 @@ translation_key=CONF_SSL_CIPHER_LIST, ) ) +TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) CONFIG_SCHEMA = vol.Schema( { @@ -59,6 +71,7 @@ vol.Optional( CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT ): CIPHER_SELECTOR, + vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR, } OPTIONS_SCHEMA = vol.Schema( @@ -69,14 +82,17 @@ ) OPTIONS_SCHEMA_ADVANCED = { + vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR, vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All( cv.positive_int, vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT), - ) + ), } -async def validate_input(user_input: dict[str, Any]) -> dict[str, str]: +async def validate_input( + hass: HomeAssistant, user_input: dict[str, Any] +) -> dict[str, str]: """Validate user input.""" errors = {} @@ -104,6 +120,7 @@ async def validate_input(user_input: dict[str, Any]) -> dict[str, str]: errors[CONF_CHARSET] = "invalid_charset" else: errors[CONF_SEARCH] = "invalid_search" + return errors @@ -131,7 +148,7 @@ async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: } ) title = user_input[CONF_NAME] - if await validate_input(data): + if await validate_input(self.hass, data): raise AbortFlow("cannot_connect") return self.async_create_entry(title=title, data=data) @@ -154,12 +171,12 @@ async def async_step_user( } ) - if not (errors := await validate_input(user_input)): + if not (errors := await validate_input(self.hass, user_input)): title = user_input[CONF_USERNAME] return self.async_create_entry(title=title, data=user_input) - schema = self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input) + schema = self.add_suggested_values_to_schema(schema, user_input) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: @@ -177,7 +194,7 @@ async def async_step_reauth_confirm( assert self._reauth_entry if user_input is not None: user_input = {**self._reauth_entry.data, **user_input} - if not (errors := await validate_input(user_input)): + if not (errors := await validate_input(self.hass, user_input)): self.hass.config_entries.async_update_entry( self._reauth_entry, data=user_input ) @@ -231,7 +248,7 @@ async def async_step_init( errors = {"base": err.reason} else: entry_data.update(user_input) - errors = await validate_input(entry_data) + errors = await validate_input(self.hass, entry_data) if not errors: self.hass.config_entries.async_update_entry( self.config_entry, data=entry_data diff --git a/homeassistant/components/imap/const.py b/homeassistant/components/imap/const.py index b39c88086331a4..2e36dd41e16856 100644 --- a/homeassistant/components/imap/const.py +++ b/homeassistant/components/imap/const.py @@ -9,6 +9,7 @@ CONF_SEARCH: Final = "search" CONF_CHARSET: Final = "charset" CONF_MAX_MESSAGE_SIZE = "max_message_size" +CONF_CUSTOM_EVENT_DATA_TEMPLATE: Final = "custom_event_data_template" CONF_SSL_CIPHER_LIST: Final = "ssl_cipher_list" DEFAULT_PORT: Final = 993 diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 512df9adf51c00..bf7f173e647659 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -5,6 +5,8 @@ from collections.abc import Mapping from datetime import datetime, timedelta import email +from email.header import decode_header, make_header +from email.utils import parseaddr, parsedate_to_datetime import logging from typing import Any @@ -16,16 +18,27 @@ CONF_PASSWORD, CONF_PORT, CONF_USERNAME, + CONF_VERIFY_SSL, CONTENT_TYPE_TEXT_PLAIN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + TemplateError, +) from homeassistant.helpers.json import json_bytes +from homeassistant.helpers.template import Template from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.ssl import SSLCipherList, client_context +from homeassistant.util.ssl import ( + SSLCipherList, + client_context, + create_no_verify_ssl_context, +) from .const import ( CONF_CHARSET, + CONF_CUSTOM_EVENT_DATA_TEMPLATE, CONF_FOLDER, CONF_MAX_MESSAGE_SIZE, CONF_SEARCH, @@ -46,9 +59,11 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" - ssl_context = client_context( - ssl_cipher_list=data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT) - ) + ssl_cipher_list: str = data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT) + if data.get(CONF_VERIFY_SSL, True): + ssl_context = client_context(ssl_cipher_list=ssl_cipher_list) + else: + ssl_context = create_no_verify_ssl_context() client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT], ssl_context=ssl_context) await client.wait_hello_from_server() @@ -76,9 +91,9 @@ def headers(self) -> dict[str, tuple[str,]]: """Get the email headers.""" header_base: dict[str, tuple[str,]] = {} for key, value in self.email_message.items(): - header: tuple[str,] = (str(value),) - if header_base.setdefault(key, header) != header: - header_base[key] += header # type: ignore[assignment] + header_instances: tuple[str,] = (str(value),) + if header_base.setdefault(key, header_instances) != header_instances: + header_base[key] += header_instances # type: ignore[assignment] return header_base @property @@ -88,23 +103,26 @@ def date(self) -> datetime | None: date_str: str | None if (date_str := self.email_message["Date"]) is None: return None - # In some cases a timezone or comment is added in parenthesis after the date - # We want to strip that part to avoid parsing errors - return datetime.strptime( - date_str.split("(")[0].strip(), "%a, %d %b %Y %H:%M:%S %z" - ) + try: + mail_dt_tm = parsedate_to_datetime(date_str) + except ValueError: + _LOGGER.debug( + "Parsed date %s is not compliant with rfc2822#section-3.3", date_str + ) + return None + return mail_dt_tm @property def sender(self) -> str: """Get the parsed message sender from the email.""" - return str(email.utils.parseaddr(self.email_message["From"])[1]) + return str(parseaddr(self.email_message["From"])[1]) @property def subject(self) -> str: """Decode the message subject.""" - decoded_header = email.header.decode_header(self.email_message["Subject"]) - header = email.header.make_header(decoded_header) - return str(header) + decoded_header = decode_header(self.email_message["Subject"] or "") + subject_header = make_header(decoded_header) + return str(subject_header) @property def text(self) -> str: @@ -145,16 +163,22 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Base class for imap client.""" config_entry: ConfigEntry + custom_event_template: Template | None def __init__( self, hass: HomeAssistant, imap_client: IMAP4_SSL, + entry: ConfigEntry, update_interval: timedelta | None, ) -> None: """Initiate imap client.""" self.imap_client = imap_client self._last_message_id: str | None = None + self.custom_event_template = None + _custom_event_template = entry.data.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE) + if _custom_event_template is not None: + self.custom_event_template = Template(_custom_event_template, hass=hass) super().__init__( hass, _LOGGER, @@ -181,15 +205,36 @@ async def _async_process_event(self, last_message_id: str) -> None: "search": self.config_entry.data[CONF_SEARCH], "folder": self.config_entry.data[CONF_FOLDER], "date": message.date, - "text": message.text[ - : self.config_entry.data.get( - CONF_MAX_MESSAGE_SIZE, DEFAULT_MAX_MESSAGE_SIZE - ) - ], + "text": message.text, "sender": message.sender, "subject": message.subject, "headers": message.headers, } + if self.custom_event_template is not None: + try: + data["custom"] = self.custom_event_template.async_render( + data, parse_result=True + ) + _LOGGER.debug( + "imap custom template (%s) for msgid %s rendered to: %s", + self.custom_event_template, + last_message_id, + data["custom"], + ) + except TemplateError as err: + data["custom"] = None + _LOGGER.error( + "Error rendering imap custom template (%s) for msgid %s " + "failed with message: %s", + self.custom_event_template, + last_message_id, + err, + ) + data["text"] = message.text[ + : self.config_entry.data.get( + CONF_MAX_MESSAGE_SIZE, DEFAULT_MAX_MESSAGE_SIZE + ) + ] if (size := len(json_bytes(data))) > MAX_EVENT_DATA_BYTES: _LOGGER.warning( "Custom imap_content event skipped, size (%s) exceeds " @@ -203,7 +248,8 @@ async def _async_process_event(self, last_message_id: str) -> None: self.hass.bus.fire(EVENT_IMAP, data) _LOGGER.debug( - "Message processed, sender: %s, subject: %s", + "Message with id %s processed, sender: %s, subject: %s", + last_message_id, message.sender, message.subject, ) @@ -260,9 +306,11 @@ async def shutdown(self, *_: Any) -> None: class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): """Class for imap client.""" - def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None: + def __init__( + self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry + ) -> None: """Initiate imap client.""" - super().__init__(hass, imap_client, timedelta(seconds=10)) + super().__init__(hass, imap_client, entry, timedelta(seconds=10)) async def _async_update_data(self) -> int | None: """Update the number of unread emails.""" @@ -291,9 +339,11 @@ async def _async_update_data(self) -> int | None: class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): """Class for imap client.""" - def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None: + def __init__( + self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry + ) -> None: """Initiate imap client.""" - super().__init__(hass, imap_client, None) + super().__init__(hass, imap_client, entry, None) self._push_wait_task: asyncio.Task[None] | None = None async def _async_update_data(self) -> int | None: diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index 39dfc6c0d48772..3c35d00f714627 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 776abc174a2c60..cd6da667ccbe28 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 . 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 6e97fbe69d84a2..6fad88959310a2 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -10,7 +10,8 @@ "charset": "Character set", "folder": "Folder", "search": "IMAP search", - "ssl_cipher_list": "SSL cipher list (Advanced)" + "ssl_cipher_list": "SSL cipher list (Advanced)", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } }, "reauth_confirm": { @@ -40,6 +41,7 @@ "data": { "folder": "[%key:component::imap::config::step::user::data::folder%]", "search": "[%key:component::imap::config::step::user::data::search%]", + "custom_event_data_template": "Template to create custom event data", "max_message_size": "Max message size (2048 < size < 30000)" } } diff --git a/homeassistant/components/imap_email_content/__init__.py b/homeassistant/components/imap_email_content/__init__.py index 1a148f4591bf57..f2041b947df6d6 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 0205e690c429f4..9e8cabbe2536a3 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -28,10 +28,9 @@ class IncomfortSensorEntityDescription(SensorEntityDescription): """Describes Incomfort sensor entity.""" extra_key: str | None = None - # IncomfortSensor does not support DEVICE_CLASS_NAME - # Restrict the type to satisfy the type checker and catch attempts - # to use DEVICE_CLASS_NAME in the entity descriptions. - name: 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 8fde1c2d8bed4e..f879ab37e8fcc6 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 67aaae225a8fe7..b4f643e876f18f 100644 --- a/homeassistant/components/influxdb/sensor.py +++ b/homeassistant/components/influxdb/sensor.py @@ -339,7 +339,7 @@ def update(self): 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 c51c0fdd67c4cc..8762769194fa80 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -272,7 +272,7 @@ async def async_added_to_hass(self): if self.state is not None: return - default_value = py_datetime.datetime.today().strftime("%Y-%m-%d 00:00:00") + default_value = py_datetime.datetime.today().strftime(f"{FMT_DATE} 00:00:00") # Priority 2: Old state if (old_state := await self.async_get_last_state()) is None: @@ -292,13 +292,12 @@ async def async_added_to_hass(self): 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/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 186ab84fb81a18..2c5a1c87f29e90 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -302,12 +302,9 @@ def extra_state_attributes(self) -> dict[str, bool]: async def async_select_option(self, option: str) -> None: """Select new option.""" if option not in self.options: - _LOGGER.warning( - "Invalid option: %s (possible options: %s)", - option, - ", ".join(self.options), + raise HomeAssistantError( + f"Invalid option: {option} (possible options: {', '.join(self.options)})" ) - return self._attr_current_option = option self.async_write_ha_state() diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index 1667f5fb779a5c..a074ad4600ba61 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -9,13 +9,12 @@ 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 @@ DOMAIN, INSTEON_PLATFORMS, ) -from .schemas import convert_yaml_to_config_flow from .utils import ( add_insteon_events, async_register_services, @@ -36,6 +34,8 @@ _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/binary_sensor.py b/homeassistant/components/insteon/binary_sensor.py index 9d1ec352bedc2d..f895b9c7f6aab6 100644 --- a/homeassistant/components/insteon/binary_sensor.py +++ b/homeassistant/components/insteon/binary_sensor.py @@ -25,7 +25,7 @@ from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity -from .utils import async_add_insteon_entities +from .utils import async_add_insteon_devices, async_add_insteon_entities SENSOR_TYPES = { OPEN_CLOSE_SENSOR: BinarySensorDeviceClass.OPENING, @@ -62,7 +62,12 @@ def async_add_insteon_binary_sensor_entities(discovery_info=None): signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.BINARY_SENSOR}" async_dispatcher_connect(hass, signal, async_add_insteon_binary_sensor_entities) - async_add_insteon_binary_sensor_entities() + async_add_insteon_devices( + hass, + Platform.BINARY_SENSOR, + InsteonBinarySensorEntity, + async_add_entities, + ) class InsteonBinarySensorEntity(InsteonEntity, BinarySensorEntity): diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index cf5f4ac2c0cba3..48ff898d6aa692 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -23,7 +23,7 @@ from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity -from .utils import async_add_insteon_entities +from .utils import async_add_insteon_devices, async_add_insteon_entities FAN_ONLY = "fan_only" @@ -71,7 +71,12 @@ def async_add_insteon_climate_entities(discovery_info=None): signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.CLIMATE}" async_dispatcher_connect(hass, signal, async_add_insteon_climate_entities) - async_add_insteon_climate_entities() + async_add_insteon_devices( + hass, + Platform.CLIMATE, + InsteonClimateEntity, + async_add_entities, + ) class InsteonClimateEntity(InsteonEntity, ClimateEntity): diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index 15ce7c849e6135..f5bafd935a0f74 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -163,25 +163,14 @@ async def _async_setup_hub(self, hub_version, user_input): 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/cover.py b/homeassistant/components/insteon/cover.py index 69a66d304ced1b..0756e6035794d9 100644 --- a/homeassistant/components/insteon/cover.py +++ b/homeassistant/components/insteon/cover.py @@ -15,7 +15,7 @@ from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity -from .utils import async_add_insteon_entities +from .utils import async_add_insteon_devices, async_add_insteon_entities async def async_setup_entry( @@ -34,7 +34,12 @@ def async_add_insteon_cover_entities(discovery_info=None): signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.COVER}" async_dispatcher_connect(hass, signal, async_add_insteon_cover_entities) - async_add_insteon_cover_entities() + async_add_insteon_devices( + hass, + Platform.COVER, + InsteonCoverEntity, + async_add_entities, + ) class InsteonCoverEntity(InsteonEntity, CoverEntity): diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index b0d664a821b811..92f56098a917b0 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -17,7 +17,7 @@ from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity -from .utils import async_add_insteon_entities +from .utils import async_add_insteon_devices, async_add_insteon_entities SPEED_RANGE = (1, 255) # off is not included @@ -38,7 +38,12 @@ def async_add_insteon_fan_entities(discovery_info=None): signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.FAN}" async_dispatcher_connect(hass, signal, async_add_insteon_fan_entities) - async_add_insteon_fan_entities() + async_add_insteon_devices( + hass, + Platform.FAN, + InsteonFanEntity, + async_add_entities, + ) class InsteonFanEntity(InsteonEntity, FanEntity): diff --git a/homeassistant/components/insteon/ipdb.py b/homeassistant/components/insteon/ipdb.py index ee799e103f923b..de3ba7d55f20fc 100644 --- a/homeassistant/components/insteon/ipdb.py +++ b/homeassistant/components/insteon/ipdb.py @@ -1,4 +1,7 @@ """Utility methods for the Insteon platform.""" +from collections.abc import Iterable + +from pyinsteon.device_types.device_base import Device from pyinsteon.device_types.ipdb import ( AccessControl_Morningstar, ClimateControl_Thermostat, @@ -44,7 +47,7 @@ from homeassistant.const import Platform -DEVICE_PLATFORM = { +DEVICE_PLATFORM: dict[Device, dict[Platform, Iterable[int]]] = { AccessControl_Morningstar: {Platform.LOCK: [1]}, DimmableLightingControl: {Platform.LIGHT: [1]}, DimmableLightingControl_Dial: {Platform.LIGHT: [1]}, @@ -101,11 +104,11 @@ } -def get_device_platforms(device): +def get_device_platforms(device) -> dict[Platform, Iterable[int]]: """Return the HA platforms for a device type.""" - return DEVICE_PLATFORM.get(type(device), {}).keys() + return DEVICE_PLATFORM.get(type(device), {}) -def get_platform_groups(device, domain) -> dict: - """Return the platforms that a device belongs in.""" - return DEVICE_PLATFORM.get(type(device), {}).get(domain, {}) # type: ignore[attr-defined] +def get_device_platform_groups(device: Device, platform: Platform) -> Iterable[int]: + """Return the list of device groups for a platform.""" + return get_device_platforms(device).get(platform, []) diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py index 44574c696b4e82..1c12bc794f9b48 100644 --- a/homeassistant/components/insteon/light.py +++ b/homeassistant/components/insteon/light.py @@ -12,7 +12,7 @@ from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity -from .utils import async_add_insteon_entities +from .utils import async_add_insteon_devices, async_add_insteon_entities MAX_BRIGHTNESS = 255 @@ -37,7 +37,12 @@ def async_add_insteon_light_entities(discovery_info=None): signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.LIGHT}" async_dispatcher_connect(hass, signal, async_add_insteon_light_entities) - async_add_insteon_light_entities() + async_add_insteon_devices( + hass, + Platform.LIGHT, + InsteonDimmerEntity, + async_add_entities, + ) class InsteonDimmerEntity(InsteonEntity, LightEntity): diff --git a/homeassistant/components/insteon/lock.py b/homeassistant/components/insteon/lock.py index 75487e7696cd78..27fb0fd42d873e 100644 --- a/homeassistant/components/insteon/lock.py +++ b/homeassistant/components/insteon/lock.py @@ -11,7 +11,7 @@ from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity -from .utils import async_add_insteon_entities +from .utils import async_add_insteon_devices, async_add_insteon_entities async def async_setup_entry( @@ -30,7 +30,12 @@ def async_add_insteon_lock_entities(discovery_info=None): signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.LOCK}" async_dispatcher_connect(hass, signal, async_add_insteon_lock_entities) - async_add_insteon_lock_entities() + async_add_insteon_devices( + hass, + Platform.LOCK, + InsteonLockEntity, + async_add_entities, + ) class InsteonLockEntity(InsteonEntity, LockEntity): diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index cc8495384b1b1c..ad3fb7bfbe81fd 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["pyinsteon", "pypubsub"], "requirements": [ - "pyinsteon==1.4.2", + "pyinsteon==1.4.3", "insteon-frontend-home-assistant==0.3.5" ], "usb": [ diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index 84b586e76494c7..e6b22a8cbb964e 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -22,26 +22,13 @@ 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 @@ 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 @@ def insteon_address(value: str) -> str: 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/insteon/switch.py b/homeassistant/components/insteon/switch.py index 8f7c396f21331e..8acde0429cd4b1 100644 --- a/homeassistant/components/insteon/switch.py +++ b/homeassistant/components/insteon/switch.py @@ -10,7 +10,7 @@ from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity -from .utils import async_add_insteon_entities +from .utils import async_add_insteon_devices, async_add_insteon_entities async def async_setup_entry( @@ -33,7 +33,12 @@ def async_add_insteon_switch_entities(discovery_info=None): signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.SWITCH}" async_dispatcher_connect(hass, signal, async_add_insteon_switch_entities) - async_add_insteon_switch_entities() + async_add_insteon_devices( + hass, + Platform.SWITCH, + InsteonSwitchEntity, + async_add_entities, + ) class InsteonSwitchEntity(InsteonEntity, SwitchEntity): diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index 58b2430092cbe1..d7cbe676eee501 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -1,7 +1,10 @@ """Utilities used by insteon component.""" +from __future__ import annotations + import asyncio from collections.abc import Callable import logging +from typing import TYPE_CHECKING, Any from pyinsteon import devices from pyinsteon.address import Address @@ -30,6 +33,7 @@ CONF_ENTITY_ID, CONF_PLATFORM, ENTITY_MATCH_ALL, + Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr @@ -38,6 +42,7 @@ async_dispatcher_send, dispatcher_send, ) +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( CONF_CAT, @@ -78,7 +83,7 @@ SRV_X10_ALL_LIGHTS_ON, SRV_X10_ALL_UNITS_OFF, ) -from .ipdb import get_device_platforms, get_platform_groups +from .ipdb import get_device_platform_groups, get_device_platforms from .schemas import ( ADD_ALL_LINK_SCHEMA, ADD_DEFAULT_LINKS_SCHEMA, @@ -89,6 +94,9 @@ X10_HOUSECODE_SCHEMA, ) +if TYPE_CHECKING: + from .insteon_entity import InsteonEntity + _LOGGER = logging.getLogger(__name__) @@ -107,8 +115,8 @@ def add_insteon_events(hass: HomeAssistant, device: Device) -> None: """Register Insteon device events.""" @callback - def async_fire_group_on_off_event( - name: str, address: Address, group: int, button: str + def async_fire_insteon_event( + name: str, address: Address, group: int, button: str | None = None ): # Firing an event when a button is pressed. if button and button[-2] == "_": @@ -132,12 +140,15 @@ def async_fire_group_on_off_event( _LOGGER.debug("Firing event %s with %s", event, schema) hass.bus.async_fire(event, schema) + if str(device.address).startswith("X10"): + return + for name_or_group, event in device.events.items(): if isinstance(name_or_group, int): for _, event in device.events[name_or_group].items(): - _register_event(event, async_fire_group_on_off_event) + _register_event(event, async_fire_insteon_event) else: - _register_event(event, async_fire_group_on_off_event) + _register_event(event, async_fire_insteon_event) def register_new_device_callback(hass): @@ -158,8 +169,10 @@ async def async_create_new_entities(address): await device.async_status() platforms = get_device_platforms(device) for platform in platforms: + groups = get_device_platform_groups(device, platform) signal = f"{SIGNAL_ADD_ENTITIES}_{platform}" - dispatcher_send(hass, signal, {"address": device.address}) + dispatcher_send(hass, signal, {"address": device.address, "groups": groups}) + add_insteon_events(hass, device) devices.subscribe(async_new_insteon_device, force_strong_ref=True) @@ -383,18 +396,36 @@ def print_aldb_to_log(aldb): @callback def async_add_insteon_entities( - hass, platform, entity_type, async_add_entities, discovery_info -): - """Add Insteon devices to a platform.""" - new_entities = [] - device_list = [discovery_info.get("address")] if discovery_info else devices + hass: HomeAssistant, + platform: Platform, + entity_type: type[InsteonEntity], + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any], +) -> None: + """Add an Insteon group to a platform.""" + address = discovery_info["address"] + device = devices[address] + new_entities = [ + entity_type(device=device, group=group) for group in discovery_info["groups"] + ] + async_add_entities(new_entities) - for address in device_list: + +@callback +def async_add_insteon_devices( + hass: HomeAssistant, + platform: Platform, + entity_type: type[InsteonEntity], + async_add_entities: AddEntitiesCallback, +) -> None: + """Add all entities to a platform.""" + for address in devices: device = devices[address] - groups = get_platform_groups(device, platform) - for group in groups: - new_entities.append(entity_type(device, group)) - async_add_entities(new_entities) + groups = get_device_platform_groups(device, platform) + discovery_info = {"address": address, "groups": groups} + async_add_insteon_entities( + hass, platform, entity_type, async_add_entities, discovery_info + ) def get_usb_ports() -> dict[str, str]: diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 64d83506ad95d3..af4248e5e3ba45 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -1,16 +1,19 @@ """Numeric integration of data coming from a source sensor over time.""" from __future__ import annotations -from decimal import Decimal, DecimalException +from dataclasses import dataclass +from decimal import Decimal, DecimalException, InvalidOperation import logging -from typing import Final +from typing import Any, Final +from typing_extensions import Self import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, + RestoreSensor, SensorDeviceClass, - SensorEntity, + SensorExtraStoredData, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -25,10 +28,14 @@ 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.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -79,6 +86,53 @@ ) +@dataclass +class IntegrationSensorExtraStoredData(SensorExtraStoredData): + """Object to hold extra stored data.""" + + source_entity: str | None + last_valid_state: Decimal | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the utility sensor data.""" + data = super().as_dict() + data["source_entity"] = self.source_entity + data["last_valid_state"] = ( + str(self.last_valid_state) if self.last_valid_state else None + ) + return data + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored sensor state from a dict.""" + extra = SensorExtraStoredData.from_dict(restored) + if extra is None: + return None + + source_entity = restored.get(ATTR_SOURCE_ID) + + try: + last_valid_state = ( + Decimal(str(restored.get("last_valid_state"))) + if restored.get("last_valid_state") + else None + ) + except InvalidOperation: + # last_period is corrupted + _LOGGER.error("Could not use last_valid_state") + return None + + if last_valid_state is None: + return None + + return cls( + extra.native_value, + extra.native_unit_of_measurement, + source_entity, + last_valid_state, + ) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -91,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 @@ -103,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]) @@ -128,7 +205,8 @@ async def async_setup_platform( async_add_entities([integral]) -class IntegrationSensor(RestoreEntity, SensorEntity): +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class IntegrationSensor(RestoreSensor): """Representation of an integration sensor.""" _attr_state_class = SensorStateClass.TOTAL @@ -144,6 +222,7 @@ def __init__( 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 @@ -159,7 +238,9 @@ def __init__( self._unit_time = UNIT_TIME[unit_time] self._unit_time_str = unit_time self._attr_icon = "mdi:chart-histogram" - self._attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity} + self._source_entity: str = source_entity + self._last_valid_state: Decimal | None = None + self._attr_device_info = device_info def _unit(self, source_unit: str) -> str: """Derive unit from the source sensor, SI prefix and time unit.""" @@ -174,10 +255,28 @@ def _unit(self, source_unit: str) -> str: async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() - if (state := await self.async_get_last_state()) is not None: - if state.state == STATE_UNAVAILABLE: - self._attr_available = False - elif state.state != STATE_UNKNOWN: + + if (last_sensor_data := await self.async_get_last_sensor_data()) is not None: + self._state = ( + Decimal(str(last_sensor_data.native_value)) + if last_sensor_data.native_value + else last_sensor_data.last_valid_state + ) + self._attr_native_value = last_sensor_data.native_value + self._unit_of_measurement = last_sensor_data.native_unit_of_measurement + self._last_valid_state = last_sensor_data.last_valid_state + + _LOGGER.debug( + "Restored state %s and last_valid_state %s", + self._state, + self._last_valid_state, + ) + elif (state := await self.async_get_last_state()) is not None: + # legacy to be removed on 2023.10 (we are keeping this to avoid losing data during the transition) + if state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + if state.state == STATE_UNAVAILABLE: + self._attr_available = False + else: try: self._state = Decimal(state.state) except (DecimalException, ValueError) as err: @@ -294,6 +393,7 @@ def calc_integration(event: Event) -> None: self._state += integral else: self._state = integral + self._last_valid_state = self._state self.async_write_ha_state() self.async_on_remove( @@ -313,3 +413,33 @@ def native_value(self) -> Decimal | None: def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" return self._unit_of_measurement + + @property + def extra_state_attributes(self) -> dict[str, str] | None: + """Return the state attributes of the sensor.""" + state_attr = { + ATTR_SOURCE_ID: self._source_entity, + } + + return state_attr + + @property + def extra_restore_state_data(self) -> IntegrationSensorExtraStoredData: + """Return sensor specific state data to be restored.""" + return IntegrationSensorExtraStoredData( + self.native_value, + self.native_unit_of_measurement, + self._source_entity, + self._last_valid_state, + ) + + async def async_get_last_sensor_data( + self, + ) -> IntegrationSensorExtraStoredData | None: + """Restore Utility Meter Sensor Extra Stored Data.""" + if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: + return None + + return IntegrationSensorExtraStoredData.from_dict( + restored_last_extra_data.as_dict() + ) diff --git a/homeassistant/components/intellifire/climate.py b/homeassistant/components/intellifire/climate.py index 649234d7568455..5d305db8febcfd 100644 --- a/homeassistant/components/intellifire/climate.py +++ b/homeassistant/components/intellifire/climate.py @@ -66,7 +66,7 @@ def __init__( 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 2f5ea26a8a65a4..b2c77fed3af135 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -29,6 +29,8 @@ _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Intent component.""" @@ -68,18 +70,22 @@ async def async_call_service(self, intent_obj: intent.Intent, state: State) -> N 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 2ec898bfb0eb93..55c4947fe4a04d 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -5,9 +5,16 @@ 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 @@ ) -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 00000000000000..bb981dbc69cb4b --- /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/ios/sensor.py b/homeassistant/components/ios/sensor.py index f4dab9e301bad8..f3767be9f3d5ed 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -1,7 +1,11 @@ """Support for Home Assistant iOS app sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant, callback @@ -17,12 +21,12 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="level", - name="Battery Level", native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, ), SensorEntityDescription( key="state", - name="Battery State", + translation_key="battery_state", ), ) @@ -59,6 +63,7 @@ class IOSSensor(SensorEntity): """Representation of an iOS sensor.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, device_name, device, description: SensorEntityDescription @@ -67,9 +72,6 @@ def __init__( self.entity_description = description self._device = device - device_name = device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_NAME] - self._attr_name = f"{device_name} {description.key}" - device_id = device[ios.ATTR_DEVICE_ID] self._attr_unique_id = f"{description.key}_{device_id}" diff --git a/homeassistant/components/ios/strings.json b/homeassistant/components/ios/strings.json index 2b486cc0c04834..6c77209e3171b1 100644 --- a/homeassistant/components/ios/strings.json +++ b/homeassistant/components/ios/strings.json @@ -8,5 +8,12 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "entity": { + "sensor": { + "battery_state": { + "name": "Battery state" + } + } } } diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json index 1a25a26ee35fa8..5beaa1e318ca6b 100644 --- a/homeassistant/components/iotawatt/manifest.json +++ b/homeassistant/components/iotawatt/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/iotawatt", "iot_class": "local_polling", "loggers": ["iotawattpy"], - "requirements": ["iotawattpy==0.1.0"] + "requirements": ["ha-iotawattpy==0.1.1"] } diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 849a2055ce32d2..b616c7e4ae9b9c 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS from .coordinator import IotawattUpdater @@ -203,7 +203,7 @@ def _handle_coordinator_update(self) -> None: return if (begin := self._sensor_data.getBegin()) and ( - last_reset := dt.parse_datetime(begin) + last_reset := dt_util.parse_datetime(begin) ): self._attr_last_reset = last_reset diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index e39d1e1d864c3d..d3db0e76631422 100644 --- a/homeassistant/components/iperf3/sensor.py +++ b/homeassistant/components/iperf3/sensor.py @@ -35,6 +35,7 @@ async def async_setup_platform( async_add_entities(entities, True) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Iperf3Sensor(RestoreEntity, SensorEntity): """A Iperf3 sensor implementation.""" diff --git a/homeassistant/components/ipma/__init__.py b/homeassistant/components/ipma/__init__.py index 866e79cbe404e2..dd46593998e6bc 100644 --- a/homeassistant/components/ipma/__init__.py +++ b/homeassistant/components/ipma/__init__.py @@ -18,7 +18,7 @@ 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 81ab8f98014246..eb361d3f9d5b5d 100644 --- a/homeassistant/components/ipma/config_flow.py +++ b/homeassistant/components/ipma/config_flow.py @@ -5,8 +5,7 @@ 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 515fb501fbd03c..2d715011e4326f 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_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 00000000000000..bc8136b620605b --- /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 00000000000000..f02f8b7d9d054f --- /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 0dd013135dc0f0..b9f50c66f9ee82 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 bfd1b820c7a625..811eddf91bf2ca 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.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.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 @@ def _async_migrator(entity_entry: er.RegistryEntry): 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 @@ async def async_update(self) -> None: 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/__init__.py b/homeassistant/components/ipp/__init__.py index 42dc2b8d93b9c9..9df377b939a488 100644 --- a/homeassistant/components/ipp/__init__.py +++ b/homeassistant/components/ipp/__init__.py @@ -19,21 +19,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up IPP from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - if not (coordinator := hass.data[DOMAIN].get(entry.entry_id)): - # Create IPP instance for this entry - coordinator = IPPDataUpdateCoordinator( - hass, - host=entry.data[CONF_HOST], - port=entry.data[CONF_PORT], - base_path=entry.data[CONF_BASE_PATH], - tls=entry.data[CONF_SSL], - verify_ssl=entry.data[CONF_VERIFY_SSL], - ) - hass.data[DOMAIN][entry.entry_id] = coordinator - + coordinator = IPPDataUpdateCoordinator( + hass, + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + base_path=entry.data[CONF_BASE_PATH], + tls=entry.data[CONF_SSL], + verify_ssl=entry.data[CONF_VERIFY_SSL], + ) await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -41,7 +38,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + 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/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index 59f8c32c210744..59b8b4b070e1e3 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["deepmerge", "pyipp"], "quality_scale": "platinum", - "requirements": ["pyipp==0.12.1"], + "requirements": ["pyipp==0.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 7e68ba9b24d2b8..c87cb2d28ac047 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/iss/config_flow.py b/homeassistant/components/iss/config_flow.py index ebfd445f62c8bc..2beffc7c8944f3 100644 --- a/homeassistant/components/iss/config_flow.py +++ b/homeassistant/components/iss/config_flow.py @@ -3,7 +3,7 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME, CONF_SHOW_ON_MAP +from homeassistant.const import CONF_SHOW_ON_MAP from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -33,7 +33,7 @@ async def async_step_user(self, user_input=None) -> FlowResult: if user_input is not None: return self.async_create_entry( - title=user_input.get(CONF_NAME, DEFAULT_NAME), + title=DEFAULT_NAME, data={}, options={CONF_SHOW_ON_MAP: user_input.get(CONF_SHOW_ON_MAP, False)}, ) diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 686ffdb72f3112..70f0f49d7a1cf6 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -15,15 +15,18 @@ 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 @@ STATE_UNLOCKED, UV_INDEX, Platform, + UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -399,7 +403,7 @@ "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 @@ "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 d2b10ef7419106..60e2111848d2cd 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 a150c0526783f6..62ae375736d018 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.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 .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 @@ def __init__( 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 2025e1a2a6cd52..bcd8e97582324b 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -90,6 +90,7 @@ def __init__( 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 b2e7e1468fd0f0..318798fdc5f2d3 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -21,7 +21,6 @@ from .const import ( COLLECTION_TYPE_MOVIES, COLLECTION_TYPE_MUSIC, - COLLECTION_TYPE_TVSHOWS, DOMAIN, ITEM_KEY_COLLECTION_TYPE, ITEM_KEY_ID, @@ -155,10 +154,7 @@ async def _build_library( return await self._build_music_library(library, include_children) if collection_type == COLLECTION_TYPE_MOVIES: return await self._build_movie_library(library, include_children) - if collection_type == COLLECTION_TYPE_TVSHOWS: - return await self._build_tv_library(library, include_children) - - raise BrowseError(f"Unsupported collection type {collection_type}") + return await self._build_tv_library(library, include_children) async def _build_music_library( self, library: dict[str, Any], include_children: bool @@ -189,7 +185,7 @@ async def _build_music_library( 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 +216,7 @@ async def _build_artist( 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 +306,7 @@ async def _build_movie_library( 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 +359,7 @@ async def _build_tv_library( 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 +390,7 @@ async def _build_series( 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 +421,7 @@ async def _build_season( 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 1957adfc6eb12d..cd0e9ab21a23b0 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 e33eef74c4872e..45f797a5aaa7e1 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 9a5e62bca94cba..87a9fa4da0eecf 100644 --- a/homeassistant/components/kaleidescape/entity.py +++ b/homeassistant/components/kaleidescape/entity.py @@ -19,16 +19,19 @@ class KaleidescapeEntity(Entity): """Defines a base Kaleidescape entity.""" + _attr_has_entity_name = True + _attr_should_poll = False + def __init__(self, device: KaleidescapeDevice) -> None: """Initialize entity.""" self._device = device - self._attr_should_poll = False self._attr_unique_id = device.serial_number - 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=f"{KALEIDESCAPE_NAME} {device.system.friendly_name}", model=self._device.system.type, manufacturer=KALEIDESCAPE_NAME, sw_version=f"{self._device.system.kos_version}", diff --git a/homeassistant/components/kaleidescape/media_player.py b/homeassistant/components/kaleidescape/media_player.py index cbae7f0df76591..7751f6b6a29762 100644 --- a/homeassistant/components/kaleidescape/media_player.py +++ b/homeassistant/components/kaleidescape/media_player.py @@ -56,6 +56,7 @@ class KaleidescapeMediaPlayer(KaleidescapeEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.PREVIOUS_TRACK ) + _attr_name = None async def async_turn_on(self) -> None: """Send leave standby command.""" diff --git a/homeassistant/components/kaleidescape/remote.py b/homeassistant/components/kaleidescape/remote.py index 61080052ee58d5..2d35ad2787fda7 100644 --- a/homeassistant/components/kaleidescape/remote.py +++ b/homeassistant/components/kaleidescape/remote.py @@ -47,6 +47,8 @@ async def async_setup_entry( class KaleidescapeRemote(KaleidescapeEntity, RemoteEntity): """Representation of a Kaleidescape device.""" + _attr_name = None + @property def is_on(self) -> bool: """Return true if device is on.""" diff --git a/homeassistant/components/kaleidescape/sensor.py b/homeassistant/components/kaleidescape/sensor.py index 23d40684c13cc4..183036f3973dfc 100644 --- a/homeassistant/components/kaleidescape/sensor.py +++ b/homeassistant/components/kaleidescape/sensor.py @@ -39,67 +39,67 @@ class KaleidescapeSensorEntityDescription( SENSOR_TYPES: tuple[KaleidescapeSensorEntityDescription, ...] = ( KaleidescapeSensorEntityDescription( key="media_location", - name="Media Location", + translation_key="media_location", icon="mdi:monitor", value_fn=lambda device: device.automation.movie_location, ), KaleidescapeSensorEntityDescription( key="play_status", - name="Play Status", + translation_key="play_status", icon="mdi:monitor", value_fn=lambda device: device.movie.play_status, ), KaleidescapeSensorEntityDescription( key="play_speed", - name="Play Speed", + translation_key="play_speed", icon="mdi:monitor", value_fn=lambda device: device.movie.play_speed, ), KaleidescapeSensorEntityDescription( key="video_mode", - name="Video Mode", + translation_key="video_mode", icon="mdi:monitor-screenshot", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.video_mode, ), KaleidescapeSensorEntityDescription( key="video_color_eotf", - name="Video Color EOTF", + translation_key="video_color_eotf", icon="mdi:monitor-eye", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.video_color_eotf, ), KaleidescapeSensorEntityDescription( key="video_color_space", - name="Video Color Space", + translation_key="video_color_space", icon="mdi:monitor-eye", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.video_color_space, ), KaleidescapeSensorEntityDescription( key="video_color_depth", - name="Video Color Depth", + translation_key="video_color_depth", icon="mdi:monitor-eye", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.video_color_depth, ), KaleidescapeSensorEntityDescription( key="video_color_sampling", - name="Video Color Sampling", + translation_key="video_color_sampling", icon="mdi:monitor-eye", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.video_color_sampling, ), KaleidescapeSensorEntityDescription( key="screen_mask_ratio", - name="Screen Mask Ratio", + translation_key="screen_mask_ratio", icon="mdi:monitor-screenshot", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.screen_mask_ratio, ), KaleidescapeSensorEntityDescription( key="screen_mask_top_trim_rel", - name="Screen Mask Top Trim Rel", + translation_key="screen_mask_top_trim_rel", icon="mdi:monitor-screenshot", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, @@ -107,7 +107,7 @@ class KaleidescapeSensorEntityDescription( ), KaleidescapeSensorEntityDescription( key="screen_mask_bottom_trim_rel", - name="Screen Mask Bottom Trim Rel", + translation_key="screen_mask_bottom_trim_rel", icon="mdi:monitor-screenshot", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, @@ -115,14 +115,14 @@ class KaleidescapeSensorEntityDescription( ), KaleidescapeSensorEntityDescription( key="screen_mask_conservative_ratio", - name="Screen Mask Conservative Ratio", + translation_key="screen_mask_conservative_ratio", icon="mdi:monitor-screenshot", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.screen_mask_conservative_ratio, ), KaleidescapeSensorEntityDescription( key="screen_mask_top_mask_abs", - name="Screen Mask Top Mask Abs", + translation_key="screen_mask_top_mask_abs", icon="mdi:monitor-screenshot", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, @@ -130,7 +130,7 @@ class KaleidescapeSensorEntityDescription( ), KaleidescapeSensorEntityDescription( key="screen_mask_bottom_mask_abs", - name="Screen Mask Bottom Mask Abs", + translation_key="screen_mask_bottom_mask_abs", icon="mdi:monitor-screenshot", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, @@ -138,14 +138,14 @@ class KaleidescapeSensorEntityDescription( ), KaleidescapeSensorEntityDescription( key="cinemascape_mask", - name="Cinemascape Mask", + translation_key="cinemascape_mask", icon="mdi:monitor-star", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.cinemascape_mask, ), KaleidescapeSensorEntityDescription( key="cinemascape_mode", - name="Cinemascape Mode", + translation_key="cinemascape_mode", icon="mdi:monitor-star", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.cinemascape_mode, @@ -177,7 +177,6 @@ def __init__( super().__init__(device) self.entity_description = entity_description self._attr_unique_id = f"{self._attr_unique_id}-{entity_description.key}" - self._attr_name = f"{self._attr_name} {entity_description.name}" @property def native_value(self) -> StateType: diff --git a/homeassistant/components/kaleidescape/strings.json b/homeassistant/components/kaleidescape/strings.json index 92b9c931acddc6..30c22a8ca0ee11 100644 --- a/homeassistant/components/kaleidescape/strings.json +++ b/homeassistant/components/kaleidescape/strings.json @@ -21,5 +21,57 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unsupported": "Unsupported device" } + }, + "entity": { + "sensor": { + "media_location": { + "name": "Media location" + }, + "play_status": { + "name": "Play status" + }, + "play_speed": { + "name": "Play speed" + }, + "video_mode": { + "name": "Video mode" + }, + "video_color_eotf": { + "name": "Video color EOTF" + }, + "video_color_space": { + "name": "Video color space" + }, + "video_color_depth": { + "name": "Video color depth" + }, + "video_color_sampling": { + "name": "Video color sampling" + }, + "screen_mask_ratio": { + "name": "Screen mask ratio" + }, + "screen_mask_top_trim_rel": { + "name": "Screen mask top trim relative" + }, + "screen_mask_bottom_trim_rel": { + "name": "Screen mask bottom trim relative" + }, + "screen_mask_conservative_ratio": { + "name": "Screen mask conservative ratio" + }, + "screen_mask_top_mask_abs": { + "name": "Screen mask top mask absolute" + }, + "screen_mask_bottom_mask_abs": { + "name": "Screen mask bottom mask absolute" + }, + "cinemascape_mask": { + "name": "Cinemascape mask" + }, + "cinemascape_mode": { + "name": "Cinemascape mode" + } + } } } diff --git a/homeassistant/components/keenetic_ndms2/manifest.json b/homeassistant/components/keenetic_ndms2/manifest.json index 41a1d0f2a2fb30..0751b40acd2361 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/__init__.py b/homeassistant/components/keyboard/__init__.py index cdd80119bc5d0b..f4e7f9e042480b 100644 --- a/homeassistant/components/keyboard/__init__.py +++ b/homeassistant/components/keyboard/__init__.py @@ -11,12 +11,15 @@ SERVICE_VOLUME_UP, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DOMAIN = "keyboard" TAP_KEY_SCHEMA = vol.Schema({}) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Listen for keyboard events.""" diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py index f0f1497f940e13..df3b6f0e427938 100644 --- a/homeassistant/components/keyboard_remote/__init__.py +++ b/homeassistant/components/keyboard_remote/__init__.py @@ -1,11 +1,14 @@ """Receive signals from a keyboard and use it as a remote control.""" # pylint: disable=import-error +from __future__ import annotations + import asyncio from contextlib import suppress import logging import os +from typing import Any -import aionotify +from asyncinotify import Inotify, Mask from evdev import InputDevice, categorize, ecodes, list_devices import voluptuous as vol @@ -64,9 +67,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the keyboard_remote.""" - config = config[DOMAIN] + domain_config: list[dict[str, Any]] = config[DOMAIN] - remote = KeyboardRemote(hass, config) + remote = KeyboardRemote(hass, domain_config) remote.setup() return True @@ -75,12 +78,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class KeyboardRemote: """Manage device connection/disconnection using inotify to asynchronously monitor.""" - def __init__(self, hass, config): + def __init__(self, hass: HomeAssistant, config: list[dict[str, Any]]) -> None: """Create handlers and setup dictionaries to keep track of them.""" self.hass = hass self.handlers_by_name = {} self.handlers_by_descriptor = {} - self.active_handlers_by_descriptor = {} + self.active_handlers_by_descriptor: dict[str, asyncio.Future] = {} + self.inotify = None self.watcher = None self.monitor_task = None @@ -110,16 +114,12 @@ async def async_start_monitoring(self, event): connected, and start monitoring for device connection/disconnection. """ - # start watching - self.watcher = aionotify.Watcher() - self.watcher.watch( - alias="devinput", - path=DEVINPUT, - flags=aionotify.Flags.CREATE - | aionotify.Flags.ATTRIB - | aionotify.Flags.DELETE, + _LOGGER.debug("Start monitoring") + + self.inotify = Inotify() + self.watcher = self.inotify.add_watch( + DEVINPUT, Mask.CREATE | Mask.ATTRIB | Mask.DELETE ) - await self.watcher.setup(self.hass.loop) # add initial devices (do this AFTER starting watcher in order to # avoid race conditions leading to missing device connections) @@ -134,7 +134,9 @@ async def async_start_monitoring(self, event): continue self.active_handlers_by_descriptor[descriptor] = handler - initial_start_monitoring.add(handler.async_start_monitoring(dev)) + initial_start_monitoring.add( + asyncio.create_task(handler.async_device_start_monitoring(dev)) + ) if initial_start_monitoring: await asyncio.wait(initial_start_monitoring) @@ -146,6 +148,10 @@ async def async_stop_monitoring(self, event): _LOGGER.debug("Cleanup on shutdown") + if self.inotify and self.watcher: + self.inotify.rm_watch(self.watcher) + self.watcher = None + if self.monitor_task is not None: if not self.monitor_task.done(): self.monitor_task.cancel() @@ -153,11 +159,16 @@ async def async_stop_monitoring(self, event): handler_stop_monitoring = set() for handler in self.active_handlers_by_descriptor.values(): - handler_stop_monitoring.add(handler.async_stop_monitoring()) - + handler_stop_monitoring.add( + asyncio.create_task(handler.async_device_stop_monitoring()) + ) if handler_stop_monitoring: await asyncio.wait(handler_stop_monitoring) + if self.inotify: + self.inotify.close() + self.inotify = None + def get_device_handler(self, descriptor): """Find the correct device handler given a descriptor (path).""" @@ -187,52 +198,63 @@ def get_device_handler(self, descriptor): async def async_monitor_devices(self): """Monitor asynchronously for device connection/disconnection or permissions changes.""" + _LOGGER.debug("Start monitoring loop") + try: - while True: - event = await self.watcher.get_event() + async for event in self.inotify: descriptor = f"{DEVINPUT}/{event.name}" + _LOGGER.debug( + "got event for %s: %s", + descriptor, + event.mask, + ) descriptor_active = descriptor in self.active_handlers_by_descriptor - if (event.flags & aionotify.Flags.DELETE) and descriptor_active: + 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_stop_monitoring() + await handler.async_device_stop_monitoring() elif ( - (event.flags & aionotify.Flags.CREATE) - or (event.flags & aionotify.Flags.ATTRIB) + (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_start_monitoring(dev) + await handler.async_device_start_monitoring(dev) except asyncio.CancelledError: + _LOGGER.debug("Monitoring canceled") return class DeviceHandler: """Manage input events using evdev with asyncio.""" - def __init__(self, hass, dev_block): + def __init__(self, hass: HomeAssistant, dev_block: dict[str, Any]) -> None: """Fill configuration data.""" self.hass = hass - key_types = dev_block.get(TYPE) + key_types = dev_block[TYPE] self.key_values = set() for key_type in key_types: self.key_values.add(KEY_VALUE[key_type]) - self.emulate_key_hold = dev_block.get(EMULATE_KEY_HOLD) - self.emulate_key_hold_delay = dev_block.get(EMULATE_KEY_HOLD_DELAY) - self.emulate_key_hold_repeat = dev_block.get(EMULATE_KEY_HOLD_REPEAT) + self.emulate_key_hold = dev_block[EMULATE_KEY_HOLD] + self.emulate_key_hold_delay = dev_block[EMULATE_KEY_HOLD_DELAY] + 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_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) @@ -242,26 +264,36 @@ async def async_keyrepeat(self, path, name, code, delay, repeat): { 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) - async def async_start_monitoring(self, dev): + async def async_device_start_monitoring(self, dev): """Start event monitoring task and issue event.""" + _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) - async def async_stop_monitoring(self): + async def async_device_stop_monitoring(self): """Stop event monitoring task and issue event.""" if self.monitor_task is not None: with suppress(OSError): @@ -277,12 +309,16 @@ async def async_stop_monitoring(self): 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, @@ -293,18 +329,22 @@ async def async_monitor_input(self, dev): 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, }, ) @@ -313,9 +353,7 @@ async def async_monitor_input(self, dev): and self.emulate_key_hold ): repeat_tasks[event.code] = self.hass.async_create_task( - self.async_keyrepeat( - dev.path, - dev.name, + self.async_device_keyrepeat( 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 d319ba93ce2648..bb84b32defc81f 100644 --- a/homeassistant/components/keyboard_remote/manifest.json +++ b/homeassistant/components/keyboard_remote/manifest.json @@ -3,7 +3,8 @@ "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.4.0", "aionotify==0.2.0"] + "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 3b7b96e90b6fa4..7857e6b31495d4 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -18,6 +18,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import Platform, UnitOfEnergy, UnitOfTemperature, UnitOfVolume from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util @@ -25,7 +26,9 @@ 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) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/kitchen_sink/image.py b/homeassistant/components/kitchen_sink/image.py new file mode 100644 index 00000000000000..4fe20f08de9dad --- /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 343190acb63132..b25941cf1a3e14 100644 --- a/homeassistant/components/kitchen_sink/lock.py +++ b/homeassistant/components/kitchen_sink/lock.py @@ -5,7 +5,7 @@ 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 @@ def __init__( 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 @@ def is_locked(self) -> bool: 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 00000000000000..d8350728b633a9 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 6692f53810be43..6912c9404823a7 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -7,7 +7,7 @@ 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 @@ def __init__( 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 @@ def __init__( 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/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json new file mode 100644 index 00000000000000..ce907a3368da12 --- /dev/null +++ b/homeassistant/components/kitchen_sink/strings.json @@ -0,0 +1,43 @@ +{ + "issues": { + "bad_psu": { + "title": "The power supply is not stable", + "fix_flow": { + "step": { + "confirm": { + "title": "The power supply needs to be replaced", + "description": "Press SUBMIT to confirm the power supply has been replaced" + } + } + } + }, + "out_of_blinker_fluid": { + "title": "The blinker fluid is empty and needs to be refilled", + "fix_flow": { + "step": { + "confirm": { + "title": "Blinker fluid needs to be refilled", + "description": "Press SUBMIT when blinker fluid has been refilled" + } + } + } + }, + "cold_tea": { + "title": "The tea is cold", + "fix_flow": { + "step": {}, + "abort": { + "not_tea_time": "Can not re-heat the tea at this time" + } + } + }, + "transmogrifier_deprecated": { + "title": "The transmogrifier component is deprecated", + "description": "The transmogrifier component is now deprecated due to the lack of local control available in the new API" + }, + "unfixable_problem": { + "title": "This is not a fixable problem", + "description": "This issue is never going to give up." + } + } +} diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 8a8e87b893fb6d..e8c237114b51ab 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -30,6 +30,7 @@ CONF_PORT, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, + SERVICE_RELOAD, Platform, ) from homeassistant.core import Event, HomeAssistant, ServiceCall @@ -90,6 +91,7 @@ SensorSchema, SwitchSchema, TextSchema, + TimeSchema, WeatherSchema, ga_validator, sensor_type_validator, @@ -143,6 +145,7 @@ **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 @@ async def service_send_to_knx_bus(self, call: ServiceCall) -> None: 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 @@ async def service_read_to_knx_bus(self, call: ServiceCall) -> None: 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 5546a2d6fd9c18..a9f5341fbfdb76 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 @@ class ColorTempModes(Enum): Platform.SENSOR, Platform.SWITCH, Platform.TEXT, + Platform.TIME, Platform.WEATHER, ] diff --git a/homeassistant/components/knx/device_trigger.py b/homeassistant/components/knx/device_trigger.py index 8a074b43b7ddd9..1abafb221db759 100644 --- a/homeassistant/components/knx/device_trigger.py +++ b/homeassistant/components/knx/device_trigger.py @@ -84,6 +84,7 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" + trigger_data = trigger_info["trigger_data"] dst_addresses: list[str] = config.get(EXTRA_FIELD_DESTINATION, []) job = HassJob(action, f"KNX device trigger {trigger_info}") knx: KNXModule = hass.data[DOMAIN] @@ -95,7 +96,7 @@ def async_call_trigger_action(telegram: TelegramDict) -> None: return hass.async_run_hass_job( job, - {"trigger": telegram}, + {"trigger": {**trigger_data, **telegram}}, ) return knx.telegrams.async_listen_telegram( diff --git a/homeassistant/components/knx/knx_entity.py b/homeassistant/components/knx/knx_entity.py index fff7f9b9f4f68c..9545510e635de0 100644 --- a/homeassistant/components/knx/knx_entity.py +++ b/homeassistant/components/knx/knx_entity.py @@ -42,8 +42,5 @@ async def after_update_callback(self, device: XknxDevice) -> None: async def async_added_to_hass(self) -> None: """Store register state change callback.""" self._device.register_device_updated_cb(self.after_update_callback) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect device object when removed.""" - # will also remove all callbacks - self._device.shutdown() + # will remove all callbacks and xknx tasks + self.async_on_remove(self._device.shutdown) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index f5ef8f61b84538..07747f094c31b5 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -4,7 +4,11 @@ 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 individual_color_addresses(color: str, feature: str) -> Any | None: 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 individual_color_addresses(color: str, feature: str) -> Any | None: 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 @@ def color_temp_kelvin(self) -> int | None: """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 b850954e8a8dc4..30e239a65a9778 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.9.0", - "xknxproject==3.1.0", - "knx_frontend==2023.5.16.204359" + "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 0f627b724cb580..86bf790a0775cc 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 ea5ba2f63a60ab..dbfe8e9bd5e1ff 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -4,6 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta +from functools import partial from typing import Any from xknx import XKNX @@ -71,7 +72,7 @@ class KNXSystemEntityDescription(SensorEntityDescription): 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", @@ -221,9 +222,9 @@ async def async_added_to_hass(self) -> None: self.knx.xknx.connection_manager.register_connection_state_changed_cb( self.after_update_callback ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect device object when removed.""" - self.knx.xknx.connection_manager.unregister_connection_state_changed_cb( - self.after_update_callback + self.async_on_remove( + partial( + self.knx.xknx.connection_manager.unregister_connection_state_changed_cb, + self.after_update_callback, + ) ) diff --git a/homeassistant/components/knx/services.yaml b/homeassistant/components/knx/services.yaml index d95a15738722f2..0ad497a30a261c 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 5b429b0bdc18a6..09307794066744 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -20,9 +20,13 @@ 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 @@ def __init__( 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 @@ def remove_listener() -> None: 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 @@ def telegram_to_dict(self, telegram: Telegram) -> TelegramDict: 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 @@ def telegram_to_dict(self, telegram: Telegram) -> TelegramDict: 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 00000000000000..af8ee48b806f5d --- /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 d63ba89fbcc714..ad29fd19928fdb 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -3,15 +3,14 @@ from typing import TYPE_CHECKING, Final -from knx_frontend import get_build_id, 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,23 +29,24 @@ 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() - build_id = get_build_id() hass.http.register_static_path( - URL_BASE, path, cache_headers=(build_id != "dev") + URL_BASE, + 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-{build_id}.js", + module_url=f"{URL_BASE}/{knx_panel.entrypoint_js}", embed_iframe=True, require_admin=True, ) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/info", @@ -129,6 +129,7 @@ async def ws_project_file_remove( connection.send_result(msg["id"]) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/group_monitor_info", @@ -142,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"], { @@ -155,6 +153,7 @@ def ws_group_monitor_info( ) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/subscribe_telegrams", @@ -174,7 +173,7 @@ def forward_telegram(telegram: TelegramDict) -> None: """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( @@ -182,38 +181,3 @@ def forward_telegram(telegram: TelegramDict) -> None: 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 c15c415bd9c343..3f931d1e2641f9 100644 --- a/homeassistant/components/kodi/device_trigger.py +++ b/homeassistant/components/kodi/device_trigger.py @@ -23,7 +23,7 @@ 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 3272491a06d3e3..af4e5700805124 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -26,6 +26,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_DEVICE_ID, CONF_HOST, CONF_NAME, CONF_PASSWORD, @@ -33,6 +34,7 @@ CONF_PROXY_SSL, CONF_SSL, CONF_TIMEOUT, + CONF_TYPE, CONF_USERNAME, EVENT_HOMEASSISTANT_STARTED, ) @@ -257,6 +259,8 @@ async def wrapper(obj: _KodiEntityT, *args: _P.args, **kwargs: _P.kwargs) -> Non 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 @@ -279,6 +283,7 @@ def __init__(self, connection, kodi, name, uid): self._connection = connection self._kodi = kodi self._unique_id = uid + self._device_id = None self._players = None self._properties = {} self._item = {} @@ -287,7 +292,11 @@ def __init__(self, connection, kodi, name, uid): 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 @@ -336,6 +345,20 @@ def async_on_volume_changed(self, sender, data): self._app_properties["muted"] = data["muted"] self.async_write_ha_state() + @callback + def async_on_key_press(self, sender, data): + """Handle a incoming key press notification.""" + self.hass.bus.async_fire( + f"{DOMAIN}_keypress", + { + CONF_TYPE: "keypress", + CONF_DEVICE_ID: self._device_id, + ATTR_ENTITY_ID: self.entity_id, + "sender": sender, + "data": data, + }, + ) + async def async_on_quit(self, sender, data): """Reset the player state on quit action.""" await self._clear_connection() @@ -351,15 +374,6 @@ def unique_id(self): """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.""" @@ -410,6 +424,7 @@ async def _on_ws_connected(self): dev_reg = dr.async_get(self.hass) device = dev_reg.async_get_device({(DOMAIN, self.unique_id)}) dev_reg.async_update_device(device.id, sw_version=sw_version) + self._device_id = device.id self.async_schedule_update_ha_state(True) @@ -457,6 +472,7 @@ def _register_ws_callbacks(self): self._connection.server.Application.OnVolumeChanged = ( self.async_on_volume_changed ) + self._connection.server.Other.OnKeyPress = self.async_on_key_press self._connection.server.System.OnQuit = self.async_on_quit self._connection.server.System.OnRestart = self.async_on_quit self._connection.server.System.OnSleep = self.async_on_quit diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index fb2c60b32c98ce..7355a60f5f0fed 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/lacrosse_view/__init__.py b/homeassistant/components/lacrosse_view/__init__.py index 46239485eb314c..86793a94a4bdd4 100644 --- a/homeassistant/components/lacrosse_view/__init__.py +++ b/homeassistant/components/lacrosse_view/__init__.py @@ -1,6 +1,8 @@ """The LaCrosse View integration.""" from __future__ import annotations +import logging + from lacrosse_view import LaCrosse, LoginError from homeassistant.config_entries import ConfigEntry @@ -13,6 +15,7 @@ from .coordinator import LaCrosseUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -22,17 +25,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await api.login(entry.data["username"], entry.data["password"]) + _LOGGER.debug("Log in successful") except LoginError as error: raise ConfigEntryAuthFailed from error coordinator = LaCrosseUpdateCoordinator(hass, api, entry) + _LOGGER.debug("First refresh") await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { "coordinator": coordinator, } + _LOGGER.debug("Setting up platforms") await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/lacrosse_view/config_flow.py b/homeassistant/components/lacrosse_view/config_flow.py index 2b694860bc81b4..67d294de179177 100644 --- a/homeassistant/components/lacrosse_view/config_flow.py +++ b/homeassistant/components/lacrosse_view/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from lacrosse_view import LaCrosse, Location, LoginError @@ -13,7 +14,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, LOGGER +from .const import DOMAIN STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -21,6 +22,7 @@ vol.Required("password"): str, } ) +_LOGGER = logging.getLogger(__name__) async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> list[Location]: @@ -29,14 +31,16 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> list[Loca api = LaCrosse(async_get_clientsession(hass)) try: - await api.login(data["username"], data["password"]) + if await api.login(data["username"], data["password"]): + _LOGGER.debug("Successfully logged in") locations = await api.get_locations() + _LOGGER.debug(locations) except LoginError as error: raise InvalidAuth from error if not locations: - raise NoLocations("No locations found for account {}".format(data["username"])) + raise NoLocations(f'No locations found for account {data["username"]}') return locations @@ -57,6 +61,7 @@ async def async_step_user( ) -> FlowResult: """Handle the initial step.""" if user_input is None: + _LOGGER.debug("Showing initial form") return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) @@ -66,11 +71,12 @@ async def async_step_user( try: info = await validate_input(self.hass, user_input) except InvalidAuth: + _LOGGER.exception("Could not login") errors["base"] = "invalid_auth" except NoLocations: errors["base"] = "no_locations" except Exception: # pylint: disable=broad-except - LOGGER.exception("Unexpected exception") + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: self.data = user_input @@ -83,8 +89,11 @@ async def async_step_user( ) await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) return self.async_abort(reason="reauth_successful") + + _LOGGER.debug("Moving on to location step") return await self.async_step_location() + _LOGGER.debug("Showing errors") return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) @@ -95,6 +104,7 @@ async def async_step_location( """Handle the location step.""" if not user_input: + _LOGGER.debug("Showing initial location selection") return self.async_show_form( step_id="location", data_schema=vol.Schema( @@ -113,7 +123,6 @@ async def async_step_location( ) await self.async_set_unique_id(location_id) - self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/lacrosse_view/const.py b/homeassistant/components/lacrosse_view/const.py index cae11315bc7abe..900463cff6e3a6 100644 --- a/homeassistant/components/lacrosse_view/const.py +++ b/homeassistant/components/lacrosse_view/const.py @@ -1,6 +1,4 @@ """Constants for the LaCrosse View integration.""" -import logging DOMAIN = "lacrosse_view" -LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = 30 diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 8dcbd8a2e5eab5..b45fe3ae1b4ed0 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +import logging from time import time from lacrosse_view import HTTPError, LaCrosse, Location, LoginError, Sensor @@ -11,7 +12,9 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import LOGGER, SCAN_INTERVAL +from .const import SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): @@ -39,7 +42,7 @@ def __init__( self.id = entry.data["id"] super().__init__( hass, - LOGGER, + _LOGGER, name="LaCrosse View", update_interval=timedelta(seconds=SCAN_INTERVAL), ) @@ -49,6 +52,7 @@ async def _async_update_data(self) -> list[Sensor]: now = int(time()) if self.last_update < now - 59 * 60: # Get new token once in a hour + _LOGGER.debug("Refreshing token") self.last_update = now try: await self.api.login(self.username, self.password) @@ -66,6 +70,8 @@ async def _async_update_data(self) -> list[Sensor]: except HTTPError as error: raise ConfigEntryNotReady from error + _LOGGER.debug("Got data: %s", sensors) + # Verify that we have permission to read the sensors for sensor in sensors: if not sensor.permissions.get("read", False): diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index 1c2daa2ba4ab52..547772cad09ff1 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -3,6 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass +import logging from lacrosse_view import Sensor @@ -22,13 +23,16 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from .const import DOMAIN, LOGGER +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) @dataclass @@ -63,7 +67,6 @@ def get_value(sensor: Sensor, field: str) -> float | int | str | None: "Temperature": LaCrosseSensorEntityDescription( key="Temperature", device_class=SensorDeviceClass.TEMPERATURE, - name="Temperature", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -71,22 +74,20 @@ def get_value(sensor: Sensor, field: str) -> float | int | str | None: "Humidity": LaCrosseSensorEntityDescription( key="Humidity", device_class=SensorDeviceClass.HUMIDITY, - name="Humidity", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=PERCENTAGE, ), "HeatIndex": LaCrosseSensorEntityDescription( key="HeatIndex", + translation_key="heat_index", device_class=SensorDeviceClass.TEMPERATURE, - name="Heat index", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ), "WindSpeed": LaCrosseSensorEntityDescription( key="WindSpeed", - name="Wind speed", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, @@ -94,7 +95,6 @@ def get_value(sensor: Sensor, field: str) -> float | int | str | None: ), "Rain": LaCrosseSensorEntityDescription( key="Rain", - name="Rain", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, @@ -102,23 +102,23 @@ def get_value(sensor: Sensor, field: str) -> float | int | str | None: ), "WindHeading": LaCrosseSensorEntityDescription( key="WindHeading", - name="Wind heading", + translation_key="wind_heading", value_fn=get_value, native_unit_of_measurement=DEGREE, ), "WetDry": LaCrosseSensorEntityDescription( key="WetDry", - name="Wet/Dry", + translation_key="wet_dry", value_fn=get_value, ), "Flex": LaCrosseSensorEntityDescription( key="Flex", - name="Flex", + translation_key="flex", value_fn=get_value, ), "BarometricPressure": LaCrosseSensorEntityDescription( key="BarometricPressure", - name="Barometric pressure", + translation_key="barometric_pressure", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, @@ -126,7 +126,7 @@ def get_value(sensor: Sensor, field: str) -> float | int | str | None: ), "FeelsLike": LaCrosseSensorEntityDescription( key="FeelsLike", - name="Feels like", + translation_key="feels_like", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, @@ -134,7 +134,7 @@ def get_value(sensor: Sensor, field: str) -> float | int | str | None: ), "WindChill": LaCrosseSensorEntityDescription( key="WindChill", - name="Wind chill", + translation_key="wind_chill", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, @@ -169,7 +169,7 @@ async def async_setup_entry( f"title=LaCrosse%20View%20Unsupported%20sensor%20field:%20{field}" ) - LOGGER.warning(message) + _LOGGER.warning(message) continue sensor_list.append( LaCrosseViewSensor( @@ -189,7 +189,7 @@ class LaCrosseViewSensor( """LaCrosse View sensor.""" entity_description: LaCrosseSensorEntityDescription - _attr_has_entity_name: bool = True + _attr_has_entity_name = True def __init__( self, @@ -203,13 +203,13 @@ def __init__( self.entity_description = description self._attr_unique_id = f"{sensor.sensor_id}-{description.key}" - self._attr_device_info = { - "identifiers": {(DOMAIN, sensor.sensor_id)}, - "name": sensor.name, - "manufacturer": "LaCrosse Technology", - "model": sensor.model, - "via_device": (DOMAIN, sensor.location.id), - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, sensor.sensor_id)}, + name=sensor.name, + manufacturer="LaCrosse Technology", + model=sensor.model, + via_device=(DOMAIN, sensor.location.id), + ) self.index = index @property diff --git a/homeassistant/components/lacrosse_view/strings.json b/homeassistant/components/lacrosse_view/strings.json index 160517793d8f9c..8dc27ba259e622 100644 --- a/homeassistant/components/lacrosse_view/strings.json +++ b/homeassistant/components/lacrosse_view/strings.json @@ -17,5 +17,30 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "sensor": { + "heat_index": { + "name": "Heat index" + }, + "wind_heading": { + "name": "Wind heading" + }, + "wet_dry": { + "name": "Wet/Dry" + }, + "flex": { + "name": "Flex" + }, + "barometric_pressure": { + "name": "Barometric pressure" + }, + "feels_like": { + "name": "Feels like" + }, + "wind_chill": { + "name": "Wind chill" + } + } } } diff --git a/homeassistant/components/lametric/config_flow.py b/homeassistant/components/lametric/config_flow.py index 8e9da5851cf50a..1dad190d70675d 100644 --- a/homeassistant/components/lametric/config_flow.py +++ b/homeassistant/components/lametric/config_flow.py @@ -248,6 +248,10 @@ async def _async_step_create_entry(self, host: str, api_key: str) -> FlowResult: updates={CONF_HOST: lametric.host, CONF_API_KEY: lametric.api_key} ) + notify_sound: Sound | None = None + if device.model != "sa5": + notify_sound = Sound(sound=NotificationSound.WIN) + await lametric.notify( notification=Notification( priority=NotificationPriority.CRITICAL, @@ -255,7 +259,7 @@ async def _async_step_create_entry(self, host: str, api_key: str) -> FlowResult: model=Model( cycles=2, frames=[Simple(text="Connected to Home Assistant!", icon=7956)], - sound=Sound(sound=NotificationSound.WIN), + sound=notify_sound, ), ) ) diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index 0c26d2c7dd5892..6cddf81b2bf3f6 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -39,7 +39,6 @@ class LaMetricSensorEntityDescription( LaMetricSensorEntityDescription( key="rssi", translation_key="rssi", - name="Wi-Fi signal", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/lastfm/__init__.py b/homeassistant/components/lastfm/__init__.py index 2012834293175f..fc26dd85ea3229 100644 --- a/homeassistant/components/lastfm/__init__.py +++ b/homeassistant/components/lastfm/__init__.py @@ -1 +1,27 @@ """The lastfm component.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up lastfm from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload lastfm config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py new file mode 100644 index 00000000000000..54406a6e03bc01 --- /dev/null +++ b/homeassistant/components/lastfm/config_flow.py @@ -0,0 +1,229 @@ +"""Config flow for LastFm.""" +from __future__ import annotations + +from typing import Any + +from pylast import LastFMNetwork, PyLastError, User, WSError +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlowWithConfigEntry, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_MAIN_USER, CONF_USERS, DOMAIN + +PLACEHOLDERS = {"api_account_url": "https://www.last.fm/api/account/create"} + +CONFIG_SCHEMA: vol.Schema = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_MAIN_USER): str, + } +) + + +def get_lastfm_user(api_key: str, username: str) -> tuple[User, dict[str, str]]: + """Get and validate lastFM User.""" + user = LastFMNetwork(api_key=api_key).get_user(username) + errors = {} + try: + user.get_playcount() + except WSError as error: + if error.details == "User not found": + errors["base"] = "invalid_account" + elif ( + error.details + == "Invalid API key - You must be granted a valid key by last.fm" + ): + errors["base"] = "invalid_auth" + else: + errors["base"] = "unknown" + except Exception: # pylint:disable=broad-except + errors["base"] = "unknown" + return user, errors + + +def validate_lastfm_users( + api_key: str, usernames: list[str] +) -> tuple[list[str], dict[str, str]]: + """Validate list of users. Return tuple of valid users and errors.""" + valid_users = [] + errors = {} + for username in usernames: + _, lastfm_errors = get_lastfm_user(api_key, username) + if lastfm_errors: + errors = lastfm_errors + else: + valid_users.append(username) + return valid_users, errors + + +class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow handler for LastFm.""" + + data: dict[str, Any] = {} + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> LastFmOptionsFlowHandler: + """Get the options flow for this handler.""" + return LastFmOptionsFlowHandler(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Initialize user input.""" + errors: dict[str, str] = {} + if user_input is not None: + self.data = user_input.copy() + _, errors = get_lastfm_user( + self.data[CONF_API_KEY], self.data[CONF_MAIN_USER] + ) + if not errors: + return await self.async_step_friends() + return self.async_show_form( + step_id="user", + errors=errors, + description_placeholders=PLACEHOLDERS, + data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input), + ) + + async def async_step_friends( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Form to select other users and friends.""" + errors: dict[str, str] = {} + if user_input is not None: + users, errors = validate_lastfm_users( + self.data[CONF_API_KEY], user_input[CONF_USERS] + ) + user_input[CONF_USERS] = users + if not errors: + return self.async_create_entry( + title="LastFM", + data={}, + options={ + CONF_API_KEY: self.data[CONF_API_KEY], + CONF_MAIN_USER: self.data[CONF_MAIN_USER], + CONF_USERS: [ + self.data[CONF_MAIN_USER], + *user_input[CONF_USERS], + ], + }, + ) + try: + main_user, _ = get_lastfm_user( + self.data[CONF_API_KEY], self.data[CONF_MAIN_USER] + ) + friends_response = await self.hass.async_add_executor_job( + main_user.get_friends + ) + friends = [ + SelectOptionDict(value=friend.name, label=friend.get_name(True)) + for friend in friends_response + ] + except PyLastError: + friends = [] + return self.async_show_form( + step_id="friends", + errors=errors, + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_USERS): SelectSelector( + SelectSelectorConfig( + options=friends, custom_value=True, multiple=True + ) + ), + } + ), + user_input or {CONF_USERS: []}, + ), + ) + + async def async_step_import(self, import_config: ConfigType) -> FlowResult: + """Import config from yaml.""" + for entry in self._async_current_entries(): + if entry.options[CONF_API_KEY] == import_config[CONF_API_KEY]: + return self.async_abort(reason="already_configured") + users, _ = validate_lastfm_users( + import_config[CONF_API_KEY], import_config[CONF_USERS] + ) + return self.async_create_entry( + title="LastFM", + data={}, + options={ + CONF_API_KEY: import_config[CONF_API_KEY], + CONF_MAIN_USER: None, + CONF_USERS: users, + }, + ) + + +class LastFmOptionsFlowHandler(OptionsFlowWithConfigEntry): + """LastFm Options flow handler.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Initialize form.""" + errors: dict[str, str] = {} + if user_input is not None: + users, errors = validate_lastfm_users( + self.options[CONF_API_KEY], user_input[CONF_USERS] + ) + user_input[CONF_USERS] = users + if not errors: + return self.async_create_entry( + title="LastFM", + data={ + **self.options, + CONF_USERS: user_input[CONF_USERS], + }, + ) + if self.options[CONF_MAIN_USER]: + try: + main_user, _ = get_lastfm_user( + self.options[CONF_API_KEY], + self.options[CONF_MAIN_USER], + ) + friends_response = await self.hass.async_add_executor_job( + main_user.get_friends + ) + friends = [ + SelectOptionDict(value=friend.name, label=friend.get_name(True)) + for friend in friends_response + ] + except PyLastError: + friends = [] + else: + friends = [] + return self.async_show_form( + step_id="init", + errors=errors, + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_USERS): SelectSelector( + SelectSelectorConfig( + options=friends, custom_value=True, multiple=True + ) + ), + } + ), + user_input or self.options, + ), + ) diff --git a/homeassistant/components/lastfm/const.py b/homeassistant/components/lastfm/const.py index 2a7f40b99e3a33..f895876c3c392d 100644 --- a/homeassistant/components/lastfm/const.py +++ b/homeassistant/components/lastfm/const.py @@ -2,10 +2,14 @@ import logging from typing import Final +from homeassistant.const import Platform + LOGGER = logging.getLogger(__package__) DOMAIN: Final = "lastfm" +PLATFORMS = [Platform.SENSOR] DEFAULT_NAME = "LastFM" +CONF_MAIN_USER = "main_user" CONF_USERS = "users" ATTR_LAST_PLAYED = "last_played" diff --git a/homeassistant/components/lastfm/manifest.json b/homeassistant/components/lastfm/manifest.json index 392da95a2ac881..4315f4c538998e 100644 --- a/homeassistant/components/lastfm/manifest.json +++ b/homeassistant/components/lastfm/manifest.json @@ -1,7 +1,8 @@ { "domain": "lastfm", "name": "Last.fm", - "codeowners": [], + "codeowners": ["@joostlek"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lastfm", "iot_class": "cloud_polling", "loggers": ["pylast"], diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 20c51f8a8c6a3b..b4776b19c50db9 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -3,14 +3,18 @@ import hashlib -from pylast import LastFMNetwork, Track, User, WSError +from pylast import LastFMNetwork, PyLastError, Track, User import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -18,6 +22,8 @@ ATTR_PLAY_COUNT, ATTR_TOP_PLAYED, CONF_USERS, + DEFAULT_NAME, + DOMAIN, LOGGER, STATE_NOT_SCROBBLING, ) @@ -35,23 +41,46 @@ def format_track(track: Track) -> str: return f"{track.artist} - {track.title}" -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Last.fm sensor platform.""" - lastfm_api = LastFMNetwork(api_key=config[CONF_API_KEY]) - entities = [] - for username in config[CONF_USERS]: - try: - user = lastfm_api.get_user(username) - entities.append(LastFmSensor(user, lastfm_api)) - except WSError as exc: - LOGGER.error("Failed to load LastFM user `%s`: %r", username, exc) - return - add_entities(entities, True) + """Set up the Last.fm sensor platform from yaml.""" + + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.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 + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize the entries.""" + + lastfm_api = LastFMNetwork(api_key=entry.options[CONF_API_KEY]) + async_add_entities( + ( + LastFmSensor(lastfm_api.get_user(user), entry.entry_id) + for user in entry.options[CONF_USERS] + ), + True, + ) class LastFmSensor(SensorEntity): @@ -60,28 +89,45 @@ class LastFmSensor(SensorEntity): _attr_attribution = "Data provided by Last.fm" _attr_icon = "mdi:radio-fm" - def __init__(self, user: User, lastfm_api: LastFMNetwork) -> None: + def __init__(self, user: User, entry_id: str) -> None: """Initialize the sensor.""" + self._user = user self._attr_unique_id = hashlib.sha256(user.name.encode("utf-8")).hexdigest() self._attr_name = user.name - self._user = user + self._attr_device_info = DeviceInfo( + configuration_url="https://www.last.fm", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"{entry_id}_{self._attr_unique_id}")}, + manufacturer=DEFAULT_NAME, + name=f"{DEFAULT_NAME} {user.name}", + ) def update(self) -> None: """Update device state.""" - self._attr_entity_picture = self._user.get_image() - if now_playing := self._user.get_now_playing(): + self._attr_native_value = STATE_NOT_SCROBBLING + try: + play_count = self._user.get_playcount() + self._attr_entity_picture = self._user.get_image() + now_playing = self._user.get_now_playing() + top_tracks = self._user.get_top_tracks(limit=1) + last_tracks = self._user.get_recent_tracks(limit=1) + except PyLastError as exc: + self._attr_available = False + LOGGER.error("Failed to load LastFM user `%s`: %r", self._user.name, exc) + return + self._attr_available = True + if now_playing: self._attr_native_value = format_track(now_playing) - else: - self._attr_native_value = STATE_NOT_SCROBBLING - top_played = None - if top_tracks := self._user.get_top_tracks(limit=1): - top_played = format_track(top_tracks[0].item) - last_played = None - if last_tracks := self._user.get_recent_tracks(limit=1): - last_played = format_track(last_tracks[0].track) - play_count = self._user.get_playcount() self._attr_extra_state_attributes = { - ATTR_LAST_PLAYED: last_played, ATTR_PLAY_COUNT: play_count, - ATTR_TOP_PLAYED: top_played, + ATTR_LAST_PLAYED: None, + ATTR_TOP_PLAYED: None, } + if len(last_tracks) > 0: + self._attr_extra_state_attributes[ATTR_LAST_PLAYED] = format_track( + last_tracks[0].track + ) + if len(top_tracks) > 0: + self._attr_extra_state_attributes[ATTR_TOP_PLAYED] = format_track( + top_tracks[0].item + ) diff --git a/homeassistant/components/lastfm/strings.json b/homeassistant/components/lastfm/strings.json new file mode 100644 index 00000000000000..f9156bed658c46 --- /dev/null +++ b/homeassistant/components/lastfm/strings.json @@ -0,0 +1,45 @@ +{ + "config": { + "step": { + "user": { + "description": "Request an API account at {api_account_url}.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "main_user": "Last.fm username" + } + }, + "friends": { + "description": "Fill in other users you want to add.", + "data": { + "users": "Last.fm usernames" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_account": "Invalid username", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "options": { + "step": { + "init": { + "description": "Fill in other users you want to add.", + "data": { + "users": "Last.fm usernames" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_account": "Invalid username", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The LastFM YAML configuration is being removed", + "description": "Configuring LastFM using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the LastFM YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/laundrify/manifest.json b/homeassistant/components/laundrify/manifest.json index b9469f79e65d43..8dca67058b746c 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 c0e46250c1efcc..bee6c0f0e298b5 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/__init__.py b/homeassistant/components/ld2410_ble/__init__.py index 204a5367e0b90d..e127a4a98361e5 100644 --- a/homeassistant/components/ld2410_ble/__init__.py +++ b/homeassistant/components/ld2410_ble/__init__.py @@ -2,7 +2,7 @@ import logging -from bleak_retry_connector import BleakError, get_device +from bleak_retry_connector import BleakError, close_stale_connections, get_device from ld2410_ble import LD2410BLE from homeassistant.components import bluetooth @@ -31,6 +31,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady( f"Could not find LD2410B device with address {address}" ) + + await close_stale_connections(ble_device) + ld2410_ble = LD2410BLE(ble_device) coordinator = LD2410BLECoordinator(hass, ld2410_ble) diff --git a/homeassistant/components/ld2410_ble/coordinator.py b/homeassistant/components/ld2410_ble/coordinator.py index 6ab255530940b1..2f0fd079773342 100644 --- a/homeassistant/components/ld2410_ble/coordinator.py +++ b/homeassistant/components/ld2410_ble/coordinator.py @@ -1,16 +1,22 @@ """Data coordinator for receiving LD2410B updates.""" +from datetime import datetime import logging +import time from ld2410_ble import LD2410BLE, LD2410BLEState -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +NEVER_TIME = -86400.0 +DEBOUNCE_SECONDS = 1.0 + class LD2410BLECoordinator(DataUpdateCoordinator[None]): """Data coordinator for receiving LD2410B updates.""" @@ -26,15 +32,43 @@ def __init__(self, hass: HomeAssistant, ld2410_ble: LD2410BLE) -> None: ld2410_ble.register_callback(self._async_handle_update) ld2410_ble.register_disconnected_callback(self._async_handle_disconnect) self.connected = False + self._last_update_time = NEVER_TIME + self._debounce_cancel: CALLBACK_TYPE | None = None + self._debounced_update_job = HassJob( + self._async_handle_debounced_update, + f"LD2410 {ld2410_ble.address} BLE debounced update", + ) + + @callback + def _async_handle_debounced_update(self, _now: datetime) -> None: + """Handle debounced update.""" + self._debounce_cancel = None + self._last_update_time = time.monotonic() + self.async_set_updated_data(None) @callback def _async_handle_update(self, state: LD2410BLEState) -> None: """Just trigger the callbacks.""" self.connected = True - self.async_set_updated_data(None) + previous_last_updated_time = self._last_update_time + self._last_update_time = time.monotonic() + if self._last_update_time - previous_last_updated_time >= DEBOUNCE_SECONDS: + self.async_set_updated_data(None) + return + if self._debounce_cancel is None: + self._debounce_cancel = async_call_later( + self.hass, DEBOUNCE_SECONDS, self._debounced_update_job + ) @callback def _async_handle_disconnect(self) -> None: """Trigger the callbacks for disconnected.""" self.connected = False self.async_update_listeners() + + async def async_shutdown(self) -> None: + """Shutdown the coordinator.""" + if self._debounce_cancel is not None: + self._debounce_cancel() + self._debounce_cancel = None + await super().async_shutdown() diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 4716519ac18ceb..6eaf2885d89306 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 a19680ffa5c6c1..cdc270f2e99371 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 2074966e1e7be8..2b59e628705bb9 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -40,6 +40,7 @@ | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.STOP ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -74,6 +75,7 @@ def setup_platform( class LgTVDevice(MediaPlayerEntity): """Representation of a LG TV.""" + _attr_assumed_state = True _attr_device_class = MediaPlayerDeviceClass.TV _attr_media_content_type = MediaType.CHANNEL @@ -83,8 +85,6 @@ def __init__(self, client, name, on_action_script): self._name = name self._muted = False self._on_action_script = on_action_script - # Assume that the TV is in Play mode - self._playing = True self._volume = 0 self._channel_id = None self._channel_name = "" @@ -106,7 +106,7 @@ def update(self) -> None: try: with self._client as client: - self._attr_state = MediaPlayerState.PLAYING + self._attr_state = MediaPlayerState.ON self.__update_volume() @@ -233,25 +233,18 @@ def select_source(self, source: str) -> None: """Select input source.""" self._client.change_channel(self._sources[source]) - def media_play_pause(self) -> None: - """Simulate play pause media player.""" - if self._playing: - self.media_pause() - else: - self.media_play() - def media_play(self) -> None: """Send play command.""" - self._playing = True - self._attr_state = MediaPlayerState.PLAYING self.send_command(LG_COMMAND.PLAY) def media_pause(self) -> None: """Send media pause command to media player.""" - self._playing = False - self._attr_state = MediaPlayerState.PAUSED self.send_command(LG_COMMAND.PAUSE) + def media_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 96ab2e5f7a2801..1a2930c8051709 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 @@ class LidarrSensorEntityDescription( ), "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 @@ class LidarrSensorEntityDescription( ), "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 ffa91c23f2a92c..bbe4b19db25d3a 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/binary_sensor.py b/homeassistant/components/lifx/binary_sensor.py index 110661b1c5ca63..5719c881d1faa8 100644 --- a/homeassistant/components/lifx/binary_sensor.py +++ b/homeassistant/components/lifx/binary_sensor.py @@ -18,7 +18,7 @@ HEV_CYCLE_STATE_SENSOR = BinarySensorEntityDescription( key=HEV_CYCLE_STATE, - name="Clean Cycle", + translation_key="clean_cycle", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.RUNNING, ) @@ -39,8 +39,6 @@ async def async_setup_entry( class LIFXHevCycleBinarySensorEntity(LIFXEntity, BinarySensorEntity): """LIFX HEV cycle state binary sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: LIFXUpdateCoordinator, diff --git a/homeassistant/components/lifx/button.py b/homeassistant/components/lifx/button.py index b5f5373b3e838f..86e3bc569b148a 100644 --- a/homeassistant/components/lifx/button.py +++ b/homeassistant/components/lifx/button.py @@ -17,14 +17,13 @@ RESTART_BUTTON_DESCRIPTION = ButtonEntityDescription( key=RESTART, - name="Restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, ) IDENTIFY_BUTTON_DESCRIPTION = ButtonEntityDescription( key=IDENTIFY, - name="Identify", + device_class=ButtonDeviceClass.IDENTIFY, entity_category=EntityCategory.CONFIG, ) @@ -45,8 +44,7 @@ async def async_setup_entry( class LIFXButton(LIFXEntity, ButtonEntity): """Base LIFX button.""" - _attr_has_entity_name: bool = True - _attr_should_poll: bool = False + _attr_should_poll = False def __init__(self, coordinator: LIFXUpdateCoordinator) -> None: """Initialise a LIFX button.""" diff --git a/homeassistant/components/lifx/entity.py b/homeassistant/components/lifx/entity.py index a86bda53cfdc86..5f08b6e7884478 100644 --- a/homeassistant/components/lifx/entity.py +++ b/homeassistant/components/lifx/entity.py @@ -14,6 +14,8 @@ class LIFXEntity(CoordinatorEntity[LIFXUpdateCoordinator]): """Representation of a LIFX entity with a coordinator.""" + _attr_has_entity_name = True + def __init__(self, coordinator: LIFXUpdateCoordinator) -> None: """Initialise the light.""" super().__init__(coordinator) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index cb901dcbe477f9..0e56155832ffeb 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -112,6 +112,7 @@ class LIFXLight(LIFXEntity, LightEntity): """Representation of a LIFX light.""" _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT + _attr_name = None def __init__( self, @@ -131,7 +132,6 @@ def __init__( self.postponed_update: CALLBACK_TYPE | None = None self.entry = entry self._attr_unique_id = self.coordinator.serial_number - self._attr_name = self.bulb.label self._attr_min_color_temp_kelvin = bulb_features["min_kelvin"] self._attr_max_color_temp_kelvin = bulb_features["max_kelvin"] if bulb_features["min_kelvin"] != bulb_features["max_kelvin"]: diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index e867bb65eb03d2..d6b253bd4784a5 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/lifx/select.py b/homeassistant/components/lifx/select.py index 9ad457e0270551..183e31dec1fde2 100644 --- a/homeassistant/components/lifx/select.py +++ b/homeassistant/components/lifx/select.py @@ -23,14 +23,14 @@ INFRARED_BRIGHTNESS_ENTITY = SelectEntityDescription( key=INFRARED_BRIGHTNESS, - name="Infrared brightness", + translation_key="infrared_brightness", entity_category=EntityCategory.CONFIG, options=list(INFRARED_BRIGHTNESS_VALUES_MAP.values()), ) THEME_ENTITY = SelectEntityDescription( key=ATTR_THEME, - name="Theme", + translation_key="theme", entity_category=EntityCategory.CONFIG, options=THEME_NAMES, ) @@ -58,8 +58,6 @@ async def async_setup_entry( class LIFXInfraredBrightnessSelectEntity(LIFXEntity, SelectEntity): """LIFX Nightvision infrared brightness configuration entity.""" - _attr_has_entity_name = True - def __init__( self, coordinator: LIFXUpdateCoordinator, @@ -90,8 +88,6 @@ async def async_select_option(self, option: str) -> None: class LIFXThemeSelectEntity(LIFXEntity, SelectEntity): """Theme entity for LIFX multizone devices.""" - _attr_has_entity_name = True - def __init__( self, coordinator: LIFXUpdateCoordinator, diff --git a/homeassistant/components/lifx/sensor.py b/homeassistant/components/lifx/sensor.py index 654b528575601c..e10f9579bc31fd 100644 --- a/homeassistant/components/lifx/sensor.py +++ b/homeassistant/components/lifx/sensor.py @@ -22,7 +22,7 @@ RSSI_SENSOR = SensorEntityDescription( key=ATTR_RSSI, - name="RSSI", + translation_key="rssi", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -41,8 +41,6 @@ async def async_setup_entry( class LIFXRssiSensor(LIFXEntity, SensorEntity): """LIFX RSSI sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: LIFXUpdateCoordinator, diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index 93d3bd62abea74..69055d6bbc67cb 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -25,5 +25,25 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "entity": { + "binary_sensor": { + "clean_cycle": { + "name": "Clean cycle" + } + }, + "select": { + "infrared_brightness": { + "name": "Infrared brightness" + }, + "theme": { + "name": "Theme" + } + }, + "sensor": { + "rssi": { + "name": "RSSI" + } + } } } diff --git a/homeassistant/components/light/device_action.py b/homeassistant/components/light/device_action.py index 2b4c32cb3b1912..2b49c963438048 100644 --- a/homeassistant/components/light/device_action.py +++ b/homeassistant/components/light/device_action.py @@ -3,7 +3,11 @@ 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_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 @@ ) +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/lightwave/__init__.py b/homeassistant/components/lightwave/__init__.py index e774a27999deee..9feefd6e24ddc6 100644 --- a/homeassistant/components/lightwave/__init__.py +++ b/homeassistant/components/lightwave/__init__.py @@ -1,4 +1,6 @@ """Support for device connected via Lightwave WiFi-link hub.""" +import logging + from lightwave.lightwave import LWLink import voluptuous as vol @@ -20,12 +22,13 @@ CONF_TRV = "trv" CONF_TRVS = "trvs" DEFAULT_PROXY_PORT = 7878 -DEFAULT_PROXY_IP = "127.0.0.1" DOMAIN = "lightwave" LIGHTWAVE_LINK = f"{DOMAIN}_link" LIGHTWAVE_TRV_PROXY = f"{DOMAIN}_proxy" LIGHTWAVE_TRV_PROXY_PORT = f"{DOMAIN}_proxy_port" +_LOGGER = logging.getLogger(__name__) + CONFIG_SCHEMA = vol.Schema( { @@ -44,9 +47,7 @@ vol.Optional( CONF_PROXY_PORT, default=DEFAULT_PROXY_PORT ): cv.port, - vol.Optional( - CONF_PROXY_IP, default=DEFAULT_PROXY_IP - ): cv.string, + vol.Optional(CONF_PROXY_IP): cv.string, vol.Required(CONF_TRVS, default={}): { cv.string: vol.Schema( { @@ -84,9 +85,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if trv := config[DOMAIN][CONF_TRV]: trvs = trv[CONF_TRVS] - proxy_ip = trv[CONF_PROXY_IP] + proxy_ip = trv.get(CONF_PROXY_IP) proxy_port = trv[CONF_PROXY_PORT] - lwlink.set_trv_proxy(proxy_ip, proxy_port) + if proxy_ip is None: + await lwlink.LW_listen() + else: + lwlink.set_trv_proxy(proxy_ip, proxy_port) + _LOGGER.warning( + "Proxy no longer required, remove `proxy_ip` from config to use builtin listener" + ) for platform in PLATFORMS: hass.async_create_task( diff --git a/homeassistant/components/lightwave/manifest.json b/homeassistant/components/lightwave/manifest.json index 86c6a9263f3d4b..d242195a71c0c0 100644 --- a/homeassistant/components/lightwave/manifest.json +++ b/homeassistant/components/lightwave/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/lightwave", "iot_class": "assumed_state", "loggers": ["lightwave"], - "requirements": ["lightwave==0.20"] + "requirements": ["lightwave==0.24"] } diff --git a/homeassistant/components/lirc/__init__.py b/homeassistant/components/lirc/__init__.py index cf76213a88e2ba..181783b6bbdce6 100644 --- a/homeassistant/components/lirc/__init__.py +++ b/homeassistant/components/lirc/__init__.py @@ -8,6 +8,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -20,6 +21,8 @@ ICON = "mdi:remote" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LIRC capability.""" diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 45483f99e5b0e0..c7eda2f118b983 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -18,7 +18,7 @@ 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 107935be7b8063..5308a3b4f832a2 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -51,7 +51,7 @@ def is_on(self) -> bool: 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 @@ def is_on(self) -> bool: ), 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 @@ def is_on(self) -> bool: 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 1d208ca48e12a8..06c4fe75888e0e 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/manifest.json b/homeassistant/components/litterrobot/manifest.json index d3dcf77f3243eb..2a4a3447eb663c 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.4.0"] + "requirements": ["pylitterbot==2023.4.2"] } diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index feac85ecac484a..6fabd6ea526468 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 @@ class 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 @@ class 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 e7aed366fa3e93..ba601a0ba54daa 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -69,32 +69,31 @@ def icon(self) -> str | None: 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 @@ def icon(self) -> str | None: 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 @@ def icon(self) -> str | None: 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 b4aa8f0016d68e..00a8a6122db998 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 eb2297e506e17f..6b4e5b56b48ee3 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 00000000000000..f352b7cee70ccc --- /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 33ca6cd0376666..9b8391c5bae53d 100644 --- a/homeassistant/components/litterrobot/update.py +++ b/homeassistant/components/litterrobot/update.py @@ -24,7 +24,7 @@ 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 b56724f15c855e..d1352c1e45f75d 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -42,7 +42,9 @@ 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/local_calendar/__init__.py b/homeassistant/components/local_calendar/__init__.py index 33ad67cc81a6ef..7c1d2f09b04b80 100644 --- a/homeassistant/components/local_calendar/__init__.py +++ b/homeassistant/components/local_calendar/__init__.py @@ -39,3 +39,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle removal of an entry.""" + key = slugify(entry.data[CONF_CALENDAR_NAME]) + path = Path(hass.config.path(STORAGE_PATH.format(key=key))) + + def unlink(path: Path) -> None: + path.unlink(missing_ok=True) + + await hass.async_add_executor_job(unlink, path) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 049f9de03ea305..b56acffe4e2507 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==4.5.1"] + "requirements": ["ical==4.5.4"] } diff --git a/homeassistant/components/local_calendar/strings.json b/homeassistant/components/local_calendar/strings.json index f49c92e5438e0b..c6eb36ee88f0eb 100644 --- a/homeassistant/components/local_calendar/strings.json +++ b/homeassistant/components/local_calendar/strings.json @@ -1,4 +1,5 @@ { + "title": "Local Calendar", "config": { "step": { "user": { diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py index 5a796b976ff58c..cca322f3baa0f4 100644 --- a/homeassistant/components/locative/__init__.py +++ b/homeassistant/components/locative/__init__.py @@ -21,7 +21,6 @@ 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 @@ def _validate_test_mode(obj: dict) -> dict: ) -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 01e7b21d4b6e9b..fba95a932de511 100644 --- a/homeassistant/components/lock/device_action.py +++ b/homeassistant/components/lock/device_action.py @@ -3,6 +3,7 @@ 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 @@ 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 c439fe99d14865..5ba93554aec5bf 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -39,7 +39,7 @@ 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 ec996d4f0b29f2..c6b86eaca4afc7 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -29,7 +29,7 @@ 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/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index ee2ae3da4d95de..0c614972e1e39c 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -127,7 +127,7 @@ def log_message(service: ServiceCall) -> None: possible_merged_entities_filter = convert_include_exclude_filter(merged_filter) if not possible_merged_entities_filter.empty_filter: filters = sqlalchemy_filter_from_include_exclude_conf(merged_filter) - entities_filter = possible_merged_entities_filter + entities_filter = possible_merged_entities_filter.get_filter() else: filters = None entities_filter = None diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index c8f55331de1332..3a1ec971b54e26 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -23,7 +23,6 @@ split_entity_id, ) from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_track_state_change_event from .const import ALWAYS_CONTINUOUS_DOMAINS, AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN @@ -104,7 +103,7 @@ def extract_attr(source: dict[str, Any], attr: str) -> list[str]: @callback def event_forwarder_filtered( target: Callable[[Event], None], - entities_filter: EntityFilter | None, + entities_filter: Callable[[str], bool] | None, entity_ids: list[str] | None, device_ids: list[str] | None, ) -> Callable[[Event], None]: @@ -159,7 +158,7 @@ def async_subscribe_events( subscriptions: list[CALLBACK_TYPE], target: Callable[[Event], None], event_types: tuple[str, ...], - entities_filter: EntityFilter | None, + entities_filter: Callable[[str], bool] | None, entity_ids: list[str] | None, device_ids: list[str] | None, ) -> None: diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 86dcfdf82c5140..e351ee6bb6133f 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -16,7 +16,6 @@ ) from homeassistant.const import ATTR_ICON, EVENT_STATE_CHANGED from homeassistant.core import Context, Event, State, callback -from homeassistant.helpers.entityfilter import EntityFilter import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads from homeassistant.util.ulid import ulid_to_bytes @@ -30,7 +29,7 @@ class LogbookConfig: str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] ] sqlalchemy_filter: Filters | None = None - entity_filter: EntityFilter | None = None + entity_filter: Callable[[str], bool] | None = None class LazyEventPartialState: diff --git a/homeassistant/components/logbook/rest_api.py b/homeassistant/components/logbook/rest_api.py index a1a7db3ed2c0bb..57d0a6695c7d4b 100644 --- a/homeassistant/components/logbook/rest_api.py +++ b/homeassistant/components/logbook/rest_api.py @@ -1,6 +1,7 @@ """Event parser and human readable log generator.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta from http import HTTPStatus from typing import Any, cast @@ -14,7 +15,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import InvalidEntityFormatError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util @@ -27,7 +27,7 @@ def async_setup( hass: HomeAssistant, conf: ConfigType, filters: Filters | None, - entities_filter: EntityFilter | None, + entities_filter: Callable[[str], bool] | None, ) -> None: """Set up the logbook rest API.""" hass.http.register_view(LogbookView(conf, filters, entities_filter)) @@ -44,7 +44,7 @@ def __init__( self, config: dict[str, Any], filters: Filters | None, - entities_filter: EntityFilter | None, + entities_filter: Callable[[str], bool] | None, ) -> None: """Initialize the logbook view.""" self.config = config diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index c4e6b9814f4904..4afa40cb14f1a0 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -15,7 +15,6 @@ from homeassistant.components.websocket_api import messages from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.json import JSON_DUMP import homeassistant.util.dt as dt_util @@ -357,7 +356,7 @@ def _queue_or_cancel(event: Event) -> None: ) _unsub() - entities_filter: EntityFilter | None = None + entities_filter: Callable[[str], bool] | None = None if not event_processor.limited_select: logbook_config: LogbookConfig = hass.data[DOMAIN] entities_filter = logbook_config.entity_filter diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index fe29447aeba44c..cd2761510d3eef 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -13,8 +13,8 @@ from . import websocket_api from .const import ( ATTR_LEVEL, - DEFAULT_LOGSEVERITY, DOMAIN, + EVENT_LOGGING_CHANGED, # noqa: F401 LOGGER_DEFAULT, LOGGER_FILTERS, LOGGER_LOGS, @@ -22,7 +22,6 @@ SERVICE_SET_DEFAULT_LEVEL, SERVICE_SET_LEVEL, ) -from .const import EVENT_LOGGING_CHANGED # noqa: F401 from .helpers import ( LoggerDomainConfig, LoggerSettings, @@ -39,9 +38,7 @@ { DOMAIN: vol.Schema( { - vol.Optional( - LOGGER_DEFAULT, default=DEFAULT_LOGSEVERITY - ): _VALID_LOG_LEVEL, + vol.Optional(LOGGER_DEFAULT): _VALID_LOG_LEVEL, vol.Optional(LOGGER_LOGS): vol.Schema({cv.string: _VALID_LOG_LEVEL}), vol.Optional(LOGGER_FILTERS): vol.Schema({cv.string: [cv.is_regex]}), } diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index 0f1751c1b2ec65..dcd4348a561848 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -119,7 +119,7 @@ def __init__(self, hass: HomeAssistant, yaml_config: ConfigType) -> None: self._yaml_config = yaml_config self._default_level = logging.INFO - if DOMAIN in yaml_config: + if DOMAIN in yaml_config and LOGGER_DEFAULT in yaml_config[DOMAIN]: self._default_level = yaml_config[DOMAIN][LOGGER_DEFAULT] self._store: Store[dict[str, dict[str, dict[str, Any]]]] = Store( hass, STORAGE_VERSION, STORAGE_KEY diff --git a/homeassistant/components/logi_circle/manifest.json b/homeassistant/components/logi_circle/manifest.json index 2f08fe6f135546..f4f65b2250571c 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 00000000000000..e6c69e0751e629 --- /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, Platform.SENSOR] + + +_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 00000000000000..5eecc0b3f59a89 --- /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 00000000000000..6b1c0311a2d049 --- /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 00000000000000..507debc02ab9ba --- /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 00000000000000..978fe844d619cb --- /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 00000000000000..d34df19e2d11c3 --- /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 00000000000000..1000d8f804d643 --- /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/sensor.py b/homeassistant/components/loqed/sensor.py new file mode 100644 index 00000000000000..ee4fa7ecd74b00 --- /dev/null +++ b/homeassistant/components/loqed/sensor.py @@ -0,0 +1,71 @@ +"""Creates LOQED sensors.""" +from typing import Final + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import LoqedDataCoordinator, StatusMessage +from .entity import LoqedEntity + +SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + key="ble_strength", + translation_key="ble_strength", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="battery_percentage", + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), +) + + +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(LoqedSensor(coordinator, sensor) for sensor in SENSORS) + + +class LoqedSensor(LoqedEntity, SensorEntity): + """Representation of Sensor state.""" + + def __init__( + self, coordinator: LoqedDataCoordinator, description: SensorEntityDescription + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{self.coordinator.lock.id}_{description.key}" + + @property + def data(self) -> StatusMessage: + """Return data object from DataUpdateCoordinator.""" + return self.coordinator.lock + + @property + def native_value(self) -> int: + """Return state of sensor.""" + return getattr(self.data, self.entity_description.key) diff --git a/homeassistant/components/loqed/strings.json b/homeassistant/components/loqed/strings.json new file mode 100644 index 00000000000000..3d31194f5a6ed4 --- /dev/null +++ b/homeassistant/components/loqed/strings.json @@ -0,0 +1,28 @@ +{ + "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%]" + } + }, + "entity": { + "sensor": { + "ble_strength": { + "name": "Bluetooth signal" + } + } + } +} diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index 262a6701f56bbd..cca467ce756d2e 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -32,21 +32,18 @@ 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 @@ ), 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 d54bc6d0bdc9db..e990142923f73b 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 d8ccce8a6bc662..c15f0ea075e933 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -37,6 +37,7 @@ # 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 @@ def __init__(self, hass, area_name, keypad, button): 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 @@ def button_callback(self, button, context, event, params): 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 f34366f24d0e46..c2423a7c47fa42 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -23,8 +23,6 @@ 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 .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 29f023d0de229a..75cea546b71ecf 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -16,7 +16,11 @@ 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 @@ 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/map/__init__.py b/homeassistant/components/map/__init__.py index 0ea1fbf2af931a..a3ba65be7db13d 100644 --- a/homeassistant/components/map/__init__.py +++ b/homeassistant/components/map/__init__.py @@ -1,10 +1,13 @@ """Support for showing device locations.""" from homeassistant.components import frontend from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DOMAIN = "map" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the built-in map panel.""" diff --git a/homeassistant/components/marytts/tts.py b/homeassistant/components/marytts/tts.py index 5904e271a6a96b..56f31d81d9713d 100644 --- a/homeassistant/components/marytts/tts.py +++ b/homeassistant/components/marytts/tts.py @@ -80,7 +80,7 @@ def supported_options(self): """Return a list of supported options.""" return SUPPORT_OPTIONS - def get_tts_audio(self, message, language, options=None): + def get_tts_audio(self, message, language, options): """Load TTS from MaryTTS.""" effects = options[CONF_EFFECT] diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index 4c47cd4d235477..59c5ec9efc8418 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations import asyncio +from contextlib import suppress import async_timeout from matter_server.client import MatterClient from matter_server.client.exceptions import CannotConnect, InvalidServerVersion -from matter_server.common.errors import MatterError, NodeCommissionFailed +from matter_server.common.errors import MatterError, NodeCommissionFailed, NodeNotExists import voluptuous as vol from homeassistant.components.hassio import AddonError, AddonManager, AddonState @@ -207,7 +208,9 @@ async def async_remove_config_entry_device( ) matter = get_matter(hass) - await matter.matter_client.remove_node(node.node_id) + with suppress(NodeNotExists): + # ignore if the server has already removed the node. + await matter.matter_client.remove_node(node.node_id) return True diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index fbc027091b4d16..8e76706b7fdb8b 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, cast +from matter_server.client.models.device_types import BridgedDevice from matter_server.common.models import EventType, ServerInfoMessage from homeassistant.config_entries import ConfigEntry @@ -20,6 +21,14 @@ from matter_server.client.models.node import MatterEndpoint, MatterNode +def get_clean_name(name: str | None) -> str | None: + """Strip spaces and null char from the name.""" + if name is None: + return name + name = name.replace("\x00", "") + return name.strip() or None + + class MatterAdapter: """Connect Matter into Home Assistant.""" @@ -43,15 +52,68 @@ def register_platform_handler( 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_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(node_added_callback, EventType.NODE_ADDED) + 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: @@ -70,11 +132,27 @@ def _create_device_registry( server_info = cast(ServerInfoMessage, self.matter_client.server_info) basic_info = endpoint.device_info - name = basic_info.nodeLabel or basic_info.productLabel or basic_info.productName + # use (first) DeviceType of the endpoint as fallback product name + device_type = next( + ( + x + for x in endpoint.device_types + if x.device_type != BridgedDevice.device_type + ), + None, + ) + name = ( + get_clean_name(basic_info.nodeLabel) + or get_clean_name(basic_info.productLabel) + or get_clean_name(basic_info.productName) + or device_type.__name__ + if device_type + else None + ) # handle bridged devices bridge_device_id = None - if endpoint.is_bridged_device: + if endpoint.is_bridged_device and endpoint.node.endpoints[0] != endpoint: bridge_device_id = get_device_id( server_info, endpoint.node.endpoints[0], @@ -91,14 +169,19 @@ def _create_device_registry( # prefix identifier with 'serial_' to be able to filter it identifiers.add((DOMAIN, f"{ID_TYPE_SERIAL}_{basic_info.serialNumber}")) + model = ( + get_clean_name(basic_info.productName) or device_type.__name__ + if device_type + else None + ) dr.async_get(self.hass).async_get_or_create( name=name, config_entry_id=self.config_entry.entry_id, identifiers=identifiers, hw_version=basic_info.hardwareVersionString, sw_version=basic_info.softwareVersionString, - manufacturer=basic_info.vendorName, - model=basic_info.productName, + manufacturer=basic_info.vendorName or endpoint.node.device_info.vendorName, + model=model, via_device=(DOMAIN, bridge_device_id) if bridge_device_id else None, ) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index a82614cbcc6997..aabfc12eefbf6a 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -13,7 +13,7 @@ BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -65,7 +65,6 @@ def _update_from_device(self) -> None: 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 @@ def _update_from_device(self) -> None: 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 @@ def _update_from_device(self) -> None: 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,9 +99,9 @@ def _update_from_device(self) -> None: entity_description=MatterBinarySensorEntityDescription( key="BatteryChargeLevel", device_class=BinarySensorDeviceClass.BATTERY, - name="Battery Status", + entity_category=EntityCategory.DIAGNOSTIC, measurement_to_ha=lambda x: x - != clusters.PowerSource.Enums.BatChargeLevel.kOk, + != clusters.PowerSource.Enums.BatChargeLevelEnum.kOk, ), entity_class=MatterBinarySensor, required_attributes=(clusters.PowerSource.Attributes.BatChargeLevel,), diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py new file mode 100644 index 00000000000000..6da88533edc065 --- /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 4e227d83b44e82..590f325cf22960 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -2,12 +2,14 @@ from __future__ import annotations from enum import IntEnum +from math import floor from typing import Any from chip.clusters import Objects as clusters from homeassistant.components.cover import ( ATTR_POSITION, + ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, CoverEntityDescription, @@ -56,17 +58,20 @@ class MatterCover(MatterEntity, CoverEntity): """Representation of a Matter Cover.""" entity_description: CoverEntityDescription - _attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.STOP - | CoverEntityFeature.SET_POSITION - ) @property - def is_closed(self) -> bool: - """Return true if cover is closed, else False.""" - return self.current_cover_position == 0 + def is_closed(self) -> bool | None: + """Return true if cover is closed, if there is no position report, return None.""" + if not self._entity_info.endpoint.has_attribute( + None, clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths + ): + return None + + return ( + self.current_cover_position == 0 + if self.current_cover_position is not None + else None + ) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover movement.""" @@ -88,6 +93,14 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: clusters.WindowCovering.Commands.GoToLiftPercentage((100 - position) * 100) ) + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Set the cover tilt to a specific position.""" + position = kwargs[ATTR_TILT_POSITION] + await self.send_device_command( + # value needs to be inverted and is sent in 100ths + clusters.WindowCovering.Commands.GoToTiltPercentage((100 - position) * 100) + ) + async def send_device_command(self, command: Any) -> None: """Send device command.""" await self.matter_client.send_device_command( @@ -123,18 +136,45 @@ def _update_from_device(self) -> None: self._attr_is_opening = False self._attr_is_closing = False - # current position is inverted in matter (100 is closed, 0 is open) - current_cover_position = self.get_matter_attribute_value( - clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage - ) - self._attr_current_cover_position = 100 - current_cover_position + if self._entity_info.endpoint.has_attribute( + None, clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths + ): + # current position is inverted in matter (100 is closed, 0 is open) + current_cover_position = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths + ) + self._attr_current_cover_position = ( + 100 - floor(current_cover_position / 100) + if current_cover_position is not None + else None + ) - LOGGER.debug( - "Current position for %s - raw: %s - corrected: %s", - self.entity_id, - current_cover_position, - self.current_cover_position, - ) + LOGGER.debug( + "Current position for %s - raw: %s - corrected: %s", + self.entity_id, + current_cover_position, + self.current_cover_position, + ) + + if self._entity_info.endpoint.has_attribute( + None, clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths + ): + # current tilt position is inverted in matter (100 is closed, 0 is open) + current_cover_tilt_position = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths + ) + self._attr_current_cover_tilt_position = ( + 100 - floor(current_cover_tilt_position / 100) + if current_cover_tilt_position is not None + else None + ) + + LOGGER.debug( + "Current tilt position for %s - raw: %s - corrected: %s", + self.entity_id, + current_cover_tilt_position, + self.current_cover_tilt_position, + ) # map matter type to HA deviceclass device_type: clusters.WindowCovering.Enums.Type = ( @@ -142,16 +182,75 @@ def _update_from_device(self) -> None: ) self._attr_device_class = TYPE_MAP.get(device_type, CoverDeviceClass.AWNING) + supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + commands = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.AcceptedCommandList + ) + if clusters.WindowCovering.Commands.GoToLiftPercentage.command_id in commands: + supported_features |= CoverEntityFeature.SET_POSITION + if clusters.WindowCovering.Commands.GoToTiltPercentage.command_id in commands: + supported_features |= CoverEntityFeature.SET_TILT_POSITION + self._attr_supported_features = supported_features + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ 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, + clusters.WindowCovering.Attributes.Type, + ), + absent_attributes=( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths, + clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths, + ), + ), + MatterDiscoverySchema( + platform=Platform.COVER, + entity_description=CoverEntityDescription( + key="MatterCoverPositionAwareLift", name=None + ), + entity_class=MatterCover, + required_attributes=( + clusters.WindowCovering.Attributes.OperationalStatus, + clusters.WindowCovering.Attributes.Type, + clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths, + ), + absent_attributes=( + clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths, + ), + ), + MatterDiscoverySchema( + platform=Platform.COVER, + entity_description=CoverEntityDescription( + key="MatterCoverPositionAwareTilt", name=None + ), + entity_class=MatterCover, + required_attributes=( + clusters.WindowCovering.Attributes.OperationalStatus, + clusters.WindowCovering.Attributes.Type, + clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths, + ), + absent_attributes=( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths, + ), + ), + MatterDiscoverySchema( + platform=Platform.COVER, + entity_description=CoverEntityDescription( + key="MatterCoverPositionAwareLiftAndTilt", name=None + ), entity_class=MatterCover, required_attributes=( - clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, clusters.WindowCovering.Attributes.OperationalStatus, + clusters.WindowCovering.Attributes.Type, + clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths, + clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths, ), - ) + ), ] diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 28f5b6b7f90168..0b4bacf00ca343 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -10,6 +10,7 @@ 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 @@ 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 bf0a74ef84574c..0457cfaa8107b4 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -75,20 +75,25 @@ async def async_added_to_hass(self) -> None: 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 4b6099502562e1..0274c80edf8ef1 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 10a52eb88055c8..02919baa8f1a4b 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -1,7 +1,6 @@ """Matter light.""" from __future__ import annotations -from enum import IntFlag from typing import Any from chip.clusters import Objects as clusters @@ -112,7 +111,7 @@ async def _set_color_temp(self, color_temp: int) -> None: await self.send_device_command( clusters.ColorControl.Commands.MoveToColorTemperature( - colorTemperature=color_temp, + colorTemperatureMireds=color_temp, # It's required in TLV. We don't implement transition time yet. transitionTime=0, ) @@ -129,7 +128,7 @@ async def _set_brightness(self, brightness: int) -> None: renormalize( brightness, (0, 255), - (level_control.minLevel, level_control.maxLevel), + (level_control.minLevel or 1, level_control.maxLevel or 254), ) ) @@ -221,7 +220,7 @@ def _get_brightness(self) -> int: return round( renormalize( level_control.currentLevel, - (level_control.minLevel, level_control.maxLevel), + (level_control.minLevel or 1, level_control.maxLevel or 254), (0, 255), ) ) @@ -300,6 +299,8 @@ def _update_from_device(self) -> None: # 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 @@ -307,13 +308,22 @@ def _update_from_device(self) -> None: assert capabilities is not None - if capabilities & ColorCapabilities.kHueSaturationSupported: + if ( + capabilities + & clusters.ColorControl.Bitmaps.ColorCapabilities.kHueSaturationSupported + ): supported_color_modes.add(ColorMode.HS) - if capabilities & ColorCapabilities.kXYAttributesSupported: + if ( + capabilities + & clusters.ColorControl.Bitmaps.ColorCapabilities.kXYAttributesSupported + ): supported_color_modes.add(ColorMode.XY) - if capabilities & ColorCapabilities.kColorTemperatureSupported: + if ( + capabilities + & clusters.ColorControl.Bitmaps.ColorCapabilities.kColorTemperatureSupported + ): supported_color_modes.add(ColorMode.COLOR_TEMP) self._attr_supported_color_modes = supported_color_modes @@ -344,23 +354,11 @@ def _update_from_device(self) -> None: self._attr_brightness = self._get_brightness() -# This enum should be removed once the ColorControlCapabilities enum is added to the CHIP (Matter) library -# clusters.ColorControl.Bitmap.ColorCapabilities -class ColorCapabilities(IntFlag): - """Color control capabilities bitmap.""" - - kHueSaturationSupported = 0x1 - kEnhancedHueSupported = 0x2 - kColorLoopSupported = 0x4 - kXYAttributesSupported = 0x8 - kColorTemperatureSupported = 0x10 - - # Discovery schema(s) to map Matter Attributes to HA entities 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=( @@ -372,10 +370,97 @@ class ColorCapabilities(IntFlag): clusters.ColorControl.Attributes.CurrentY, clusters.ColorControl.Attributes.ColorTemperatureMireds, ), - # restrict device type to prevent discovery by the wrong platform + device_type=( + device_types.ColorTemperatureLight, + device_types.DimmableLight, + device_types.ExtendedColorLight, + 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.DoorLock, + 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 f90d8eb485d0d3..a5f625f9e73ddd 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -8,7 +8,7 @@ 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 @@ async def send_device_command( 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: @@ -106,7 +140,7 @@ def _update_from_device(self) -> None: LOGGER.debug("Door state: %s for %s", door_state, self.entity_id) self._attr_is_jammed = ( - door_state is clusters.DoorLock.Enums.DlDoorState.kDoorJammed + door_state is clusters.DoorLock.Enums.DoorStateEnum.kDoorJammed ) @@ -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 190bf33dcf71ea..85434407a10ffb 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.2.0"] + "requirements": ["python-matter-server==3.6.3"] } diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 84e68695d639e0..5021ed7fa0d016 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, + EntityCategory, Platform, UnitOfPressure, UnitOfTemperature, @@ -68,7 +69,6 @@ def _update_from_device(self) -> None: 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 +80,6 @@ def _update_from_device(self) -> None: 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 +91,8 @@ def _update_from_device(self) -> None: 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 +102,6 @@ def _update_from_device(self) -> None: 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 +115,6 @@ def _update_from_device(self) -> None: 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,9 +126,9 @@ def _update_from_device(self) -> None: platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="PowerSource", - name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, # value has double precision measurement_to_ha=lambda x: int(x / 2), ), diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 594998c236f2df..dc5eb30df51a33 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 809d0ad73861c3..e1fb4464b83122 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -63,15 +63,21 @@ def _update_from_device(self) -> None: 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,), # restrict device type to prevent discovery by the wrong platform not_device_type=( - device_types.OnOffLight, + device_types.ColorTemperatureLight, device_types.DimmableLight, + device_types.ExtendedColorLight, + device_types.OnOffLight, device_types.DoorLock, + 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 99a1a4ac2ff889..1b1e51db0358f6 100644 --- a/homeassistant/components/mazda/button.py +++ b/homeassistant/components/mazda/button.py @@ -78,21 +78,25 @@ class MazdaButtonEntityDescription(ButtonEntityDescription): 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 2c2aafa960e932..01f77cb2d38a4f 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/meater/sensor.py b/homeassistant/components/meater/sensor.py index 0a1240c74716ee..cf71455a81bb47 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -63,8 +64,8 @@ def _remaining_time_to_timestamp(probe: MeaterProbe) -> datetime | None: # Ambient temperature MeaterSensorEntityDescription( key="ambient", + translation_key="ambient", device_class=SensorDeviceClass.TEMPERATURE, - name="Ambient", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, available=lambda probe: probe is not None, @@ -73,8 +74,8 @@ def _remaining_time_to_timestamp(probe: MeaterProbe) -> datetime | None: # Internal temperature (probe tip) MeaterSensorEntityDescription( key="internal", + translation_key="internal", device_class=SensorDeviceClass.TEMPERATURE, - name="Internal", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, available=lambda probe: probe is not None, @@ -83,7 +84,7 @@ def _remaining_time_to_timestamp(probe: MeaterProbe) -> datetime | None: # Name of selected meat in user language or user given custom name MeaterSensorEntityDescription( key="cook_name", - name="Cooking", + translation_key="cook_name", available=lambda probe: probe is not None and probe.cook is not None, value=lambda probe: probe.cook.name if probe.cook else None, ), @@ -91,15 +92,15 @@ def _remaining_time_to_timestamp(probe: MeaterProbe) -> datetime | None: # Slightly Underdone, Finished, Slightly Overdone, OVERCOOK!. Not translated. MeaterSensorEntityDescription( key="cook_state", - name="Cook state", + translation_key="cook_state", available=lambda probe: probe is not None and probe.cook is not None, value=lambda probe: probe.cook.state if probe.cook else None, ), # Target temperature MeaterSensorEntityDescription( key="cook_target_temp", + translation_key="cook_target_temp", device_class=SensorDeviceClass.TEMPERATURE, - name="Target", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, available=lambda probe: probe is not None and probe.cook is not None, @@ -110,8 +111,8 @@ def _remaining_time_to_timestamp(probe: MeaterProbe) -> datetime | None: # Peak temperature MeaterSensorEntityDescription( key="cook_peak_temp", + translation_key="cook_peak_temp", device_class=SensorDeviceClass.TEMPERATURE, - name="Peak", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, available=lambda probe: probe is not None and probe.cook is not None, @@ -123,8 +124,8 @@ def _remaining_time_to_timestamp(probe: MeaterProbe) -> datetime | None: # Exposed as a TIMESTAMP sensor where the timestamp is current time + remaining time. MeaterSensorEntityDescription( key="cook_time_remaining", + translation_key="cook_time_remaining", device_class=SensorDeviceClass.TIMESTAMP, - name="Remaining time", available=lambda probe: probe is not None and probe.cook is not None, value=_remaining_time_to_timestamp, ), @@ -132,8 +133,8 @@ def _remaining_time_to_timestamp(probe: MeaterProbe) -> datetime | None: # where the timestamp is current time - elapsed time. MeaterSensorEntityDescription( key="cook_time_elapsed", + translation_key="cook_time_elapsed", device_class=SensorDeviceClass.TIMESTAMP, - name="Elapsed time", available=lambda probe: probe is not None and probe.cook is not None, value=_elapsed_time_to_timestamp, ), @@ -191,16 +192,15 @@ def __init__( ) -> None: """Initialise the sensor.""" super().__init__(coordinator) - self._attr_name = f"Meater Probe {description.name}" - self._attr_device_info = { - "identifiers": { + self._attr_device_info = DeviceInfo( + identifiers={ # Serial numbers are unique identifiers within a specific domain (DOMAIN, device_id) }, - "manufacturer": "Apption Labs", - "model": "Meater Probe", - "name": f"Meater Probe {device_id}", - } + manufacturer="Apption Labs", + model="Meater Probe", + name=f"Meater Probe {device_id}", + ) self._attr_unique_id = f"{device_id}-{description.key}" self.device_id = device_id diff --git a/homeassistant/components/meater/strings.json b/homeassistant/components/meater/strings.json index 7f4a97a5b19c5a..279841bb14777c 100644 --- a/homeassistant/components/meater/strings.json +++ b/homeassistant/components/meater/strings.json @@ -26,5 +26,33 @@ "unknown_auth_error": "[%key:common::config_flow::error::unknown%]", "service_unavailable_error": "The API is currently unavailable, please try again later." } + }, + "entity": { + "sensor": { + "ambient": { + "name": "Ambient temperature" + }, + "internal": { + "name": "Internal temperature" + }, + "cook_name": { + "name": "Cooking" + }, + "cook_state": { + "name": "Cook state" + }, + "cook_target_temp": { + "name": "Target temperature" + }, + "cook_peak_temp": { + "name": "Peak temperature" + }, + "cook_time_remaining": { + "name": "Time remaining" + }, + "cook_time_elapsed": { + "name": "Time elapsed" + } + } } } diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index a0c542d72a556b..a35650f00929e5 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 @@ def stream_selector(query): _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 @@ def call_media_player_service(self, stream_selector, entity_id): 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 c358b29062a808..ccab196032f45d 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 2b046868f164b8..1e9be742c5316c 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 9e3981ed9833a9..5efee0c0b49464 100644 --- a/homeassistant/components/media_player/device_condition.py +++ b/homeassistant/components/media_player/device_condition.py @@ -39,7 +39,7 @@ 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 58fc0aca84fab9..e626059841c66f 100644 --- a/homeassistant/components/media_player/device_trigger.py +++ b/homeassistant/components/media_player/device_trigger.py @@ -33,7 +33,7 @@ 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_player/strings.json b/homeassistant/components/media_player/strings.json index cee0ee200fe9e9..2c63a543119669 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -122,7 +122,7 @@ "name": "Repeat", "state": { "all": "All", - "off": "Off", + "off": "[%key:common::state::off%]", "one": "One" } }, diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 21c32c9137fee4..62cf78156130ae 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -18,6 +18,7 @@ ) from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.frame import report from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, @@ -53,6 +54,9 @@ ] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + def is_media_source_id(media_content_id: str) -> bool: """Test if identifier is a media source.""" return URI_SCHEME_REGEX.match(media_content_id) is not None @@ -189,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/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index c29794ae8d7b97..89437a6b2e0513 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -38,7 +38,7 @@ def async_setup(hass: HomeAssistant) -> None: class LocalSource(MediaSource): """Provide local directories as media sources.""" - name: str = "Local Media" + name: str = "My media" def __init__(self, hass: HomeAssistant) -> None: """Initialize local source.""" diff --git a/homeassistant/components/melnor/__init__.py b/homeassistant/components/melnor/__init__.py index 3cd9fee4fe7aaa..9a15e81dc22c0d 100644 --- a/homeassistant/components/melnor/__init__.py +++ b/homeassistant/components/melnor/__init__.py @@ -18,6 +18,7 @@ Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, + Platform.TIME, ] diff --git a/homeassistant/components/melnor/manifest.json b/homeassistant/components/melnor/manifest.json index 4a95900aeb396f..45dce207f7e213 100644 --- a/homeassistant/components/melnor/manifest.json +++ b/homeassistant/components/melnor/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/melnor", "iot_class": "local_polling", - "requirements": ["melnor-bluetooth==0.0.20"] + "requirements": ["melnor-bluetooth==0.0.25"] } diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py index c750e07f7e844b..e0f9c7d3bf67a8 100644 --- a/homeassistant/components/melnor/number.py +++ b/homeassistant/components/melnor/number.py @@ -8,9 +8,13 @@ from melnor_bluetooth.device import Valve -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -44,10 +48,33 @@ class MelnorZoneNumberEntityDescription( native_min_value=1, icon="mdi:timer-cog-outline", key="manual_minutes", - name="Manual Minutes", + translation_key="manual_minutes", + native_unit_of_measurement=UnitOfTime.MINUTES, set_num_fn=lambda valve, value: valve.set_manual_watering_minutes(value), state_fn=lambda valve: valve.manual_watering_minutes, - ) + ), + MelnorZoneNumberEntityDescription( + entity_category=EntityCategory.CONFIG, + native_max_value=168, + native_min_value=1, + icon="mdi:calendar-refresh-outline", + key="frequency_interval_hours", + translation_key="frequency_interval_hours", + native_unit_of_measurement=UnitOfTime.HOURS, + set_num_fn=lambda valve, value: valve.set_frequency_interval_hours(value), + state_fn=lambda valve: valve.frequency.interval_hours, + ), + MelnorZoneNumberEntityDescription( + entity_category=EntityCategory.CONFIG, + native_max_value=360, + native_min_value=1, + icon="mdi:timer-outline", + key="frequency_duration_minutes", + translation_key="frequency_duration_minutes", + native_unit_of_measurement=UnitOfTime.MINUTES, + set_num_fn=lambda valve, value: valve.set_frequency_duration_minutes(value), + state_fn=lambda valve: valve.frequency.duration_minutes, + ), ] @@ -75,6 +102,7 @@ class MelnorZoneNumber(MelnorZoneEntity, NumberEntity): """A number implementation for a melnor device.""" entity_description: MelnorZoneNumberEntityDescription + _attr_mode = NumberMode.BOX def __init__( self, @@ -88,7 +116,7 @@ def __init__( @property def native_value(self) -> float | None: """Return the current value.""" - return self._valve.manual_watering_minutes + return self.entity_description.state_fn(self._valve) async def async_set_native_value(self, value: float) -> None: """Update the current value.""" diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index 1061d084ad1bd0..edb906cc80f41e 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -45,6 +45,15 @@ def watering_seconds_left(valve: Valve) -> datetime | None: return dt_util.utc_from_timestamp(valve.watering_end_time) +def next_cycle(valve: Valve) -> datetime | None: + """Return the value of the next_cycle date, only if the cycle is enabled.""" + + if valve.schedule_enabled is True: + return valve.next_cycle + + return None + + @dataclass class MelnorSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -78,7 +87,6 @@ class MelnorSensorEntityDescription( device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, key="battery", - name="Battery", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, state_fn=lambda device: device.battery_level, @@ -88,7 +96,7 @@ class MelnorSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, key="rssi", - name="RSSI", + translation_key="rssi", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, state_fn=lambda device: device.rssi, @@ -99,9 +107,15 @@ class MelnorSensorEntityDescription( MelnorZoneSensorEntityDescription( device_class=SensorDeviceClass.TIMESTAMP, key="manual_cycle_end", - name="Manual Cycle End", + translation_key="manual_cycle_end", state_fn=watering_seconds_left, ), + MelnorZoneSensorEntityDescription( + device_class=SensorDeviceClass.TIMESTAMP, + key="next_cycle", + translation_key="next_cycle", + state_fn=next_cycle, + ), ] diff --git a/homeassistant/components/melnor/strings.json b/homeassistant/components/melnor/strings.json index 2fefa32b6bc1f2..51ca18b0b3d1c0 100644 --- a/homeassistant/components/melnor/strings.json +++ b/homeassistant/components/melnor/strings.json @@ -10,5 +10,39 @@ "title": "Discovered Melnor Bluetooth valve" } } + }, + "entity": { + "number": { + "manual_minutes": { + "name": "Manual duration" + }, + "frequency_interval_hours": { + "name": "Schedule interval" + }, + "frequency_duration_minutes": { + "name": "Schedule duration" + } + }, + "sensor": { + "rssi": { + "name": "RSSI" + }, + "manual_cycle_end": { + "name": "Manual cycle end" + }, + "next_cycle": { + "name": "Next cycle" + } + }, + "switch": { + "frequency": { + "name": "Schedule" + } + }, + "time": { + "frequency_start_time": { + "name": "Schedule start time" + } + } } } diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index eca6f1a98cfbba..03bd28faa9d98e 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -45,9 +45,18 @@ class MelnorSwitchEntityDescription( 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, - ) + ), + MelnorSwitchEntityDescription( + device_class=SwitchDeviceClass.SWITCH, + icon="mdi:calendar-sync-outline", + key="frequency", + translation_key="frequency", + on_off_fn=lambda valve, bool: valve.set_frequency_enabled(bool), + state_fn=lambda valve: valve.schedule_enabled, + ), ] diff --git a/homeassistant/components/melnor/time.py b/homeassistant/components/melnor/time.py new file mode 100644 index 00000000000000..943a7996aeb250 --- /dev/null +++ b/homeassistant/components/melnor/time.py @@ -0,0 +1,91 @@ +"""Number support for Melnor Bluetooth water timer.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from datetime import time +from typing import Any + +from melnor_bluetooth.device import Valve + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .models import ( + MelnorDataUpdateCoordinator, + MelnorZoneEntity, + get_entities_for_valves, +) + + +@dataclass +class MelnorZoneTimeEntityDescriptionMixin: + """Mixin for required keys.""" + + set_time_fn: Callable[[Valve, time], Coroutine[Any, Any, None]] + state_fn: Callable[[Valve], Any] + + +@dataclass +class MelnorZoneTimeEntityDescription( + TimeEntityDescription, MelnorZoneTimeEntityDescriptionMixin +): + """Describes Melnor number entity.""" + + +ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneTimeEntityDescription] = [ + MelnorZoneTimeEntityDescription( + entity_category=EntityCategory.CONFIG, + key="frequency_start_time", + translation_key="frequency_start_time", + set_time_fn=lambda valve, value: valve.set_frequency_start_time(value), + state_fn=lambda valve: valve.frequency.start_time, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the number platform.""" + + coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + get_entities_for_valves( + coordinator, + ZONE_ENTITY_DESCRIPTIONS, + lambda valve, description: MelnorZoneTime(coordinator, description, valve), + ) + ) + + +class MelnorZoneTime(MelnorZoneEntity, TimeEntity): + """A time implementation for a melnor device.""" + + entity_description: MelnorZoneTimeEntityDescription + + def __init__( + self, + coordinator: MelnorDataUpdateCoordinator, + entity_description: MelnorZoneTimeEntityDescription, + valve: Valve, + ) -> None: + """Initialize a number for a melnor device.""" + super().__init__(coordinator, entity_description, valve) + + @property + def native_value(self) -> time | None: + """Return the current value.""" + return self.entity_description.state_fn(self._valve) + + async def async_set_value(self, value: time) -> None: + """Update the current value.""" + await self.entity_description.set_time_fn(self._valve, value) diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index c676f15336eca2..32b095230d90f2 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -179,6 +179,6 @@ async def fetch_data(self) -> Self: raise CannotConnect() self.current_weather_data = self._weather_data.get_current_weather() time_zone = dt_util.DEFAULT_TIME_ZONE - self.daily_forecast = self._weather_data.get_forecast(time_zone, False) + self.daily_forecast = self._weather_data.get_forecast(time_zone, False, 0) self.hourly_forecast = self._weather_data.get_forecast(time_zone, True) return self diff --git a/homeassistant/components/met/const.py b/homeassistant/components/met/const.py index 5b2a756847e805..dcc493570ba2ec 100644 --- a/homeassistant/components/met/const.py +++ b/homeassistant/components/met/const.py @@ -10,19 +10,24 @@ 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 @@ 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_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 5e00e13b808e1e..5c476b10665202 100644 --- a/homeassistant/components/met/manifest.json +++ b/homeassistant/components/met/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/met", "iot_class": "cloud_polling", "loggers": ["metno"], - "requirements": ["pyMetno==0.9.0"] + "requirements": ["PyMetno==0.10.0"] } diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index a6dcb23cc475c6..20822dc99732ae 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -7,10 +7,12 @@ 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 @@ def wind_bearing(self) -> float | str | None: 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.""" @@ -202,7 +218,7 @@ def forecast(self) -> list[Forecast] | None: def device_info(self) -> DeviceInfo: """Device info.""" return DeviceInfo( - default_name="Forecast", + name="Forecast", entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN,)}, # type: ignore[arg-type] manufacturer="Met.no", diff --git a/homeassistant/components/met_eireann/manifest.json b/homeassistant/components/met_eireann/manifest.json index 1e05787158adc9..72afc6977dd978 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/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index cce35731c728d1..bf0d7214c6efc1 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -159,7 +159,7 @@ def forecast(self): def device_info(self): """Device info.""" return DeviceInfo( - default_name="Forecast", + name="Forecast", entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN,)}, manufacturer="Met Éireann", diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 3b82399f217477..ccd237628507cd 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -133,10 +133,8 @@ async def _async_update_data_alert(): await coordinator_alert.async_refresh() - if not coordinator_alert.last_update_success: - raise ConfigEntryNotReady - - hass.data[DOMAIN][department] = True + if coordinator_alert.last_update_success: + hass.data[DOMAIN][department] = True else: _LOGGER.warning( ( @@ -158,11 +156,12 @@ async def _async_update_data_alert(): undo_listener = entry.add_update_listener(_async_update_listener) hass.data[DOMAIN][entry.entry_id] = { + UNDO_UPDATE_LISTENER: undo_listener, COORDINATOR_FORECAST: coordinator_forecast, COORDINATOR_RAIN: coordinator_rain, - COORDINATOR_ALERT: coordinator_alert, - UNDO_UPDATE_LISTENER: undo_listener, } + if coordinator_alert and coordinator_alert.last_update_success: + hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index c87aea052604d6..8c27f2970a3f27 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -247,11 +247,7 @@ def __init__( @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 @@ def native_value(self): 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 e1a530eef97d00..7709ba0a63880d 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -109,11 +109,7 @@ def name(self): @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/tts.py b/homeassistant/components/microsoft/tts.py index 7deb8f27c68df0..9bcd7f533f8f9d 100644 --- a/homeassistant/components/microsoft/tts.py +++ b/homeassistant/components/microsoft/tts.py @@ -1,12 +1,13 @@ """Support for the Microsoft Cognitive Services text-to-speech service.""" -from http.client import HTTPException import logging from pycsspeechtts import pycsspeechtts +from requests.exceptions import HTTPError import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider from homeassistant.const import CONF_API_KEY, CONF_REGION, CONF_TYPE, PERCENTAGE +from homeassistant.generated.microsoft_tts import SUPPORTED_LANGUAGES import homeassistant.helpers.config_validation as cv CONF_GENDER = "gender" @@ -17,80 +18,6 @@ CONF_CONTOUR = "contour" _LOGGER = logging.getLogger(__name__) -SUPPORTED_LANGUAGES = [ - "ar-eg", - "ar-sa", - "bg-bg", - "ca-es", - "cs-cz", - "cy-gb", - "da-dk", - "de-at", - "de-ch", - "de-de", - "el-gr", - "en-au", - "en-ca", - "en-gb", - "en-hk", - "en-ie", - "en-in", - "en-nz", - "en-ph", - "en-sg", - "en-us", - "en-za", - "es-ar", - "es-co", - "es-es", - "es-mx", - "es-us", - "et-ee", - "fi-fi", - "fr-be", - "fr-ca", - "fr-ch", - "fr-fr", - "ga-ie", - "gu-in", - "he-il", - "hi-in", - "hr-hr", - "hu-hu", - "id-id", - "is-is", - "it-it", - "ja-jp", - "ko-kr", - "lt-lt", - "lv-lv", - "mr-in", - "ms-my", - "mt-mt", - "nb-no", - "nl-be", - "nl-nl", - "pl-pl", - "pt-br", - "pt-pt", - "ro-ro", - "ru-ru", - "sk-sk", - "sl-si", - "sv-se", - "sw-ke", - "ta-in", - "te-in", - "th-th", - "tr-tr", - "uk-ua", - "ur-pk", - "vi-vn", - "zh-cn", - "zh-hk", - "zh-tw", -] - GENDERS = ["Female", "Male"] DEFAULT_LANG = "en-us" @@ -176,7 +103,7 @@ def default_options(self): """Return a dict include default options.""" return {CONF_GENDER: self._gender, CONF_TYPE: self._type} - def get_tts_audio(self, message, language, options=None): + def get_tts_audio(self, message, language, options): """Load TTS from Microsoft.""" if language is None: language = self._lang @@ -194,7 +121,7 @@ def get_tts_audio(self, message, language, options=None): contour=self._contour, text=message, ) - except HTTPException as ex: + except HTTPError as ex: _LOGGER.error("Error occurred for Microsoft TTS: %s", ex) return (None, None) return ("mp3", data) diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index 82a9accac59444..f57a9146858559 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -17,6 +17,7 @@ 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 @@ 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_create_group(service: ServiceCall) -> None: 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_delete_group(service: ServiceCall) -> None: 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 @@ def extra_state_attributes(self): 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 @@ def __init__(self, hass, server_loc, api_key, timeout, entities): 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 @@ async def update_store(self): """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 42b759b3cdf3aa..f1487ed59f1cdb 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 @@ def __init__( 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 @@ def _update_attr(self, heater): 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/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index dad8ebe7f11502..801b27ee9716f2 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -237,7 +237,6 @@ def __init__( ) -> None: """Initialize base entity.""" self._server = server - self._attr_name = type_name self._attr_icon = icon self._attr_unique_id = f"{self._server.unique_id}-{type_name}" self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 0bf4cdab859057..ecf7d747770345 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -29,6 +29,8 @@ async def async_setup_entry( class MinecraftServerStatusBinarySensor(MinecraftServerEntity, BinarySensorEntity): """Representation of a Minecraft Server status binary sensor.""" + _attr_translation_key = "status" + def __init__(self, server: MinecraftServer) -> None: """Initialize status binary sensor.""" super().__init__( diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 2499dd8b75b523..5d056d98dd117a 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -74,6 +74,8 @@ def available(self) -> bool: class MinecraftServerVersionSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server version sensor.""" + _attr_translation_key = "version" + def __init__(self, server: MinecraftServer) -> None: """Initialize version sensor.""" super().__init__(server=server, type_name=NAME_VERSION, icon=ICON_VERSION) @@ -86,6 +88,8 @@ async def async_update(self) -> None: class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server protocol version sensor.""" + _attr_translation_key = "protocol_version" + def __init__(self, server: MinecraftServer) -> None: """Initialize protocol version sensor.""" super().__init__( @@ -102,6 +106,8 @@ async def async_update(self) -> None: class MinecraftServerLatencyTimeSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server latency time sensor.""" + _attr_translation_key = "latency" + def __init__(self, server: MinecraftServer) -> None: """Initialize latency time sensor.""" super().__init__( @@ -119,6 +125,8 @@ async def async_update(self) -> None: class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server online players sensor.""" + _attr_translation_key = "players_online" + def __init__(self, server: MinecraftServer) -> None: """Initialize online players sensor.""" super().__init__( @@ -144,6 +152,8 @@ async def async_update(self) -> None: class MinecraftServerPlayersMaxSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server maximum number of players sensor.""" + _attr_translation_key = "players_max" + def __init__(self, server: MinecraftServer) -> None: """Initialize maximum number of players sensor.""" super().__init__( @@ -161,6 +171,8 @@ async def async_update(self) -> None: class MinecraftServerMOTDSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server MOTD sensor.""" + _attr_translation_key = "motd" + def __init__(self, server: MinecraftServer) -> None: """Initialize MOTD sensor.""" super().__init__( diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json index 9e546a3cdfa171..b4d68bc611744d 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -18,5 +18,32 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "entity": { + "binary_sensor": { + "status": { + "name": "Status" + } + }, + "sensor": { + "version": { + "name": "Version" + }, + "protocol_version": { + "name": "Protocol version" + }, + "latency": { + "name": "Latency" + }, + "players_online": { + "name": "Players online" + }, + "players_max": { + "name": "Players max" + }, + "motd": { + "name": "World message" + } + } } } diff --git a/homeassistant/components/mjpeg/__init__.py b/homeassistant/components/mjpeg/__init__.py index 605c8b6c9d51e9..a5bfc49edf6592 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,8 +16,10 @@ "filter_urllib3_logging", ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MJPEG IP Camera integration.""" filter_urllib3_logging() return True diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index 27f5c7a1411ecc..c2ab3b5768cfc7 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -2,14 +2,13 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable -from contextlib import closing +from collections.abc import AsyncIterator +from contextlib import suppress import aiohttp from aiohttp import web import async_timeout -import requests -from requests.auth import HTTPBasicAuth, HTTPDigestAuth +import httpx from yarl import URL from homeassistant.components.camera import Camera @@ -29,9 +28,13 @@ ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.httpx_client import get_async_client from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER +TIMEOUT = 10 +BUFFER_SIZE = 102400 + async def async_setup_entry( hass: HomeAssistant, @@ -59,11 +62,11 @@ async def async_setup_entry( ) -def extract_image_from_mjpeg(stream: Iterable[bytes]) -> bytes | None: +async def async_extract_image_from_mjpeg(stream: AsyncIterator[bytes]) -> bytes | None: """Take in a MJPEG stream object, return the jpg from it.""" data = b"" - for chunk in stream: + async for chunk in stream: data += chunk jpg_end = data.find(b"\xff\xd9") @@ -137,12 +140,11 @@ async def async_camera_image( self._authentication == HTTP_DIGEST_AUTHENTICATION or self._still_image_url is None ): - image = await self.hass.async_add_executor_job(self.camera_image) - return image + return await self._async_digest_camera_image() websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl) try: - async with async_timeout.timeout(10): + async with async_timeout.timeout(TIMEOUT): response = await websession.get(self._still_image_url, auth=self._auth) image = await response.read() @@ -156,37 +158,65 @@ async def async_camera_image( return None - def camera_image( - self, width: int | None = None, height: int | None = None - ) -> bytes | None: - """Return a still image response from the camera.""" - if self._username and self._password: - if self._authentication == HTTP_DIGEST_AUTHENTICATION: - auth: HTTPDigestAuth | HTTPBasicAuth = HTTPDigestAuth( - self._username, self._password + def _get_digest_auth(self) -> httpx.DigestAuth: + """Return a DigestAuth object.""" + username = "" if self._username is None else self._username + return httpx.DigestAuth(username, self._password) + + async def _async_digest_camera_image(self) -> bytes | None: + """Return a still image response from the camera using digest authentication.""" + client = get_async_client(self.hass, verify_ssl=self._verify_ssl) + auth = self._get_digest_auth() + try: + if self._still_image_url: + # Fallback to MJPEG stream if still image URL is not available + with suppress(asyncio.TimeoutError, httpx.HTTPError): + return ( + await client.get( + self._still_image_url, auth=auth, timeout=TIMEOUT + ) + ).content + + async with client.stream( + "get", self._mjpeg_url, auth=auth, timeout=TIMEOUT + ) as stream: + return await async_extract_image_from_mjpeg( + stream.aiter_bytes(BUFFER_SIZE) ) - else: - auth = HTTPBasicAuth(self._username, self._password) - req = requests.get( - self._mjpeg_url, - auth=auth, - stream=True, - timeout=10, - verify=self._verify_ssl, - ) - else: - req = requests.get(self._mjpeg_url, stream=True, timeout=10) - with closing(req) as response: - return extract_image_from_mjpeg(response.iter_content(102400)) + except asyncio.TimeoutError: + LOGGER.error("Timeout getting camera image from %s", self.name) + + except httpx.HTTPError as err: + LOGGER.error("Error getting new camera image from %s: %s", self.name, err) + + return None + + async def _handle_async_mjpeg_digest_stream( + self, request: web.Request + ) -> web.StreamResponse | None: + """Generate an HTTP MJPEG stream from the camera using digest authentication.""" + async with get_async_client(self.hass, verify_ssl=self._verify_ssl).stream( + "get", self._mjpeg_url, auth=self._get_digest_auth(), timeout=TIMEOUT + ) as stream: + response = web.StreamResponse(headers=stream.headers) + await response.prepare(request) + # Stream until we are done or client disconnects + with suppress(asyncio.TimeoutError, httpx.HTTPError): + async for chunk in stream.aiter_bytes(BUFFER_SIZE): + if not self.hass.is_running: + break + async with async_timeout.timeout(TIMEOUT): + await response.write(chunk) + return response async def handle_async_mjpeg_stream( self, request: web.Request ) -> web.StreamResponse | None: """Generate an HTTP MJPEG stream from the camera.""" - # aiohttp don't support DigestAuth -> Fallback + # aiohttp don't support DigestAuth so we use httpx if self._authentication == HTTP_DIGEST_AUTHENTICATION: - return await super().handle_async_mjpeg_stream(request) + return await self._handle_async_mjpeg_digest_stream(request) # connect to stream websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl) diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 70c23da66e23fc..3d33af38761797 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -10,7 +10,11 @@ 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 @@ 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 f78e3ef9d318d3..43f43585775c55 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -14,7 +14,6 @@ STATE_ON, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DEVICE_CLASS_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -74,11 +73,6 @@ async def async_setup_slaves( # this ensures that idx = bit position of value in result # polling is done with the base class name = self._attr_name if self._attr_name else "modbus_sensor" - - # DataUpdateCoordinator does not support DEVICE_CLASS_NAME - # the assert satisfies the type checker and will catch attempts - # to use DEVICE_CLASS_NAME in _attr_name. - assert name is not DEVICE_CLASS_NAME self._coordinator = DataUpdateCoordinator( hass, _LOGGER, @@ -121,7 +115,10 @@ async def async_update(self, now: datetime | None = None) -> None: 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 bb64a264248a21..c2e6b9ef467b7f 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/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 7c1c3b0a7913dd..ca8246577fd4e3 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -17,7 +17,6 @@ CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DEVICE_CLASS_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( @@ -80,11 +79,6 @@ async def async_setup_slaves( # this ensures that idx = bit position of value in result # polling is done with the base class name = self._attr_name if self._attr_name else "modbus_sensor" - - # DataUpdateCoordinator does not support DEVICE_CLASS_NAME - # the assert satisfies the type checker and will catch attempts - # to use DEVICE_CLASS_NAME in _attr_name. - assert name is not DEVICE_CLASS_NAME self._coordinator = DataUpdateCoordinator( hass, _LOGGER, diff --git a/homeassistant/components/modem_callerid/config_flow.py b/homeassistant/components/modem_callerid/config_flow.py index 537fe81da11339..fac20073fe939a 100644 --- a/homeassistant/components/modem_callerid/config_flow.py +++ b/homeassistant/components/modem_callerid/config_flow.py @@ -32,9 +32,7 @@ def __init__(self) -> None: 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 1ff348fb3b7b5d..34e5be431556e9 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/moehlenhoff_alpha2/button.py b/homeassistant/components/moehlenhoff_alpha2/button.py index 44dbd08ffcdf5f..c637909417c922 100644 --- a/homeassistant/components/moehlenhoff_alpha2/button.py +++ b/homeassistant/components/moehlenhoff_alpha2/button.py @@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import Alpha2BaseCoordinator from .const import DOMAIN @@ -38,4 +38,4 @@ def __init__(self, coordinator: Alpha2BaseCoordinator, entry_id: str) -> None: async def async_press(self) -> None: """Synchronize current local time from HA instance to base station.""" - await self.coordinator.base.set_datetime(dt.now()) + await self.coordinator.base.set_datetime(dt_util.now()) diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index f8e1cd24abeacc..10251fc679d3d9 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_NEW_MOON, + STATE_WAXING_CRESCENT, STATE_FIRST_QUARTER, + STATE_WAXING_GIBBOUS, STATE_FULL_MOON, + STATE_WANING_GIBBOUS, STATE_LAST_QUARTER, - STATE_NEW_MOON, STATE_WANING_CRESCENT, - STATE_WANING_GIBBOUS, - STATE_WAXING_CRESCENT, - STATE_WAXING_GIBBOUS, ] _attr_translation_key = "phase" diff --git a/homeassistant/components/mopeka/manifest.json b/homeassistant/components/mopeka/manifest.json index 711041921536fc..d6b5618bf97eb7 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 332a30a5e5fa63..d241f03a02e913 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -28,4 +28,6 @@ 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 aaf74a96de00d4..17918133614139 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -15,7 +15,7 @@ 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 @@ 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 @@ def __init__(self, coordinator, blind, device_class, sw_version): 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 @@ async def async_scheduled_update_request(self, *_): 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 @@ async def async_stop_cover(self, **kwargs: Any) -> None: 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 @@ async def async_stop_cover_tilt(self, **kwargs: Any) -> None: 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 @@ async def async_stop_cover(self, **kwargs: Any) -> None: """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/__init__.py b/homeassistant/components/mqtt/__init__.py index d3806044fcc521..3fb6c8d2c48547 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -29,10 +29,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms -from homeassistant.helpers.reload import ( - async_integration_yaml_config, - async_reload_integration_platforms, -) +from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType @@ -374,7 +371,6 @@ async def async_forward_entry_setup_and_setup_discovery( conf: ConfigType, ) -> None: """Forward the config entry setup to the platforms and set up discovery.""" - reload_manual_setup: bool = False # Local import to avoid circular dependencies # pylint: disable-next=import-outside-toplevel from . import device_automation, tag @@ -399,35 +395,12 @@ async def async_forward_entry_setup_and_setup_discovery( ) # Setup reload service after all platforms have loaded await async_setup_reload_service() - # When the entry is reloaded, also reload manual set up items to enable MQTT - if mqtt_data.reload_entry: - mqtt_data.reload_entry = False - reload_manual_setup = True - - # When the entry was disabled before, reload manual set up items to enable - # MQTT again - if mqtt_data.reload_needed: - mqtt_data.reload_needed = False - reload_manual_setup = True - - if reload_manual_setup: - await async_reload_manual_mqtt_items(hass) await async_forward_entry_setup_and_setup_discovery(entry, conf) return True -async def async_reload_manual_mqtt_items(hass: HomeAssistant) -> None: - """Reload manual configured MQTT items.""" - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - - @websocket_api.websocket_command( {vol.Required("type"): "mqtt/device/debug_info", vol.Required("device_id"): str} ) @@ -570,17 +543,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Cleanup listeners mqtt_client.cleanup() - # Trigger reload manual MQTT items at entry setup - if (mqtt_entry_status := mqtt_config_entry_enabled(hass)) is False: - # The entry is disabled reload legacy manual items when - # the entry is enabled again - mqtt_data.reload_needed = True - elif mqtt_entry_status is True: - # The entry is reloaded: - # Trigger re-fetching the yaml config at entry setup - mqtt_data.reload_entry = True - # Reload the legacy yaml platform to make entities unavailable - await async_reload_integration_platforms(hass, DOMAIN, RELOADABLE_PLATFORMS) # Cleanup entity registry hooks registry_hooks = mqtt_data.discovery_registry_hooks while registry_hooks: diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index de593385c1f8e1..a5360090bb9e9d 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -12,9 +12,6 @@ "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 @@ "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 @@ "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 @@ "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 @@ "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 @@ "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/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index b685daaf6f1825..dbed1c8aa9e8bd 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -41,12 +41,7 @@ CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic @@ -112,13 +107,6 @@ } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT alarm control panels under the alarm_control_panel platform key -# was deprecated in HA Core 2022.6; -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(alarm.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index d09e31f65f9ac6..50af9ef8a555e5 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -43,7 +43,6 @@ MqttAvailability, MqttEntity, async_setup_entry_helper, - warn_for_legacy_schema, ) from .models import MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data @@ -69,13 +68,6 @@ } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Binary sensors under the binary_sensor platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(binary_sensor.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index f81f78a487a0e2..46ecc16d385513 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -22,12 +22,7 @@ CONF_QOS, CONF_RETAIN, ) -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import MqttCommandTemplate from .util import valid_publish_topic @@ -46,14 +41,6 @@ } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Buttons under the button platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(button.DOMAIN), -) - - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index b3a78f4d2ffe8c..75ab25efcfa28f 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -20,12 +20,7 @@ from .config import MQTT_BASE_SCHEMA from .const import CONF_QOS, CONF_TOPIC from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ReceiveMessage from .util import valid_subscribe_topic @@ -56,12 +51,6 @@ PLATFORM_SCHEMA_BASE.schema, ) -# Configuring MQTT Camera under the camera platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(camera.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 140d9fb128bc57..40ec754aa44076 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -1,6 +1,7 @@ """Support for MQTT climate devices.""" from __future__ import annotations +from abc import ABC, abstractmethod from collections.abc import Callable import functools import logging @@ -14,9 +15,7 @@ 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, @@ -41,29 +40,45 @@ 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, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -77,33 +92,15 @@ 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" @@ -111,34 +108,26 @@ 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" @@ -147,13 +136,10 @@ 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, @@ -199,6 +185,7 @@ 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, @@ -316,6 +303,7 @@ def valid_humidity_state_configuration(config: ConfigType) -> ConfigType: 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( @@ -342,9 +330,9 @@ def valid_humidity_state_configuration(config: ConfigType) -> ConfigType: ): 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, @@ -364,23 +352,10 @@ def valid_humidity_state_configuration(config: ConfigType) -> ConfigType: ).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,33 +364,13 @@ def valid_humidity_state_configuration(config: ConfigType) -> ConfigType: valid_humidity_state_configuration, ) -# Configuring MQTT Climate under the climate platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(climate.DOMAIN), -) - _DISCOVERY_SCHEMA_BASE = _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA) DISCOVERY_SCHEMA = vol.All( _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, @@ -447,19 +402,209 @@ async def _async_setup_entity( async_add_entities([MqttClimate(hass, config, config_entry, discovery_data)]) -class MqttClimate(MqttEntity, ClimateEntity): - """Representation of an MQTT climate device.""" +class MqttTemperatureControlEntity(MqttEntity, ABC): + """Helper entity class to control temperature. - _entity_id_format = climate.ENTITY_ID_FORMAT - _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED + MqttTemperatureControlEntity supports shared methods for + climate and water_heater platforms. + """ + + _attr_target_temperature_low: float | None + _attr_target_temperature_high: float | None - _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]] - _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]] - _feature_preset_mode: bool _optimistic: bool - _optimistic_preset_mode: bool _topic: dict[str, Any] + _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]] + _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]] + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None, + ) -> None: + """Initialize the temperature controlled device.""" + self._attr_target_temperature_low = None + self._attr_target_temperature_high = None + self._feature_preset_mode = False + MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + + def add_subscription( + self, + topics: dict[str, dict[str, Any]], + topic: str, + msg_callback: Callable[[ReceiveMessage], None], + ) -> None: + """Add a subscription.""" + qos: int = self._config[CONF_QOS] + if 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 render_template( + self, msg: ReceiveMessage, template_name: str + ) -> ReceivePayloadType: + """Render a template by name.""" + template = self._value_templates[template_name] + return template(msg.payload) + + @callback + def handle_climate_attribute_received( + self, msg: ReceiveMessage, template_name: str, attr: str + ) -> None: + """Handle climate attributes coming via MQTT.""" + payload = self.render_template(msg, template_name) + if not payload: + _LOGGER.debug( + "Invalid empty payload for attribute %s, ignoring update", + attr, + ) + return + if payload == PAYLOAD_NONE: + setattr(self, attr, None) + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + return + try: + setattr(self, attr, float(payload)) + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + except ValueError: + _LOGGER.error("Could not parse %s from %s", template_name, payload) + + def prepare_subscribe_topics( + self, topics: dict[str, dict[str, Any]] + ) -> None: # noqa: C901 + """(Re)Subscribe to topics.""" + + @callback + @log_messages(self.hass, self.entity_id) + def handle_current_temperature_received(msg: ReceiveMessage) -> None: + """Handle current temperature coming via MQTT.""" + self.handle_climate_attribute_received( + msg, CONF_CURRENT_TEMP_TEMPLATE, "_attr_current_temperature" + ) + + self.add_subscription( + topics, CONF_CURRENT_TEMP_TOPIC, handle_current_temperature_received + ) + + @callback + @log_messages(self.hass, self.entity_id) + def handle_target_temperature_received(msg: ReceiveMessage) -> None: + """Handle target temperature coming via MQTT.""" + self.handle_climate_attribute_received( + msg, CONF_TEMP_STATE_TEMPLATE, "_attr_target_temperature" + ) + + self.add_subscription( + topics, CONF_TEMP_STATE_TOPIC, handle_target_temperature_received + ) + + @callback + @log_messages(self.hass, self.entity_id) + def handle_temperature_low_received(msg: ReceiveMessage) -> None: + """Handle target temperature low coming via MQTT.""" + self.handle_climate_attribute_received( + msg, CONF_TEMP_LOW_STATE_TEMPLATE, "_attr_target_temperature_low" + ) + + self.add_subscription( + topics, CONF_TEMP_LOW_STATE_TOPIC, handle_temperature_low_received + ) + + @callback + @log_messages(self.hass, self.entity_id) + def handle_temperature_high_received(msg: ReceiveMessage) -> None: + """Handle target temperature high coming via MQTT.""" + self.handle_climate_attribute_received( + msg, CONF_TEMP_HIGH_STATE_TEMPLATE, "_attr_target_temperature_high" + ) + + self.add_subscription( + topics, CONF_TEMP_HIGH_STATE_TOPIC, handle_temperature_high_received + ) + + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, self._sub_state, topics + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + await subscription.async_subscribe_topics(self.hass, self._sub_state) + + async def _publish(self, topic: str, payload: PublishPayloadType) -> None: + if self._topic[topic] is not None: + await self.async_publish( + self._topic[topic], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + + async def _set_climate_attribute( + self, + temp: float | None, + cmnd_topic: str, + cmnd_template: str, + state_topic: str, + attr: str, + ) -> bool: + if temp is None: + return False + changed = False + if self._optimistic or self._topic[state_topic] is None: + # optimistic mode + changed = True + setattr(self, attr, temp) + + payload = self._command_templates[cmnd_template](temp) + await self._publish(cmnd_topic, payload) + return changed + + @abstractmethod + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + changed = await self._set_climate_attribute( + kwargs.get(ATTR_TEMPERATURE), + CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_COMMAND_TEMPLATE, + CONF_TEMP_STATE_TOPIC, + "_attr_target_temperature", + ) + + changed |= await self._set_climate_attribute( + kwargs.get(ATTR_TARGET_TEMP_LOW), + CONF_TEMP_LOW_COMMAND_TOPIC, + CONF_TEMP_LOW_COMMAND_TEMPLATE, + CONF_TEMP_LOW_STATE_TOPIC, + "_attr_target_temperature_low", + ) + + changed |= await self._set_climate_attribute( + kwargs.get(ATTR_TARGET_TEMP_HIGH), + CONF_TEMP_HIGH_COMMAND_TOPIC, + CONF_TEMP_HIGH_COMMAND_TEMPLATE, + CONF_TEMP_HIGH_STATE_TOPIC, + "_attr_target_temperature_high", + ) + + if not changed: + return + self.async_write_ha_state() + + +class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): + """Representation of an MQTT climate device.""" + + _entity_id_format = climate.ENTITY_ID_FORMAT + _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED + def __init__( self, hass: HomeAssistant, @@ -473,9 +618,9 @@ def __init__( self._attr_hvac_mode = None self._attr_is_aux_heat = None self._attr_swing_mode = None - self._attr_target_temperature_low = None - self._attr_target_temperature_high = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + MqttTemperatureControlEntity.__init__( + self, hass, config, config_entry, discovery_data + ) @staticmethod def config_schema() -> vol.Schema: @@ -485,28 +630,41 @@ def config_schema() -> vol.Schema: 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] + # 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] - self._attr_precision = config.get(CONF_PRECISION, super().precision) + 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._attr_temperature_unit = config.get( - CONF_TEMPERATURE_UNIT, self.hass.config.units.temperature_unit - ) 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 @@ -594,32 +752,12 @@ def _setup_from_config(self, config: ConfigType) -> None: def _prepare_subscribe_topics(self) -> None: # noqa: C901 """(Re)Subscribe to topics.""" topics: dict[str, dict[str, Any]] = {} - qos: int = self._config[CONF_QOS] - - def add_subscription( - topics: dict[str, dict[str, Any]], - topic: str, - msg_callback: Callable[[ReceiveMessage], None], - ) -> None: - if self._topic[topic] is not None: - topics[topic] = { - "topic": self._topic[topic], - "msg_callback": msg_callback, - "qos": qos, - "encoding": self._config[CONF_ENCODING] or None, - } - - def render_template( - msg: ReceiveMessage, template_name: str - ) -> ReceivePayloadType: - template = self._value_templates[template_name] - return template(msg.payload) @callback @log_messages(self.hass, self.entity_id) def handle_action_received(msg: ReceiveMessage) -> None: """Handle receiving action via MQTT.""" - payload = render_template(msg, CONF_ACTION_TEMPLATE) + payload = self.render_template(msg, CONF_ACTION_TEMPLATE) if not payload or payload == PAYLOAD_NONE: _LOGGER.debug( "Invalid %s action: %s, ignoring", @@ -638,87 +776,17 @@ def handle_action_received(msg: ReceiveMessage) -> None: return get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - add_subscription(topics, CONF_ACTION_TOPIC, handle_action_received) - - @callback - def handle_climate_attribute_received( - msg: ReceiveMessage, template_name: str, attr: str - ) -> None: - """Handle climate attributes coming via MQTT.""" - payload = render_template(msg, template_name) - if not payload: - _LOGGER.debug( - "Invalid empty payload for attribute %s, ignoring update", - attr, - ) - return - if payload == PAYLOAD_NONE: - setattr(self, attr, None) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - return - try: - setattr(self, attr, float(payload)) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - except ValueError: - _LOGGER.error("Could not parse %s from %s", template_name, payload) - - @callback - @log_messages(self.hass, self.entity_id) - def handle_current_temperature_received(msg: ReceiveMessage) -> None: - """Handle current temperature coming via MQTT.""" - handle_climate_attribute_received( - msg, CONF_CURRENT_TEMP_TEMPLATE, "_attr_current_temperature" - ) - - add_subscription( - topics, CONF_CURRENT_TEMP_TOPIC, handle_current_temperature_received - ) - - @callback - @log_messages(self.hass, self.entity_id) - def handle_target_temperature_received(msg: ReceiveMessage) -> None: - """Handle target temperature coming via MQTT.""" - handle_climate_attribute_received( - msg, CONF_TEMP_STATE_TEMPLATE, "_attr_target_temperature" - ) - - add_subscription( - topics, CONF_TEMP_STATE_TOPIC, handle_target_temperature_received - ) - - @callback - @log_messages(self.hass, self.entity_id) - def handle_temperature_low_received(msg: ReceiveMessage) -> None: - """Handle target temperature low coming via MQTT.""" - handle_climate_attribute_received( - msg, CONF_TEMP_LOW_STATE_TEMPLATE, "_attr_target_temperature_low" - ) - - add_subscription( - topics, CONF_TEMP_LOW_STATE_TOPIC, handle_temperature_low_received - ) - - @callback - @log_messages(self.hass, self.entity_id) - def handle_temperature_high_received(msg: ReceiveMessage) -> None: - """Handle target temperature high coming via MQTT.""" - handle_climate_attribute_received( - msg, CONF_TEMP_HIGH_STATE_TEMPLATE, "_attr_target_temperature_high" - ) - - add_subscription( - topics, CONF_TEMP_HIGH_STATE_TOPIC, handle_temperature_high_received - ) + self.add_subscription(topics, CONF_ACTION_TOPIC, handle_action_received) @callback @log_messages(self.hass, self.entity_id) def handle_current_humidity_received(msg: ReceiveMessage) -> None: """Handle current humidity coming via MQTT.""" - handle_climate_attribute_received( + self.handle_climate_attribute_received( msg, CONF_CURRENT_HUMIDITY_TEMPLATE, "_attr_current_humidity" ) - add_subscription( + self.add_subscription( topics, CONF_CURRENT_HUMIDITY_TOPIC, handle_current_humidity_received ) @@ -727,11 +795,11 @@ def handle_current_humidity_received(msg: ReceiveMessage) -> None: def handle_target_humidity_received(msg: ReceiveMessage) -> None: """Handle target humidity coming via MQTT.""" - handle_climate_attribute_received( + self.handle_climate_attribute_received( msg, CONF_HUMIDITY_STATE_TEMPLATE, "_attr_target_humidity" ) - add_subscription( + self.add_subscription( topics, CONF_HUMIDITY_STATE_TOPIC, handle_target_humidity_received ) @@ -740,7 +808,7 @@ def handle_mode_received( msg: ReceiveMessage, template_name: str, attr: str, mode_list: str ) -> None: """Handle receiving listed mode via MQTT.""" - payload = render_template(msg, template_name) + payload = self.render_template(msg, template_name) if payload not in self._config[mode_list]: _LOGGER.error("Invalid %s mode: %s", mode_list, payload) @@ -756,7 +824,9 @@ def handle_current_mode_received(msg: ReceiveMessage) -> None: msg, CONF_MODE_STATE_TEMPLATE, "_attr_hvac_mode", CONF_MODE_LIST ) - add_subscription(topics, CONF_MODE_STATE_TOPIC, handle_current_mode_received) + self.add_subscription( + topics, CONF_MODE_STATE_TOPIC, handle_current_mode_received + ) @callback @log_messages(self.hass, self.entity_id) @@ -769,7 +839,9 @@ def handle_fan_mode_received(msg: ReceiveMessage) -> None: CONF_FAN_MODE_LIST, ) - add_subscription(topics, CONF_FAN_MODE_STATE_TOPIC, handle_fan_mode_received) + self.add_subscription( + topics, CONF_FAN_MODE_STATE_TOPIC, handle_fan_mode_received + ) @callback @log_messages(self.hass, self.entity_id) @@ -782,7 +854,7 @@ def handle_swing_mode_received(msg: ReceiveMessage) -> None: CONF_SWING_MODE_LIST, ) - add_subscription( + self.add_subscription( topics, CONF_SWING_MODE_STATE_TOPIC, handle_swing_mode_received ) @@ -791,7 +863,7 @@ def handle_onoff_mode_received( msg: ReceiveMessage, template_name: str, attr: str ) -> None: """Handle receiving on/off mode via MQTT.""" - payload = render_template(msg, template_name) + payload = self.render_template(msg, template_name) payload_on: str = self._config[CONF_PAYLOAD_ON] payload_off: str = self._config[CONF_PAYLOAD_OFF] @@ -817,13 +889,13 @@ def handle_aux_mode_received(msg: ReceiveMessage) -> None: msg, CONF_AUX_STATE_TEMPLATE, "_attr_is_aux_heat" ) - add_subscription(topics, CONF_AUX_STATE_TOPIC, handle_aux_mode_received) + self.add_subscription(topics, CONF_AUX_STATE_TOPIC, handle_aux_mode_received) @callback @log_messages(self.hass, self.entity_id) def handle_preset_mode_received(msg: ReceiveMessage) -> None: """Handle receiving preset mode via MQTT.""" - preset_mode = render_template(msg, CONF_PRESET_MODE_VALUE_TEMPLATE) + preset_mode = self.render_template(msg, CONF_PRESET_MODE_VALUE_TEMPLATE) if preset_mode in [PRESET_NONE, PAYLOAD_NONE]: self._attr_preset_mode = PRESET_NONE get_mqtt_data(self.hass).state_write_requests.write_state_request(self) @@ -831,7 +903,10 @@ def handle_preset_mode_received(msg: ReceiveMessage) -> None: if not preset_mode: _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) return - if not self.preset_modes or preset_mode not in self.preset_modes: + if ( + not self._attr_preset_modes + or preset_mode not in self._attr_preset_modes + ): _LOGGER.warning( "'%s' received on topic %s. '%s' is not a valid preset mode", msg.payload, @@ -843,81 +918,18 @@ def handle_preset_mode_received(msg: ReceiveMessage) -> None: get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - add_subscription( + self.add_subscription( topics, CONF_PRESET_MODE_STATE_TOPIC, handle_preset_mode_received ) - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics - ) - - async def _subscribe_topics(self) -> None: - """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) - - async def _publish(self, topic: str, payload: PublishPayloadType) -> None: - if self._topic[topic] is not None: - await self.async_publish( - self._topic[topic], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) - - async def _set_climate_attribute( - self, - temp: float | None, - cmnd_topic: str, - cmnd_template: str, - state_topic: str, - attr: str, - ) -> bool: - if temp is None: - return False - changed = False - if self._optimistic or self._topic[state_topic] is None: - # optimistic mode - changed = True - setattr(self, attr, temp) - - payload = self._command_templates[cmnd_template](temp) - await self._publish(cmnd_topic, payload) - return changed + 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) - - changed = await self._set_climate_attribute( - kwargs.get(ATTR_TEMPERATURE), - CONF_TEMP_COMMAND_TOPIC, - CONF_TEMP_COMMAND_TEMPLATE, - CONF_TEMP_STATE_TOPIC, - "_attr_target_temperature", - ) - - changed |= await self._set_climate_attribute( - kwargs.get(ATTR_TARGET_TEMP_LOW), - CONF_TEMP_LOW_COMMAND_TOPIC, - CONF_TEMP_LOW_COMMAND_TEMPLATE, - CONF_TEMP_LOW_STATE_TOPIC, - "_attr_target_temperature_low", - ) - - changed |= await self._set_climate_attribute( - kwargs.get(ATTR_TARGET_TEMP_HIGH), - CONF_TEMP_HIGH_COMMAND_TOPIC, - CONF_TEMP_HIGH_COMMAND_TEMPLATE, - CONF_TEMP_HIGH_STATE_TOPIC, - "_attr_target_temperature_high", - ) - - if not changed: - return - self.async_write_ha_state() + await super().async_set_temperature(**kwargs) async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" @@ -952,13 +964,6 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: 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) @@ -1003,3 +1008,28 @@ async def async_turn_aux_heat_on(self) -> None: 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 469f52e1488013..ba2e0427ba78ec 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -24,6 +24,7 @@ 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 @@ 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 @@ 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 @@ 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 c91c54a79a41d2..d09a2bb8cb6e17 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -29,6 +29,26 @@ 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 @@ Platform.COVER, Platform.FAN, Platform.HUMIDIFIER, + Platform.IMAGE, Platform.LIGHT, Platform.LOCK, Platform.NUMBER, @@ -106,6 +127,7 @@ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.WATER_HEATER, ] RELOADABLE_PLATFORMS = [ @@ -118,6 +140,7 @@ Platform.DEVICE_TRACKER, Platform.FAN, Platform.HUMIDIFIER, + Platform.IMAGE, Platform.LIGHT, Platform.LOCK, Platform.NUMBER, @@ -129,4 +152,5 @@ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.WATER_HEATER, ] diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index e7c458e6822fc0..0b435db0b7a97e 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -46,12 +46,7 @@ DEFAULT_OPTIMISTIC, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic @@ -205,19 +200,11 @@ def validate_options(config: ConfigType) -> ConfigType: ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) PLATFORM_SCHEMA_MODERN = vol.All( - cv.removed("tilt_invert_state"), _PLATFORM_SCHEMA_BASE, validate_options, ) -# Configuring MQTT Covers under the cover platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(cover.DOMAIN), -) - DISCOVERY_SCHEMA = vol.All( - cv.removed("tilt_invert_state"), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), validate_options, ) diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index dbe7188da7ded2..a9c4017593c9a9 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -36,7 +36,6 @@ MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper, - warn_for_legacy_schema, ) from .models import MqttValueTemplate, ReceiveMessage, ReceivePayloadType from .util import get_mqtt_data, valid_subscribe_topic @@ -78,11 +77,6 @@ def valid_config(config: ConfigType) -> ConfigType: PLATFORM_SCHEMA_MODERN_BASE.extend({}, extra=vol.REMOVE_EXTRA), valid_config ) -# Configuring MQTT Device Trackers under the device_tracker platform key was deprecated -# in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All(warn_for_legacy_schema(device_tracker.DOMAIN)) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 342e7d121f2ef2..70e5ac9e5356b8 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -54,6 +54,7 @@ "device_tracker", "fan", "humidifier", + "image", "light", "lock", "number", @@ -66,6 +67,7 @@ "text", "update", "vacuum", + "water_heater", ] MQTT_DISCOVERY_UPDATED = "mqtt_discovery_updated_{}" @@ -113,7 +115,7 @@ def async_discovery_message_received(msg: ReceiveMessage) -> None: # noqa: C901 "Received message on illegal discovery topic '%s'. The topic" " contains " "not allowed characters. For more information see " - "https://www.home-assistant.io/docs/mqtt/discovery/#discovery-topic" + "https://www.home-assistant.io/integrations/mqtt/#discovery-topic" ), topic, ) diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index e8259c608096f1..f5e92d8ecf958a 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -50,12 +50,7 @@ PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MessageCallbackType, MqttCommandTemplate, @@ -181,12 +176,6 @@ def valid_preset_mode_configuration(config: ConfigType) -> ConfigType: } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Fans under the fan platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(fan.DOMAIN), -) - PLATFORM_SCHEMA_MODERN = vol.All( _PLATFORM_SCHEMA_BASE, valid_speed_range_configuration, diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 2c6dae54f4c80e..392a112bcdb79a 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -10,10 +10,13 @@ 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 . 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, @@ -45,12 +52,7 @@ PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -116,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( @@ -151,13 +157,6 @@ def valid_humidity_range_configuration(config: ConfigType) -> ConfigType: } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Humidifiers under the humidifier platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(humidifier.DOMAIN), -) - PLATFORM_SCHEMA_MODERN = vol.All( _PLATFORM_SCHEMA_BASE, valid_humidity_range_configuration, @@ -170,6 +169,17 @@ def valid_humidity_range_configuration(config: ConfigType) -> ConfigType: 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, @@ -231,17 +241,7 @@ def _setup_from_config(self, config: ConfigType) -> None: 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], @@ -254,6 +254,8 @@ def _setup_from_config(self, config: ConfigType) -> None: 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 @@ -275,6 +277,8 @@ def _setup_from_config(self, config: ConfigType) -> None: 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), @@ -285,6 +289,22 @@ def _setup_from_config(self, config: ConfigType) -> None: 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] = {} @@ -305,13 +325,68 @@ def state_received(msg: ReceiveMessage) -> None: 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) @@ -351,14 +426,9 @@ def target_humidity_received(msg: ReceiveMessage) -> None: 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) @@ -384,14 +454,7 @@ def mode_received(msg: ReceiveMessage) -> None: 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 00000000000000..2764539770daf8 --- /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 f91f76c6a82c51..2c70490ac5e3ad 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from ..mixins import async_setup_entry_helper, warn_for_legacy_schema +from ..mixins import async_setup_entry_helper from .schema import CONF_SCHEMA, MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import ( DISCOVERY_SCHEMA_BASIC, @@ -58,12 +58,6 @@ def validate_mqtt_light_modern(config_value: dict[str, Any]) -> ConfigType: validate_mqtt_light_discovery, ) -# Configuring MQTT Lights under the light platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(light.DOMAIN), -) - PLATFORM_SCHEMA_MODERN = vol.All( MQTT_LIGHT_SCHEMA_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_mqtt_light_modern, @@ -75,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 b3659a67e61409..7f2c2cf5e06abe 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -111,10 +111,6 @@ 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 @@ 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 @@ ) 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 c40dae659b7bad..70992887ca7f2e 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -101,8 +101,6 @@ 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 @@ def valid_color_configuration(config: ConfigType) -> ConfigType: ) 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 c2b4de289fd5e0..063895d738c99a 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -75,7 +75,6 @@ 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 @@ 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 @@ ) 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/lock.py b/homeassistant/components/mqtt/lock.py index 0598c0354ed9e7..966cbc211055e7 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -33,12 +33,7 @@ CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -94,12 +89,6 @@ } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Locks under the lock platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(lock.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) STATE_CONFIG_KEYS = [ diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 388264380915b2..34b61d89c48c87 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -28,16 +28,13 @@ CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import Event, HomeAssistant, async_get_hass, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.device_registry import ( - EVENT_DEVICE_REGISTRY_UPDATED, - DeviceEntry, -) +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -49,8 +46,10 @@ async_generate_entity_id, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_entity_registry_updated_event -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.event import ( + async_track_device_registry_updated_event, + async_track_entity_registry_updated_event, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import json_loads @@ -231,51 +230,6 @@ def validate_device_has_at_least_one_identifier(value: ConfigType) -> ConfigType ) -def warn_for_legacy_schema(domain: str) -> Callable[[ConfigType], ConfigType]: - """Warn once when a legacy platform schema is used.""" - warned = set() - - def validator(config: ConfigType) -> ConfigType: - """Return a validator.""" - nonlocal warned - - # Logged error and repair can be removed from HA 2023.6 - if domain in warned: - return config - - _LOGGER.error( - ( - "Manually configured MQTT %s(s) found under platform key '%s', " - "please move to the mqtt integration key, see " - "https://www.home-assistant.io/integrations/%s.mqtt/" - ), - domain, - domain, - domain, - ) - warned.add(domain) - # Register a repair - async_create_issue( - async_get_hass(), - DOMAIN, - f"deprecated_yaml_{domain}", - breaks_in_ha_version="2022.12.0", # Warning first added in 2022.6.0 - is_fixable=False, - severity=IssueSeverity.ERROR, - translation_key="deprecated_yaml", - translation_placeholders={ - "more_info_url": ( - "https://www.home-assistant.io" - f"/integrations/{domain}.mqtt/#new_format" - ), - "platform": domain, - }, - ) - return config - - return validator - - class SetupEntity(Protocol): """Protocol type for async_setup_entities.""" @@ -290,6 +244,20 @@ async def __call__( """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, @@ -315,8 +283,15 @@ async def async_discover(discovery_payload: MQTTDiscoveryPayload) -> None: 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 @@ -680,8 +655,8 @@ def __init__( ) config_entry.async_on_unload(self._entry_unload) if device_id is not None: - self._remove_device_updated = hass.bus.async_listen( - EVENT_DEVICE_REGISTRY_UPDATED, self._async_device_removed + self._remove_device_updated = async_track_device_registry_updated_event( + hass, device_id, self._async_device_removed ) _LOGGER.info( "%s %s has been initialized", @@ -1083,7 +1058,11 @@ async def mqtt_async_added_to_hass(self) -> None: 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) @@ -1182,19 +1161,16 @@ def async_removed_from_device( hass: HomeAssistant, event: Event, mqtt_device_id: str, config_entry_id: str ) -> bool: """Check if the passed event indicates MQTT was removed from a device.""" - device_id: str = event.data["device_id"] - if event.data["action"] not in ("remove", "update"): - return False - - if device_id != mqtt_device_id: + action: str = event.data["action"] + if action not in ("remove", "update"): return False - if event.data["action"] == "update": + if action == "update": if "config_entries" not in event.data["changes"]: return False device_registry = dr.async_get(hass) if ( - device_entry := device_registry.async_get(device_id) + device_entry := device_registry.async_get(event.data["device_id"]) ) and config_entry_id in device_entry.config_entries: # Not removed from device return False diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index eac333e2a7a7b7..aeae184dc89dec 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -307,11 +307,9 @@ class MqttData: integration_unsubscribe: dict[str, CALLBACK_TYPE] = field(default_factory=dict) last_discovery: float = 0.0 reload_dispatchers: list[CALLBACK_TYPE] = field(default_factory=list) - reload_entry: bool = False reload_handlers: dict[str, Callable[[], Coroutine[Any, Any, None]]] = field( default_factory=dict ) - reload_needed: bool = False state_write_requests: EntityTopicState = field(default_factory=EntityTopicState) subscriptions_to_restore: list[Subscription] = field(default_factory=list) tags: dict[str, dict[str, MQTTTagScanner]] = field(default_factory=dict) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 1ab14b2b4f8f36..5986eab1207e6b 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -42,12 +42,7 @@ CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -107,12 +102,6 @@ def validate_config(config: ConfigType) -> ConfigType: validate_config, ) -# Configuring MQTT Number under the number platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(number.DOMAIN), -) - DISCOVERY_SCHEMA = vol.All( _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), validate_config, @@ -197,6 +186,9 @@ def message_received(msg: ReceiveMessage) -> None: """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/scene.py b/homeassistant/components/mqtt/scene.py index dd7f3347845f91..f716e4fe46f8dd 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -24,7 +24,6 @@ MQTT_AVAILABILITY_SCHEMA, MqttEntity, async_setup_entry_helper, - warn_for_legacy_schema, ) from .util import valid_publish_topic @@ -46,12 +45,6 @@ } ).extend(MQTT_AVAILABILITY_SCHEMA.schema) -# Configuring MQTT Scenes under the scene platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(scene.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index b783a001f15017..26e72af9192390 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -28,12 +28,7 @@ CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -65,11 +60,6 @@ }, ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Select under the select platform key was deprecated in HA Core 2022.6 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(select.DOMAIN), -) - DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 9de442926a0cae..e4b5f61bda0c23 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -45,7 +45,6 @@ MqttAvailability, MqttEntity, async_setup_entry_helper, - warn_for_legacy_schema, ) from .models import ( MqttValueTemplate, @@ -53,7 +52,7 @@ ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, valid_subscribe_topic +from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -73,36 +72,11 @@ DEFAULT_FORCE_UPDATE = False -def validate_options(conf: ConfigType) -> ConfigType: - """Validate options. - - If last reset topic is present it must be same as the state topic. - """ - if ( - CONF_LAST_RESET_TOPIC in conf - and CONF_STATE_TOPIC in conf - and conf[CONF_LAST_RESET_TOPIC] != conf[CONF_STATE_TOPIC] - ): - _LOGGER.warning( - "'%s' must be same as '%s'", CONF_LAST_RESET_TOPIC, CONF_STATE_TOPIC - ) - - if CONF_LAST_RESET_TOPIC in conf and CONF_LAST_RESET_VALUE_TEMPLATE not in conf: - _LOGGER.warning( - "'%s' must be set if '%s' is set", - CONF_LAST_RESET_VALUE_TEMPLATE, - CONF_LAST_RESET_TOPIC, - ) - - return conf - - _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_LAST_RESET_TOPIC): valid_subscribe_topic, vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_SUGGESTED_DISPLAY_PRECISION): cv.positive_int, @@ -112,20 +86,17 @@ def validate_options(conf: ConfigType) -> ConfigType: ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) PLATFORM_SCHEMA_MODERN = vol.All( + # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 + # Removed in HA Core 2023.6.0 + cv.removed(CONF_LAST_RESET_TOPIC), _PLATFORM_SCHEMA_BASE, - validate_options, -) - -# Configuring MQTT Sensors under the sensor platform key was deprecated in -# HA Core 2022.6 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(sensor.DOMAIN), ) DISCOVERY_SCHEMA = vol.All( - cv.deprecated(CONF_LAST_RESET_TOPIC), + # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 + # Removed in HA Core 2023.6.0 + cv.removed(CONF_LAST_RESET_TOPIC), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), - validate_options, ) @@ -319,10 +290,7 @@ def _update_last_reset(msg: ReceiveMessage) -> None: def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" _update_state(msg) - if CONF_LAST_RESET_VALUE_TEMPLATE in self._config and ( - CONF_LAST_RESET_TOPIC not in self._config - or self._config[CONF_LAST_RESET_TOPIC] == self._config[CONF_STATE_TOPIC] - ): + if CONF_LAST_RESET_VALUE_TEMPLATE in self._config: _update_last_reset(msg) get_mqtt_data(self.hass).state_write_requests.write_state_request(self) @@ -333,24 +301,6 @@ def message_received(msg: ReceiveMessage) -> None: "encoding": self._config[CONF_ENCODING] or None, } - @callback - @log_messages(self.hass, self.entity_id) - def last_reset_message_received(msg: ReceiveMessage) -> None: - """Handle new last_reset messages.""" - _update_last_reset(msg) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - - if ( - CONF_LAST_RESET_TOPIC in self._config - and self._config[CONF_LAST_RESET_TOPIC] != self._config[CONF_STATE_TOPIC] - ): - topics["last_reset_topic"] = { - "topic": self._config[CONF_LAST_RESET_TOPIC], - "msg_callback": last_reset_message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics ) diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index e279deb70b36fb..4134dd9714863b 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -50,12 +50,7 @@ PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -96,12 +91,6 @@ }, ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Sirens under the siren platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(siren.DOMAIN), -) - DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)) MQTT_SIREN_ATTRIBUTES_BLOCKED = frozenset( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index b55fa5779b8ed4..c1eff29e3bef09 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1,14 +1,4 @@ { - "issues": { - "deprecated_yaml": { - "title": "Your manually configured MQTT {platform}(s) needs attention", - "description": "Manually configured MQTT {platform}(s) found under platform key `{platform}`.\n\nPlease move the configuration to the `mqtt` integration key and restart Home Assistant to fix this issue. See the [documentation]({more_info_url}), for more information." - }, - "deprecated_yaml_broker_settings": { - "title": "Deprecated MQTT settings found in `configuration.yaml`", - "description": "The following settings found in `configuration.yaml` were migrated to MQTT config entry and will now override the settings in `configuration.yaml`:\n`{deprecated_settings}`\n\nPlease remove these settings from `configuration.yaml` and restart Home Assistant to fix this issue. See the [documentation]({more_info_url}), for more information." - } - }, "config": { "step": { "broker": { @@ -140,7 +130,7 @@ "selector": { "set_ca_cert": { "options": { - "off": "Off", + "off": "[%key:common::state::off%]", "auto": "Auto", "custom": "Custom" } diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 7ccc31bd3357d2..dda80bba84e20a 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -8,8 +8,8 @@ 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/switch.py b/homeassistant/components/mqtt/switch.py index 521b08d27489ab..7f4f609f265efe 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -37,12 +37,7 @@ PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data @@ -64,13 +59,6 @@ } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Switches under the switch platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(switch.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py index 86d94883c90628..068bc183ec411c 100644 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from ..mixins import async_setup_entry_helper, warn_for_legacy_schema +from ..mixins import async_setup_entry_helper from .schema import CONF_SCHEMA, LEGACY, MQTT_VACUUM_SCHEMA, STATE from .schema_legacy import ( DISCOVERY_SCHEMA_LEGACY, @@ -46,13 +46,6 @@ def validate_mqtt_vacuum_modern(config_value: ConfigType) -> ConfigType: MQTT_VACUUM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_mqtt_vacuum_discovery ) -# Configuring MQTT Vacuums under the vacuum platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(vacuum.DOMAIN), -) - PLATFORM_SCHEMA_MODERN = vol.All( MQTT_VACUUM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_mqtt_vacuum_modern ) diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index 8a2912f19108e4..6cab62cdb5d0bb 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -8,7 +8,6 @@ from homeassistant.components.vacuum import ( ATTR_STATUS, - DOMAIN as VACUUM_DOMAIN, ENTITY_ID_FORMAT, VacuumEntity, VacuumEntityFeature, @@ -26,7 +25,7 @@ from ..config import MQTT_BASE_SCHEMA from ..const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, warn_for_legacy_schema +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity from ..models import ( MqttValueTemplate, PayloadSentinel, @@ -160,13 +159,6 @@ .extend(MQTT_VACUUM_SCHEMA.schema) ) -# Configuring MQTT Vacuums under the vacuum platform key was deprecated in -# HA Core 2022.6; -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA_LEGACY = vol.All( - warn_for_legacy_schema(VACUUM_DOMAIN), -) - DISCOVERY_SCHEMA_LEGACY = PLATFORM_SCHEMA_LEGACY_MODERN.extend( {}, extra=vol.REMOVE_EXTRA ) diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 3f8c6953abee8c..fef185687db971 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -6,7 +6,6 @@ import voluptuous as vol from homeassistant.components.vacuum import ( - DOMAIN as VACUUM_DOMAIN, ENTITY_ID_FORMAT, STATE_CLEANING, STATE_DOCKED, @@ -39,7 +38,7 @@ CONF_STATE_TOPIC, ) from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, warn_for_legacy_schema +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity from ..models import ReceiveMessage from ..util import get_mqtt_data, valid_publish_topic from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED @@ -65,7 +64,6 @@ VacuumEntityFeature.START | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.STATUS | VacuumEntityFeature.BATTERY | VacuumEntityFeature.CLEAN_SPOT ) @@ -155,13 +153,6 @@ .extend(MQTT_VACUUM_SCHEMA.schema) ) -# Configuring MQTT Vacuums under the vacuum platform key was deprecated in -# HA Core 2022.6; -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA_STATE = vol.All( - warn_for_legacy_schema(VACUUM_DOMAIN), -) - DISCOVERY_SCHEMA_STATE = PLATFORM_SCHEMA_STATE_MODERN.extend({}, extra=vol.REMOVE_EXTRA) @@ -207,7 +198,7 @@ def config_schema() -> vol.Schema: def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" supported_feature_strings: list[str] = config[CONF_SUPPORTED_FEATURES] - self._attr_supported_features = strings_to_services( + self._attr_supported_features = VacuumEntityFeature.STATE | strings_to_services( supported_feature_strings, STRING_TO_SERVICE ) self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py new file mode 100644 index 00000000000000..0f622d55b84f7c --- /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/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 00441690b47f90..1b4cdb1c58317d 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -23,7 +23,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt, slugify +from homeassistant.util import dt as dt_util, slugify _LOGGER = logging.getLogger(__name__) @@ -122,7 +122,7 @@ def update_state(device_id, room, distance): """Update the sensor state.""" self._state = room self._distance = distance - self._updated = dt.utcnow() + self._updated = dt_util.utcnow() self.async_write_ha_state() @@ -144,7 +144,7 @@ def message_received(msg): # device is in the same room OR # device is closer to another room OR # last update from other room was too long ago - timediff = dt.utcnow() - self._updated + timediff = dt_util.utcnow() - self._updated if ( device.get(ATTR_ROOM) == self._state or device.get(ATTR_DISTANCE) < self._distance @@ -174,7 +174,7 @@ def update(self) -> None: if ( self._updated and self._consider_home - and dt.utcnow() - self._updated > self._consider_home + and dt_util.utcnow() - self._updated > self._consider_home ): self._state = STATE_NOT_HOME diff --git a/homeassistant/components/my/__init__.py b/homeassistant/components/my/__init__.py index b547662d1889db..d699e42e105016 100644 --- a/homeassistant/components/my/__init__.py +++ b/homeassistant/components/my/__init__.py @@ -1,11 +1,14 @@ """Support for my.home-assistant.io redirect service.""" from homeassistant.components import frontend from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DOMAIN = "my" URL_PATH = "_my_redirect" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register hidden _my_redirect panel.""" diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index 05f698f2170e2f..5e03f962d15270 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 d7405dba187e97..42c5a40636e5f7 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -227,7 +227,6 @@ def extra_state_attributes(self) -> dict[str, Any]: """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 dc5dc76c7ae35e..7e0ff2c99d6e36 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 54a24b9b4af4d8..64f7dafc1b7a49 100644 --- a/homeassistant/components/mystrom/__init__.py +++ b/homeassistant/components/mystrom/__init__.py @@ -1 +1,94 @@ -"""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_get_device_state( + device: MyStromSwitch | MyStromBulb, ip_address: str +) -> None: + try: + await device.get_state() + except MyStromConnectionError as err: + _LOGGER.error("No route to myStrom plug: %s", ip_address) + raise ConfigEntryNotReady() from err + + +def _get_mystrom_bulb(host: str, mac: str) -> MyStromBulb: + return MyStromBulb(host, mac) + + +def _get_mystrom_switch(host: str) -> MyStromSwitch: + return MyStromSwitch(host) + + +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 = _get_mystrom_switch(host) + platforms = PLATFORMS_SWITCH + await _async_get_device_state(device, info["ip"]) + elif device_type in [102, 105]: + mac = info["mac"] + device = _get_mystrom_bulb(host, mac) + platforms = PLATFORMS_BULB + await _async_get_device_state(device, info["ip"]) + 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 + + 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"] + platforms = [] + if device_type in [101, 106, 107]: + platforms.extend(PLATFORMS_SWITCH) + elif device_type in [102, 105]: + platforms.extend(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 00000000000000..3dc334d825261e --- /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 87697acbe967ae..5641463abf1c7b 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 26ce5b115671f3..14badde17d246c 100644 --- a/homeassistant/components/mystrom/light.py +++ b/homeassistant/components/mystrom/light.py @@ -4,7 +4,6 @@ import logging from typing import Any -from pymystrom.bulb import MyStromBulb from pymystrom.exceptions import MyStromConnectionError import voluptuous as vol @@ -17,13 +16,17 @@ 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" @@ -31,8 +34,6 @@ EFFECT_RAINBOW = "rainbow" EFFECT_SUNRISE = "sunrise" -MYSTROM_EFFECT_LIST = [EFFECT_RAINBOW, EFFECT_SUNRISE] - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -42,6 +43,15 @@ ) +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, @@ -49,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): @@ -74,52 +81,21 @@ class MyStromLight(LightEntity): _attr_color_mode = ColorMode.HS _attr_supported_color_modes = {ColorMode.HS} _attr_supported_features = LightEntityFeature.EFFECT | LightEntityFeature.FLASH + _attr_effect_list = [EFFECT_RAINBOW, EFFECT_SUNRISE] def __init__(self, bulb, name, mac): """Initialize the light.""" self._bulb = bulb - self._name = name - self._state = None - self._available = False - self._brightness = 0 - self._color_h = 0 - self._color_s = 0 - self._mac = mac - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def unique_id(self): - """Return a unique ID.""" - return self._mac - - @property - def brightness(self): - """Return the brightness of the light.""" - return self._brightness - - @property - def hs_color(self): - """Return the color of the light.""" - return self._color_h, self._color_s - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def effect_list(self): - """Return the list of supported effects.""" - return MYSTROM_EFFECT_LIST - - @property - def is_on(self): - """Return true if light is on.""" - return self._state + self._attr_name = name + self._attr_available = False + self._attr_unique_id = mac + self._attr_hs_color = 0, 0 + 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.""" @@ -130,7 +106,10 @@ async def async_turn_on(self, **kwargs: Any) -> None: color_h, color_s = kwargs[ATTR_HS_COLOR] elif ATTR_BRIGHTNESS in kwargs: # Brightness update, keep color - color_h, color_s = self._color_h, self._color_s + if self.hs_color is not None: + color_h, color_s = self.hs_color + else: + color_h, color_s = 0, 0 # Back to white else: color_h, color_s = 0, 0 # Back to white @@ -159,7 +138,7 @@ async def async_update(self) -> None: """Fetch new state data for this light.""" try: await self._bulb.get_state() - self._state = self._bulb.state + self._attr_is_on = self._bulb.state colors = self._bulb.color try: @@ -168,11 +147,10 @@ async def async_update(self) -> None: color_s, color_v = colors.split(";") color_h = 0 - self._color_h = int(color_h) - self._color_s = int(color_s) - self._brightness = int(color_v) * 255 / 100 + self._attr_hs_color = int(color_h), int(color_s) + self._attr_brightness = int(int(color_v) * 255 / 100) - self._available = True + self._attr_available = True except MyStromConnectionError: _LOGGER.warning("No route to myStrom bulb") - self._available = False + self._attr_available = False diff --git a/homeassistant/components/mystrom/manifest.json b/homeassistant/components/mystrom/manifest.json index 7659b1d8025e55..eaf9eb6acdca62 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 00000000000000..96cc40996ef508 --- /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 00000000000000..259501e14864e5 --- /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 7bce3000424c6c..8e89bb5f1518b7 100644 --- a/homeassistant/components/mystrom/switch.py +++ b/homeassistant/components/mystrom/switch.py @@ -5,17 +5,20 @@ 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 @@ ) +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): @@ -53,30 +67,15 @@ class MyStromSwitch(SwitchEntity): def __init__(self, plug, name): """Initialize the myStrom switch/plug.""" - self._name = name self.plug = plug - self._available = True - self.relay = None - - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def is_on(self): - """Return true if switch is on.""" - return bool(self.relay) - - @property - def unique_id(self): - """Return a unique ID.""" - return self.plug._mac # pylint: disable=protected-access - - @property - def available(self): - """Could the device be accessed during the last update call.""" - return self._available + self._attr_name = name + self._attr_unique_id = self.plug.mac + 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.""" @@ -96,9 +95,9 @@ async def async_update(self) -> None: """Get the latest data from the device and update the data.""" try: await self.plug.get_state() - self.relay = self.plug.relay - self._available = True + self._attr_is_on = self.plug.relay + self._attr_available = True except MyStromConnectionError: - if self._available: - self._available = False + if self.available: + self._attr_available = False _LOGGER.error("No route to myStrom plug") diff --git a/homeassistant/components/nad/manifest.json b/homeassistant/components/nad/manifest.json index dd354086a1a449..2e2d44341afe7e 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/nam/button.py b/homeassistant/components/nam/button.py index a55215962086e5..a280369e7c8582 100644 --- a/homeassistant/components/nam/button.py +++ b/homeassistant/components/nam/button.py @@ -23,7 +23,6 @@ RESTART_BUTTON: ButtonEntityDescription = ButtonEntityDescription( key="restart", - translation_key="restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, ) diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 3f9821a1e34f00..3c0b8bc9ba4784 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -338,7 +338,6 @@ class NAMSensorEntityDescription(SensorEntityDescription, NAMSensorRequiredKeysM ), NAMSensorEntityDescription( key=ATTR_SIGNAL_STRENGTH, - translation_key="signal_strength", suggested_display_precision=0, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index e60855b882c1a3..e443a398984493 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -39,11 +39,6 @@ } }, "entity": { - "button": { - "restart": { - "name": "[%key:component::button::entity_component::restart::name%]" - } - }, "sensor": { "bme280_humidity": { "name": "BME280 humidity" @@ -153,9 +148,6 @@ "dht22_temperature": { "name": "DHT22 temperature" }, - "signal_strength": { - "name": "[%key:component::sensor::entity_component::signal_strength::name%]" - }, "last_restart": { "name": "Last restart" } diff --git a/homeassistant/components/ness_alarm/__init__.py b/homeassistant/components/ness_alarm/__init__.py index c81c3e0c7f7c71..c1d97f781afa89 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.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 _close(event): 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 @@ def on_state_change(arming_state: ArmingState): 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 834202ba58d064..3eceb448fa4e89 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 ab0ce20a9a1a8d..ca975ed055d5f4 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 6af293aba9733e..dbb30ceb52adea 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/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py index 8eb607b2056650..a74d0f3a54b9af 100644 --- a/homeassistant/components/nest/sensor_sdm.py +++ b/homeassistant/components/nest/sensor_sdm.py @@ -79,7 +79,6 @@ class TemperatureSensor(SensorBase): _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - _attr_translation_key = "temperature" @property def native_value(self) -> float: @@ -96,7 +95,6 @@ class HumiditySensor(SensorBase): _attr_device_class = SensorDeviceClass.HUMIDITY _attr_native_unit_of_measurement = PERCENTAGE - _attr_translation_key = "humidity" @property def native_value(self) -> int: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 2578437acf4792..a452d015a2b946 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -86,15 +86,5 @@ "title": "Legacy Works With Nest is being removed", "description": "Legacy Works With Nest is being removed from Home Assistant.\n\nYou must take action to use the SDM API. Remove all `nest` configuration from `configuration.yaml` and restart Home Assistant, then see the Nest [integration instructions]({documentation_url}) for set up instructions and supported devices." } - }, - "entity": { - "sensor": { - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - } - } } } diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index aa8728d548d05e..e26a32965a3c0e 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -234,11 +234,10 @@ async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: 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 f3f45458d78b6c..f4719badcfa0a6 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -56,7 +56,7 @@ 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 a500689a937fd9..5fdf580c6aa647 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/netdata/manifest.json b/homeassistant/components/netdata/manifest.json index 2d7604765c437a..99410ce033d37d 100644 --- a/homeassistant/components/netdata/manifest.json +++ b/homeassistant/components/netdata/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/netdata", "iot_class": "local_polling", "loggers": ["netdata"], - "requirements": ["netdata==1.0.1"] + "requirements": ["netdata==1.1.0"] } diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index 6606604ac90c9b..1ab7a48e1b3273 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -66,7 +66,7 @@ async def async_setup_platform( port = config[CONF_PORT] resources = config[CONF_RESOURCES] - netdata = NetdataData(Netdata(host, port=port)) + netdata = NetdataData(Netdata(host, port=port, timeout=20.0)) await netdata.async_update() if netdata.api.metrics is None: diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 8f81de43ebbe76..ef31a887691288 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 a57334d2531f03..32bb9a574cd025 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -6,11 +6,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from homeassistant.loader import bind_hass from . import util from .const import ( + DOMAIN, IPV4_BROADCAST_ADDR, LOOPBACK_TARGET_IP, MDNS_TARGET_IP, @@ -21,6 +23,8 @@ _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @bind_hass async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: @@ -115,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/nexia/__init__.py b/homeassistant/components/nexia/__init__.py index b221f440ff8462..0644de58ee7b15 100644 --- a/homeassistant/components/nexia/__init__.py +++ b/homeassistant/components/nexia/__init__.py @@ -56,6 +56,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False raise ConfigEntryNotReady(f"Error from Nexia service: {http_ex}") from http_ex + except aiohttp.ClientOSError as os_error: + raise ConfigEntryNotReady( + f"Error connecting to Nexia service: {os_error}" + ) from os_error coordinator = NexiaDataUpdateCoordinator(hass, nexia_home) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index b77ffa86f03603..4b8bd1a929420f 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 02f5d8695ca136..b8f36e10fa1070 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -108,41 +108,19 @@ def __init__(self, client, agency, route, stop, name=None): self.agency = agency self.route = route self.stop = stop - self._custom_name = name + self._attr_extra_state_attributes = {} + # Maybe pull a more user friendly name from the API here - self._name = f"{agency} {route}" - self._client = client + self._attr_name = f"{agency} {route}" + if name: + self._attr_name = name - # set up default state attributes - self._state = None - self._attributes = {} + 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 @@ def update(self) -> None: ) 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 @@ def update(self) -> None: # 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 @@ def update(self) -> None: # 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/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 65829f713ef52f..8e2f39cf9b5fcb 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -1,5 +1,4 @@ """The Nextcloud integration.""" -import logging from nextcloudmonitor import ( NextcloudMonitor, @@ -7,12 +6,10 @@ NextcloudMonitorConnectionError, NextcloudMonitorRequestError, ) -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, - CONF_SCAN_INTERVAL, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, @@ -21,58 +18,13 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator -_LOGGER = logging.getLogger(__name__) PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR) -# Validate user configuration -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_URL): cv.url, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - }, - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Nextcloud integration.""" - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - - return True +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py index c5019603c09c10..ec56307aad77aa 100644 --- a/homeassistant/components/nextcloud/config_flow.py +++ b/homeassistant/components/nextcloud/config_flow.py @@ -2,14 +2,12 @@ from __future__ import annotations from collections.abc import Mapping -import logging from typing import Any from nextcloudmonitor import ( NextcloudMonitor, NextcloudMonitorAuthorizationError, NextcloudMonitorConnectionError, - NextcloudMonitorError, NextcloudMonitorRequestError, ) import voluptuous as vol @@ -35,8 +33,6 @@ } ) -_LOGGER = logging.getLogger(__name__) - class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a Nextcloud config flow.""" @@ -54,25 +50,6 @@ def _try_connect_nc(self, user_input: dict) -> NextcloudMonitor: user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), ) - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle a flow initiated by configuration file.""" - self._async_abort_entries_match({CONF_URL: user_input.get(CONF_URL)}) - try: - await self.hass.async_add_executor_job(self._try_connect_nc, user_input) - except NextcloudMonitorError: - _LOGGER.error( - "Connection error during import of yaml configuration, import aborted" - ) - return self.async_abort(reason="connection_error_during_import") - return await self.async_step_user( - { - CONF_URL: user_input[CONF_URL], - CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, - } - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index e068ae4041e54f..bcb530ffd734b3 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -28,11 +28,5 @@ "connection_error": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The Nextcloud YAML configuration has been deprecated", - "description": "Configuring Nextcloud using YAML has been deprecated.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `nextcloud` YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index b46102879c4acb..a38e2182ad7562 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -5,7 +5,6 @@ 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.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 88d61a427b5e38..fbb8e32bebe89e 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.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 @@ def _parse_data(self) -> dict[str, list[NinaWarningData]]: 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 f1579bc05eca8a..d41fa6dee3eb95 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -18,12 +18,13 @@ 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 @@ async def async_step_user( 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 @@ async def async_step_user( 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 @@ async def async_step_init(self, user_input=None): 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 8ba7c5ffaa6028..36096d97dc1fa1 100644 --- a/homeassistant/components/nina/const.py +++ b/homeassistant/components/nina/const.py @@ -11,9 +11,12 @@ 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 6386a70d08b8bc..98a088620eac2b 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 b22c2640084fc5..23a1fb8dfa6b22 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/noaa_tides/manifest.json b/homeassistant/components/noaa_tides/manifest.json index 7b954153cf1746..85c6fbcb78844f 100644 --- a/homeassistant/components/noaa_tides/manifest.json +++ b/homeassistant/components/noaa_tides/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/noaa_tides", "iot_class": "cloud_polling", "loggers": ["noaa_coops"], - "requirements": ["noaa-coops==0.1.8"] + "requirements": ["noaa-coops==0.1.9"] } diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 7cb69dfb79a736..f55dc9344ab65d 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -18,17 +18,14 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_IDENTIFIERS, ATTR_NAME, - ATTR_SUGGESTED_AREA, - ATTR_VIA_DEVICE, PRECISION_TENTHS, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( ATTR_SERIAL, @@ -95,12 +92,12 @@ def __init__(self, zone_id, hub: nobo, override_type) -> None: self._attr_hvac_mode = HVACMode.AUTO self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.AUTO] self._override_type = override_type - self._attr_device_info: DeviceInfo = { - ATTR_IDENTIFIERS: {(DOMAIN, f"{hub.hub_serial}:{zone_id}")}, - ATTR_NAME: hub.zones[zone_id][ATTR_NAME], - ATTR_VIA_DEVICE: (DOMAIN, hub.hub_info[ATTR_SERIAL]), - ATTR_SUGGESTED_AREA: hub.zones[zone_id][ATTR_NAME], - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{hub.hub_serial}:{zone_id}")}, + name=hub.zones[zone_id][ATTR_NAME], + via_device=(DOMAIN, hub.hub_info[ATTR_SERIAL]), + suggested_area=hub.zones[zone_id][ATTR_NAME], + ) self._read_state() async def async_added_to_hass(self) -> None: @@ -158,7 +155,7 @@ async def async_update(self) -> None: @callback def _read_state(self) -> None: """Read the current state from the hub. These are only local calls.""" - state = self._nobo.get_current_zone_mode(self._id, dt.now()) + state = self._nobo.get_current_zone_mode(self._id, dt_util.now()) self._attr_hvac_mode = HVACMode.AUTO self._attr_preset_mode = PRESET_NONE diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 3bb1fa373a5675..c5536bad6ea2e7 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -10,12 +10,8 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, - ATTR_SUGGESTED_AREA, - ATTR_VIA_DEVICE, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback @@ -60,16 +56,18 @@ def __init__(self, serial: str, hub: nobo) -> None: self._attr_unique_id = component[ATTR_SERIAL] self._attr_name = "Temperature" self._attr_has_entity_name = True - self._attr_device_info: DeviceInfo = { - ATTR_IDENTIFIERS: {(DOMAIN, component[ATTR_SERIAL])}, - ATTR_NAME: component[ATTR_NAME], - ATTR_MANUFACTURER: NOBO_MANUFACTURER, - ATTR_MODEL: component[ATTR_MODEL].name, - ATTR_VIA_DEVICE: (DOMAIN, hub.hub_info[ATTR_SERIAL]), - } zone_id = component[ATTR_ZONE_ID] + suggested_area = None if zone_id != "-1": - self._attr_device_info[ATTR_SUGGESTED_AREA] = hub.zones[zone_id][ATTR_NAME] + suggested_area = hub.zones[zone_id][ATTR_NAME] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, component[ATTR_SERIAL])}, + name=component[ATTR_NAME], + manufacturer=NOBO_MANUFACTURER, + model=component[ATTR_MODEL].name, + via_device=(DOMAIN, hub.hub_info[ATTR_SERIAL]), + suggested_area=suggested_area, + ) self._read_state() async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json index 12cd22149d327d..4a3fc7cee96e12 100644 --- a/homeassistant/components/norway_air/manifest.json +++ b/homeassistant/components/norway_air/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/norway_air", "iot_class": "cloud_polling", "loggers": ["metno"], - "requirements": ["pyMetno==0.9.0"] + "requirements": ["PyMetno==0.10.0"] } diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index eae47b55179875..e9e61527884ac8 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -19,7 +19,6 @@ ATTR_TITLE, DOMAIN, NOTIFY_SERVICE_SCHEMA, - PERSISTENT_NOTIFICATION_SERVICE_SCHEMA, SERVICE_NOTIFY, SERVICE_PERSISTENT_NOTIFICATION, ) @@ -70,13 +69,19 @@ async def persistent_notification(service: ServiceCall) -> None: 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 d30702915d9dd4..38dba680635bad 100644 --- a/homeassistant/components/notify/const.py +++ b/homeassistant/components/notify/const.py @@ -31,10 +31,3 @@ 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 1a0de7344a3372..9311acf2ba9536 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/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index f70af18c3e1518..ff58d566a34776 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -52,7 +52,6 @@ class NotionBinarySensorDescription( BINARY_SENSOR_DESCRIPTIONS = ( NotionBinarySensorDescription( key=SENSOR_BATTERY, - name="Low battery", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, listener_kind=ListenerKind.BATTERY, @@ -60,28 +59,24 @@ class NotionBinarySensorDescription( ), NotionBinarySensorDescription( key=SENSOR_DOOR, - name="Door", device_class=BinarySensorDeviceClass.DOOR, listener_kind=ListenerKind.DOOR, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_GARAGE_DOOR, - name="Garage door", device_class=BinarySensorDeviceClass.GARAGE_DOOR, listener_kind=ListenerKind.GARAGE_DOOR, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_LEAK, - name="Leak detector", device_class=BinarySensorDeviceClass.MOISTURE, listener_kind=ListenerKind.LEAK_STATUS, on_state="leak", ), NotionBinarySensorDescription( key=SENSOR_MISSING, - name="Missing", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, listener_kind=ListenerKind.CONNECTED, @@ -89,28 +84,28 @@ class NotionBinarySensorDescription( ), NotionBinarySensorDescription( key=SENSOR_SAFE, - name="Safe", + translation_key="safe", device_class=BinarySensorDeviceClass.DOOR, listener_kind=ListenerKind.SAFE, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_SLIDING, - name="Sliding door/window", + translation_key="sliding_door_window", device_class=BinarySensorDeviceClass.DOOR, listener_kind=ListenerKind.SLIDING_DOOR_OR_WINDOW, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_SMOKE_CO, - name="Smoke/Carbon monoxide detector", + translation_key="smoke_carbon_monoxide_detector", device_class=BinarySensorDeviceClass.SMOKE, listener_kind=ListenerKind.SMOKE, on_state="alarm", ), NotionBinarySensorDescription( key=SENSOR_WINDOW_HINGED, - name="Hinged window", + translation_key="hinged_window", listener_kind=ListenerKind.HINGED_WINDOW, on_state="open", ), diff --git a/homeassistant/components/notion/const.py b/homeassistant/components/notion/const.py index 5e89767d0e0498..0961b7c10c5d4f 100644 --- a/homeassistant/components/notion/const.py +++ b/homeassistant/components/notion/const.py @@ -9,6 +9,7 @@ SENSOR_GARAGE_DOOR = "garage_door" SENSOR_LEAK = "leak" SENSOR_MISSING = "missing" +SENSOR_MOLD = "mold" SENSOR_SAFE = "safe" SENSOR_SLIDING = "sliding" SENSOR_SMOKE_CO = "alarm" diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index e6ff3eaab6941c..4777cc94fbfd24 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import NotionEntity -from .const import DOMAIN, SENSOR_TEMPERATURE +from .const import DOMAIN, SENSOR_MOLD, SENSOR_TEMPERATURE from .model import NotionEntityDescriptionMixin @@ -25,9 +25,14 @@ class NotionSensorDescription(SensorEntityDescription, NotionEntityDescriptionMi SENSOR_DESCRIPTIONS = ( + NotionSensorDescription( + key=SENSOR_MOLD, + translation_key="mold_risk", + icon="mdi:liquid-spot", + listener_kind=ListenerKind.MOLD, + ), NotionSensorDescription( key=SENSOR_TEMPERATURE, - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -76,11 +81,11 @@ def native_unit_of_measurement(self) -> str | None: @property def native_value(self) -> str | None: - """Return the value reported by the sensor. - - The Notion API only returns a localized string for temperature (e.g. "70°"); we - simply remove the degree symbol: - """ + """Return the value reported by the sensor.""" if not self.listener.status_localized: return None - return self.listener.status_localized.state[:-1] + if self.listener.listener_kind == ListenerKind.TEMPERATURE: + # The Notion API only returns a localized string for temperature (e.g. + # "70°"); we simply remove the degree symbol: + return self.listener.status_localized.state[:-1] + return self.listener.status_localized.state diff --git a/homeassistant/components/notion/strings.json b/homeassistant/components/notion/strings.json index 49721568ff23f5..24a06d7ee714e5 100644 --- a/homeassistant/components/notion/strings.json +++ b/homeassistant/components/notion/strings.json @@ -24,5 +24,26 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "safe": { + "name": "Safe" + }, + "sliding_door_window": { + "name": "Sliding door/window" + }, + "smoke_carbon_monoxide_detector": { + "name": "Smoke/Carbon monoxide detector" + }, + "hinged_window": { + "name": "Hinged window" + } + }, + "sensor": { + "mold_risk": { + "name": "Mold risk" + } + } } } diff --git a/homeassistant/components/nsw_fuel_station/__init__.py b/homeassistant/components/nsw_fuel_station/__init__.py index 6d45104e5d3113..818656779a3fae 100644 --- a/homeassistant/components/nsw_fuel_station/__init__.py +++ b/homeassistant/components/nsw_fuel_station/__init__.py @@ -8,6 +8,7 @@ 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 @@ 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 02f7b985b3b2f2..cea62996e6d10a 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/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index b0bfe18614e670..d237303e7c98a7 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -30,6 +30,7 @@ entity_registry as er, issue_registry as ir, ) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -368,13 +369,13 @@ def __init__(self, coordinator: NukiCoordinator, nuki_device: _NukiDeviceT) -> N self._nuki_device = nuki_device @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info for Nuki entities.""" - return { - "identifiers": {(DOMAIN, parse_id(self._nuki_device.nuki_id))}, - "name": self._nuki_device.name, - "manufacturer": "Nuki Home Solutions GmbH", - "model": self._nuki_device.device_model_str.capitalize(), - "sw_version": self._nuki_device.firmware_version, - "via_device": (DOMAIN, self.coordinator.bridge_id), - } + return DeviceInfo( + identifiers={(DOMAIN, parse_id(self._nuki_device.nuki_id))}, + name=self._nuki_device.name, + manufacturer="Nuki Home Solutions GmbH", + model=self._nuki_device.device_model_str.capitalize(), + sw_version=self._nuki_device.firmware_version, + via_device=(DOMAIN, self.coordinator.bridge_id), + ) diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 55560d3bf8c213..a1a75ef8260a82 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -72,6 +72,7 @@ class NukiDeviceEntity(NukiEntity[_NukiDeviceT], LockEntity): _attr_has_entity_name = True _attr_supported_features = LockEntityFeature.OPEN _attr_translation_key = "nuki_lock" + _attr_name = None @property def unique_id(self) -> str | None: diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index 8b87816fb7d099..b84bee660c1b10 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nuki", "iot_class": "local_polling", "loggers": ["pynuki"], - "requirements": ["pynuki==1.6.1"] + "requirements": ["pynuki==1.6.2"] } diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index c4578c7d14d1df..06cfa065c54bb8 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -29,7 +29,6 @@ class NukiBatterySensor(NukiEntity[NukiDevice], SensorEntity): """Representation of a Nuki Lock Battery sensor.""" _attr_has_entity_name = True - _attr_translation_key = "battery" _attr_native_unit_of_measurement = PERCENTAGE _attr_device_class = SensorDeviceClass.BATTERY _attr_entity_category = EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index f139124e961420..4629f6a2a3b534 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -44,11 +44,6 @@ } } } - }, - "sensor": { - "battery": { - "name": "[%key:component::sensor::entity_component::battery::name%]" - } } } } diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 2ad63c75e04072..24c44b901a1304 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 @@ def capability_attributes(self) -> dict[str, Any]: 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 @@ def native_min_value(self) -> float: @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 @@ def native_max_value(self) -> float: @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 @@ def native_step(self) -> float | None: @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 @@ def unit_of_measurement(self) -> str | None: 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 @@ def native_value(self) -> float | None: @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 @@ def _convert_to_state_value( 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 @@ def _convert_to_state_value( 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 @@ def convert_to_native_value(self, value: float) -> float: 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 971f8d5a514d9b..22a51d85ad997f 100644 --- a/homeassistant/components/number/device_action.py +++ b/homeassistant/components/number/device_action.py @@ -3,6 +3,7 @@ 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 @@ 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/nws/manifest.json b/homeassistant/components/nws/manifest.json index ed7d825afff930..7f5d01f9897726 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["metar", "pynws"], "quality_scale": "platinum", - "requirements": ["pynws==1.4.1"] + "requirements": ["pynws==1.5.0"] } diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 9edf6e61751b3a..e8a35ba66f11aa 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -8,6 +8,8 @@ ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_NATIVE_DEW_POINT, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, @@ -52,16 +54,13 @@ PARALLEL_UPDATES = 0 -def convert_condition( - time: str, weather: tuple[tuple[str, int | None], ...] -) -> tuple[str, int | None]: +def convert_condition(time: str, weather: tuple[tuple[str, int | None], ...]) -> str: """Convert NWS codes to HA condition. Choose first condition in CONDITION_CLASSES that exists in weather code. If no match is found, return first condition from NWS """ conditions: list[str] = [w[0] for w in weather] - prec_probs = [w[1] or 0 for w in weather] # Choose condition with highest priority. cond = next( @@ -75,10 +74,10 @@ def convert_condition( if cond == "clear": if time == "day": - return ATTR_CONDITION_SUNNY, max(prec_probs) + return ATTR_CONDITION_SUNNY if time == "night": - return ATTR_CONDITION_CLEAR_NIGHT, max(prec_probs) - return cond, max(prec_probs) + return ATTR_CONDITION_CLEAR_NIGHT + return cond async def async_setup_entry( @@ -219,8 +218,7 @@ def condition(self) -> str | None: time = self.observation.get("iconTime") if weather: - cond, _ = convert_condition(time, weather) - return cond + return convert_condition(time, weather) return None @property @@ -256,16 +254,27 @@ def forecast(self) -> list[Forecast] | None: else: data[ATTR_FORECAST_NATIVE_TEMP] = None + data[ATTR_FORECAST_PRECIPITATION_PROBABILITY] = forecast_entry.get( + "probabilityOfPrecipitation" + ) + + if (dewp := forecast_entry.get("dewpoint")) is not None: + data[ATTR_FORECAST_NATIVE_DEW_POINT] = TemperatureConverter.convert( + dewp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS + ) + else: + data[ATTR_FORECAST_NATIVE_DEW_POINT] = None + + data[ATTR_FORECAST_HUMIDITY] = forecast_entry.get("relativeHumidity") + if self.mode == DAYNIGHT: data[ATTR_FORECAST_DAYTIME] = forecast_entry.get("isDaytime") + time = forecast_entry.get("iconTime") weather = forecast_entry.get("iconWeather") - if time and weather: - cond, precip = convert_condition(time, weather) - else: - cond, precip = None, None - data[ATTR_FORECAST_CONDITION] = cond - data[ATTR_FORECAST_PRECIPITATION_PROBABILITY] = precip + data[ATTR_FORECAST_CONDITION] = ( + convert_condition(time, weather) if time and weather else None + ) data[ATTR_FORECAST_WIND_BEARING] = forecast_entry.get("windBearing") wind_speed = forecast_entry.get("windSpeedAvg") diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index 12cb9e25f849d0..a3e16fbad76454 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -3,13 +3,22 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac from .connectivity import ObihaiConnection -from .const import LOGGER, PLATFORMS +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 @@ -22,17 +31,12 @@ 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] - new_unique_id = await hass.async_add_executor_job( + device_mac = await hass.async_add_executor_job( requester.pyobihai.get_device_mac ) - hass.config_entries.async_update_entry(entry, unique_id=new_unique_id) + hass.config_entries.async_update_entry(entry, unique_id=format_mac(device_mac)) entry.version = 2 diff --git a/homeassistant/components/obihai/button.py b/homeassistant/components/obihai/button.py index 0b84d40f4d2d06..d1b924b46934eb 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/config_flow.py b/homeassistant/components/obihai/config_flow.py index 6216fe0b973036..1790add84f07ec 100644 --- a/homeassistant/components/obihai/config_flow.py +++ b/homeassistant/components/obihai/config_flow.py @@ -10,9 +10,10 @@ from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac from .connectivity import validate_auth from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN @@ -77,7 +78,7 @@ async def async_step_user( device_mac = await self.hass.async_add_executor_job( pyobihai.get_device_mac ) - await self.async_set_unique_id(device_mac) + await self.async_set_unique_id(format_mac(device_mac)) self._abort_if_unique_id_configured() return self.async_create_entry( @@ -104,7 +105,7 @@ async def async_step_dhcp_confirm( ) -> FlowResult: """Attempt to confirm.""" assert self._dhcp_discovery_info - await self.async_set_unique_id(self._dhcp_discovery_info.macaddress) + await self.async_set_unique_id(format_mac(self._dhcp_discovery_info.macaddress)) self._abort_if_unique_id_configured() if user_input is None: @@ -135,28 +136,3 @@ async def async_step_dhcp_confirm( ) return await self.async_step_user(user_input=user_input) - - # DEPRECATED - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Handle a flow initialized by importing a config.""" - - try: - _ = await self.hass.async_add_executor_job(gethostbyname, config[CONF_HOST]) - except gaierror: - return self.async_abort(reason="cannot_connect") - - if pyobihai := await async_validate_creds(self.hass, config): - device_mac = await self.hass.async_add_executor_job(pyobihai.get_device_mac) - await self.async_set_unique_id(device_mac) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=config.get(CONF_NAME, config[CONF_HOST]), - data={ - CONF_HOST: config[CONF_HOST], - CONF_PASSWORD: config[CONF_PASSWORD], - CONF_USERNAME: config[CONF_USERNAME], - }, - ) - - return self.async_abort(reason="invalid_auth") diff --git a/homeassistant/components/obihai/connectivity.py b/homeassistant/components/obihai/connectivity.py index 071390f1ad9a97..1ab3095a5a8b00 100644 --- a/homeassistant/components/obihai/connectivity.py +++ b/homeassistant/components/obihai/connectivity.py @@ -53,14 +53,15 @@ def __init__( 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 2907f3f179db46..0f13bde5d6ba46 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 61411b0ce27155..53208a1e6a1fe6 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -1,63 +1,19 @@ """Support for Obihai Sensors.""" from __future__ import annotations -from datetime import timedelta - -from pyobihai import PyObihai -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +import datetime + +from requests.exceptions import RequestException + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .connectivity import ObihaiConnection -from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN, OBIHAI - -SCAN_INTERVAL = timedelta(seconds=5) +from .const import DOMAIN, LOGGER, OBIHAI -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, - } -) - - -# DEPRECATED -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Obihai sensor platform.""" - ir.async_create_issue( - hass, - DOMAIN, - "manual_migration", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="manual_migration", - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) +SCAN_INTERVAL = datetime.timedelta(seconds=5) async def async_setup_entry( @@ -65,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) @@ -90,89 +40,83 @@ 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._service_name = service_name - self._state = None - self._name = f"{OBIHAI} {self._service_name}" - self._pyobihai = pyobihai - self._unique_id = f"{serial}-{self._service_name}" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - @property - def available(self): - """Return if sensor is available.""" - if self._state is not None: - return True - return False - - @property - def unique_id(self): - """Return the unique ID.""" - return self._unique_id - - @property - def device_class(self): - """Return the device class for uptime sensor.""" + self.requester = requester + self._service_name = service_name + self._attr_name = f"{OBIHAI} {self._service_name}" + self._pyobihai = requester.pyobihai + self._attr_unique_id = f"{requester.serial}-{self._service_name}" if self._service_name == "Last Reboot": - return SensorDeviceClass.TIMESTAMP - return None + self._attr_device_class = SensorDeviceClass.TIMESTAMP @property - def icon(self): + def icon(self) -> str: """Return an icon.""" + if self._service_name == "Call Direction": - if self._state == "No Active Calls": + if self._attr_native_value == "No Active Calls": return "mdi:phone-off" - if self._state == "Inbound Call": + if self._attr_native_value == "Inbound Call": return "mdi:phone-incoming" return "mdi:phone-outgoing" if "Caller Info" in self._service_name: return "mdi:phone-log" if "Port" in self._service_name: - if self._state == "Ringing": + if self._attr_native_value == "Ringing": return "mdi:phone-ring" - if self._state == "Off Hook": + if self._attr_native_value == "Off Hook": return "mdi:phone-in-talk" return "mdi:phone-hangup" if "Service Status" in self._service_name: if "OBiTALK Service Status" in self._service_name: return "mdi:phone-check" - if self._state == "0": + if self._attr_native_value == "0": return "mdi:phone-hangup" return "mdi:phone-in-talk" if "Reboot Required" in self._service_name: - if self._state == "false": + if self._attr_native_value == "false": return "mdi:restart-off" return "mdi:restart-alert" return "mdi:phone" def update(self) -> None: """Update the sensor.""" - if not self._pyobihai.check_account(): - self._state = None - return - services = self._pyobihai.get_state() + 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 services: - self._state = services.get(self._service_name) + 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() - services = self._pyobihai.get_line_state() + if self._service_name in services: + self._attr_native_value = services.get(self._service_name) - if services is not None and self._service_name in services: - self._state = services.get(self._service_name) + if not self.requester.available: + self.requester.available = True + LOGGER.info("Connection restored") + self._attr_available = True + + return - call_direction = self._pyobihai.get_call_direction() + 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 call_direction: - self._state = call_direction.get(self._service_name) + self._attr_native_value = None + self._attr_available = False + self.requester.available = False diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json index 1b91cd606543f2..823bc2e1b8de11 100644 --- a/homeassistant/components/obihai/strings.json +++ b/homeassistant/components/obihai/strings.json @@ -24,11 +24,5 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } - }, - "issues": { - "manual_migration": { - "title": "Obihai YAML configuration is being removed", - "description": "Configuration of the Obihai platform in YAML is deprecated and will be removed in Home Assistant 2023.6; Your existing configuration has been imported into the UI automatically and can be safely removed from your configuration.yaml file." - } } } diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index c36f19fd28d921..d334a0051c3db7 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -19,6 +20,8 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 4 +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + class OnboadingStorage(Store): """Store onboarding data.""" diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 51817be35b8b5c..5b47394e0e4b37 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -193,7 +193,7 @@ async def post(self, request): 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 bf248369987625..7118944a4ec457 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 02c953736bb718..24414e4efb8bb4 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 129cdf509797f4..5e226dcead7656 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -70,8 +70,8 @@ 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/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index a834a8f2df6d34..ea6cd542fea75b 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -1,10 +1,11 @@ """The ONVIF integration.""" import asyncio +from contextlib import suppress from http import HTTPStatus import logging from httpx import RequestError -from onvif.exceptions import ONVIFAuthError, ONVIFError, ONVIFTimeoutError +from onvif.exceptions import ONVIFError from onvif.util import is_auth_error, stringify_onvif_error from zeep.exceptions import Fault, TransportError @@ -120,31 +121,31 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, device.platforms) -async def _get_snapshot_auth(device): +async def _get_snapshot_auth(device: ONVIFDevice) -> str | None: """Determine auth type for snapshots.""" - if not device.capabilities.snapshot or not (device.username and device.password): - return HTTP_DIGEST_AUTHENTICATION + if not device.capabilities.snapshot: + return None - try: - snapshot = await device.device.get_snapshot(device.profiles[0].token) + for basic_auth in (False, True): + method = HTTP_BASIC_AUTHENTICATION if basic_auth else HTTP_DIGEST_AUTHENTICATION + with suppress(ONVIFError): + if await device.device.get_snapshot(device.profiles[0].token, basic_auth): + return method - if snapshot: - return HTTP_DIGEST_AUTHENTICATION - return HTTP_BASIC_AUTHENTICATION - except (ONVIFAuthError, ONVIFTimeoutError): - return HTTP_BASIC_AUTHENTICATION - except ONVIFError: - return HTTP_DIGEST_AUTHENTICATION + return None -async def async_populate_snapshot_auth(hass, device, entry): +async def async_populate_snapshot_auth( + hass: HomeAssistant, device: ONVIFDevice, entry: ConfigEntry +) -> None: """Check if digest auth for snapshots is possible.""" - auth = await _get_snapshot_auth(device) - new_data = {**entry.data, CONF_SNAPSHOT_AUTH: auth} - hass.config_entries.async_update_entry(entry, data=new_data) + if auth := await _get_snapshot_auth(device): + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_SNAPSHOT_AUTH: auth} + ) -async def async_populate_options(hass, entry): +async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Populate default options for device.""" options = { CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS, diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 020649db87ebdd..c1df94f5f8383b 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -35,6 +35,7 @@ from .const import ( CONF_DEVICE_ID, CONF_ENABLE_WEBHOOKS, + CONF_HARDWARE, DEFAULT_ARGUMENTS, DEFAULT_ENABLE_WEBHOOKS, DEFAULT_PORT, @@ -50,12 +51,15 @@ def wsdiscovery() -> list[Service]: """Get ONVIF Profile S devices from network.""" discovery = WSDiscovery(ttl=4) - discovery.start() - services = discovery.searchServices( - scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")] - ) - discovery.stop() - return services + try: + discovery.start() + return discovery.searchServices( + scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")] + ) + finally: + discovery.stop() + # Stop the threads started by WSDiscovery since otherwise there is a leak. + discovery._stopThreads() # pylint: disable=protected-access async def async_discovery(hass: HomeAssistant) -> list[dict[str, Any]]: @@ -71,11 +75,14 @@ async def async_discovery(hass: HomeAssistant) -> list[dict[str, Any]]: CONF_NAME: service.getEPR(), CONF_HOST: url.hostname, CONF_PORT: url.port or 80, + CONF_HARDWARE: None, } for scope in service.getScopes(): scope_str = scope.getValue() if scope_str.lower().startswith("onvif://www.onvif.org/name"): device[CONF_NAME] = scope_str.split("/")[-1] + if scope_str.lower().startswith("onvif://www.onvif.org/hardware"): + device[CONF_HARDWARE] = scope_str.split("/")[-1] if scope_str.lower().startswith("onvif://www.onvif.org/mac"): device[CONF_DEVICE_ID] = scope_str.split("/")[-1] devices.append(device) @@ -192,8 +199,7 @@ async def async_step_device(self, user_input=None): return await self.async_step_configure() for device in self.devices: - name = f"{device[CONF_NAME]} ({device[CONF_HOST]})" - if name == user_input[CONF_HOST]: + if device[CONF_HOST] == user_input[CONF_HOST]: self.device_id = device[CONF_DEVICE_ID] self.onvif_config = { CONF_NAME: device[CONF_NAME], @@ -215,15 +221,16 @@ async def async_step_device(self, user_input=None): LOGGER.debug("Discovered ONVIF devices %s", pformat(self.devices)) if self.devices: - names = [ - f"{device[CONF_NAME]} ({device[CONF_HOST]})" for device in self.devices - ] - - names.append(CONF_MANUAL_INPUT) + devices = {CONF_MANUAL_INPUT: CONF_MANUAL_INPUT} + for device in self.devices: + description = f"{device[CONF_NAME]} ({device[CONF_HOST]})" + if hardware := device[CONF_HARDWARE]: + description += f" [{hardware}]" + devices[device[CONF_HOST]] = description return self.async_show_form( step_id="device", - data_schema=vol.Schema({vol.Optional(CONF_HOST): vol.In(names)}), + data_schema=vol.Schema({vol.Optional(CONF_HOST): vol.In(devices)}), ) return await self.async_step_configure() diff --git a/homeassistant/components/onvif/const.py b/homeassistant/components/onvif/const.py index 8d95ef484bf95c..77fa098a316060 100644 --- a/homeassistant/components/onvif/const.py +++ b/homeassistant/components/onvif/const.py @@ -13,6 +13,7 @@ DEFAULT_ARGUMENTS = "-pred 1" CONF_DEVICE_ID = "deviceid" +CONF_HARDWARE = "hardware" CONF_SNAPSHOT_AUTH = "snapshot_auth" CONF_ENABLE_WEBHOOKS = "enable_webhooks" DEFAULT_ENABLE_WEBHOOKS = True diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index e470b3c700bfc6..a524d8ea519825 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -23,7 +23,7 @@ CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.util.dt as dt_util from .const import ( @@ -189,14 +189,19 @@ async def async_manually_set_date_and_time(self) -> None: dt_param.DateTimeType = "Manual" # Retrieve DST setting from system dt_param.DaylightSavings = bool(time.localtime().tm_isdst) - dt_param.UTCDateTime = device_time.UTCDateTime + dt_param.UTCDateTime = { + "Date": { + "Year": system_date.year, + "Month": system_date.month, + "Day": system_date.day, + }, + "Time": { + "Hour": system_date.hour, + "Minute": system_date.minute, + "Second": system_date.second, + }, + } # Retrieve timezone from system - dt_param.UTCDateTime.Date.Year = system_date.year - dt_param.UTCDateTime.Date.Month = system_date.month - dt_param.UTCDateTime.Date.Day = system_date.day - dt_param.UTCDateTime.Time.Hour = system_date.hour - dt_param.UTCDateTime.Time.Minute = system_date.minute - dt_param.UTCDateTime.Time.Second = system_date.second system_timezone = str(system_date.astimezone().tzinfo) timezone_names: list[str | None] = [system_timezone] if (time_zone := device_time.TimeZone) and system_timezone != time_zone.TZ: @@ -283,6 +288,22 @@ async def async_check_date_and_time(self) -> None: if abs(self._dt_diff_seconds) < 5: return + if device_time.DateTimeType != "Manual": + self._async_log_time_out_of_sync(cam_date_utc, system_date) + return + + # Set Date and Time ourselves if Date and Time is set manually in the camera. + try: + await self.async_manually_set_date_and_time() + except (RequestError, TransportError, IndexError, Fault): + LOGGER.warning("%s: Could not sync date/time on this camera", self.name) + self._async_log_time_out_of_sync(cam_date_utc, system_date) + + @callback + def _async_log_time_out_of_sync( + self, cam_date_utc: dt.datetime, system_date: dt.datetime + ) -> None: + """Log a warning if the camera and system date/time are not synced.""" LOGGER.warning( ( "The date/time on %s (UTC) is '%s', " @@ -294,15 +315,6 @@ async def async_check_date_and_time(self) -> None: system_date, ) - if device_time.DateTimeType != "Manual": - return - - # Set Date and Time ourselves if Date and Time is set manually in the camera. - try: - await self.async_manually_set_date_and_time() - except (RequestError, TransportError, IndexError, Fault): - LOGGER.warning("%s: Could not sync date/time on this camera", self.name) - async def async_get_device_info(self) -> DeviceInfo: """Obtain information about this device.""" device_mgmt = await self.device.create_devicemgmt_service() diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index a749e59be48c64..e92e80a9a68aaa 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.1.7", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.1.9", "WSDiscovery==2.0.0"] } diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py index 2d06b20a30a358..b23abb54f8b6ce 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 78294ceb6f4def..c7ee5a7d00c728 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 00000000000000..c8a13a3c7aab3e --- /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 aa563151f0be28..de6c56a01ddaac 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 b625d9976da20c..77ab0ac0aafb5c 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -9,7 +9,6 @@ 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 @@ 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 @@ _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 @@ def __init__(self, hass, device): 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 00000000000000..54c2d16fb2b35e --- /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 7fdb6cfd677e1c..6c6d3acb30eb4e 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -1,7 +1,8 @@ { "domain": "opensky", "name": "OpenSky Network", - "codeowners": [], + "codeowners": ["@joostlek"], "documentation": "https://www.home-assistant.io/integrations/opensky", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "requirements": ["python-opensky==0.0.10"] } diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 03e242f40b2974..cdedd0c9620b7d 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -3,7 +3,7 @@ from datetime import timedelta -import requests +from python_opensky import BoundingBox, OpenSky, StateVector import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -14,14 +14,12 @@ CONF_LONGITUDE, CONF_NAME, CONF_RADIUS, - UnitOfLength, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import location as util_location -from homeassistant.util.unit_conversion import DistanceConverter CONF_ALTITUDE = "altitude" @@ -79,15 +77,18 @@ def setup_platform( """Set up the Open Sky platform.""" latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + radius = config.get(CONF_RADIUS, 0) + bounding_box = OpenSky.get_bounding_box(latitude, longitude, radius * 1000) + session = async_get_clientsession(hass) + opensky = OpenSky(session=session) add_entities( [ OpenSkySensor( hass, config.get(CONF_NAME, DOMAIN), - latitude, - longitude, - config.get(CONF_RADIUS), - config.get(CONF_ALTITUDE), + opensky, + bounding_box, + config[CONF_ALTITUDE], ) ], True, @@ -101,38 +102,43 @@ class OpenSkySensor(SensorEntity): "Information provided by the OpenSky Network (https://opensky-network.org)" ) - def __init__(self, hass, name, latitude, longitude, radius, altitude): + def __init__( + self, + hass: HomeAssistant, + name: str, + opensky: OpenSky, + bounding_box: BoundingBox, + altitude: float, + ) -> None: """Initialize the sensor.""" - self._session = requests.Session() - self._latitude = latitude - self._longitude = longitude - self._radius = DistanceConverter.convert( - radius, UnitOfLength.KILOMETERS, UnitOfLength.METERS - ) self._altitude = altitude self._state = 0 self._hass = hass self._name = name - self._previously_tracked = None + self._previously_tracked: set[str] = set() + self._opensky = opensky + self._bounding_box = bounding_box @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def native_value(self): + def native_value(self) -> int: """Return the state of the sensor.""" return self._state - def _handle_boundary(self, flights, event, metadata): + def _handle_boundary( + self, flights: set[str], event: str, metadata: dict[str, StateVector] + ) -> None: """Handle flights crossing region boundary.""" for flight in flights: if flight in metadata: - altitude = metadata[flight].get(ATTR_ALTITUDE) - longitude = metadata[flight].get(ATTR_LONGITUDE) - latitude = metadata[flight].get(ATTR_LATITUDE) - icao24 = metadata[flight].get(ATTR_ICAO24) + altitude = metadata[flight].barometric_altitude + longitude = metadata[flight].longitude + latitude = metadata[flight].latitude + icao24 = metadata[flight].icao24 else: # Assume Flight has landed if missing. altitude = 0 @@ -150,33 +156,27 @@ def _handle_boundary(self, flights, event, metadata): } self._hass.bus.fire(event, data) - def update(self) -> None: + async def async_update(self) -> None: """Update device state.""" currently_tracked = set() - flight_metadata = {} - states = self._session.get(OPENSKY_API_URL).json().get(ATTR_STATES) - for state in states: - flight = dict(zip(OPENSKY_API_FIELDS, state)) - callsign = flight[ATTR_CALLSIGN].strip() + flight_metadata: dict[str, StateVector] = {} + response = await self._opensky.get_states(bounding_box=self._bounding_box) + for flight in response.states: + if not flight.callsign: + continue + callsign = flight.callsign.strip() if callsign != "": flight_metadata[callsign] = flight else: continue if ( - (longitude := flight.get(ATTR_LONGITUDE)) is None - or (latitude := flight.get(ATTR_LATITUDE)) is None - or flight.get(ATTR_ON_GROUND) + flight.longitude is None + or flight.latitude is None + or flight.on_ground + or flight.barometric_altitude is None ): continue - distance = util_location.distance( - self._latitude, - self._longitude, - latitude, - longitude, - ) - if distance is None or distance > self._radius: - continue - altitude = flight.get(ATTR_ALTITUDE) + altitude = flight.barometric_altitude if altitude > self._altitude and self._altitude != 0: continue currently_tracked.add(callsign) @@ -189,11 +189,11 @@ def update(self) -> None: self._previously_tracked = currently_tracked @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str: """Return the unit of measurement.""" return "flights" @property - def icon(self): + def icon(self) -> str: """Return the icon.""" return "mdi:airplane" diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index 1e69af66eec530..e9f9ee99ff6bc1 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -19,7 +19,7 @@ BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW = BinarySensorEntityDescription( key=TYPE_PROTECTION_WINDOW, - name="Protection window", + translation_key="protection_window", icon="mdi:sunglasses", ) diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 44bde8341a0bc8..90eefac594a7f9 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -49,67 +49,67 @@ SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=TYPE_CURRENT_OZONE_LEVEL, - name="Current ozone level", + translation_key="current_ozone_level", native_unit_of_measurement="du", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_CURRENT_UV_INDEX, - name="Current UV index", + translation_key="current_uv_index", icon="mdi:weather-sunny", native_unit_of_measurement=UV_INDEX, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_CURRENT_UV_LEVEL, - name="Current UV level", + translation_key="current_uv_level", icon="mdi:weather-sunny", ), SensorEntityDescription( key=TYPE_MAX_UV_INDEX, - name="Max UV index", + translation_key="max_uv_index", icon="mdi:weather-sunny", native_unit_of_measurement=UV_INDEX, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_1, - name="Skin type 1 safe exposure time", + translation_key="skin_type_1_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_2, - name="Skin type 2 safe exposure time", + translation_key="skin_type_2_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_3, - name="Skin type 3 safe exposure time", + translation_key="skin_type_3_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_4, - name="Skin type 4 safe exposure time", + translation_key="skin_type_4_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_5, - name="Skin type 5 safe exposure time", + translation_key="skin_type_5_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_6, - name="Skin type 6 safe exposure time", + translation_key="skin_type_6_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/openuv/strings.json b/homeassistant/components/openuv/strings.json index 9542cb8b1a7142..4aa29d11fcf26c 100644 --- a/homeassistant/components/openuv/strings.json +++ b/homeassistant/components/openuv/strings.json @@ -46,5 +46,44 @@ "title": "The {deprecated_service} service is being removed", "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with `{alternate_targets}` as the target." } + }, + "entity": { + "binary_sensor": { + "protection_window": { + "name": "Protection window" + } + }, + "sensor": { + "current_ozone_level": { + "name": "Current ozone level" + }, + "current_uv_index": { + "name": "Current UV index" + }, + "current_uv_level": { + "name": "Current UV level" + }, + "max_uv_index": { + "name": "Max UV index" + }, + "skin_type_1_safe_exposure_time": { + "name": "Skin type 1 safe exposure time" + }, + "skin_type_2_safe_exposure_time": { + "name": "Skin type 2 safe exposure time" + }, + "skin_type_3_safe_exposure_time": { + "name": "Skin type 3 safe exposure time" + }, + "skin_type_4_safe_exposure_time": { + "name": "Skin type 4 safe exposure time" + }, + "skin_type_5_safe_exposure_time": { + "name": "Skin type 5 safe exposure time" + }, + "skin_type_6_safe_exposure_time": { + "name": "Skin type 6 safe exposure time" + } + } } } diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 4af068f2b84057..d53fbc136b21da 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -35,6 +35,7 @@ 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 @@ 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 da29031d513d43..30f98bb39d1f25 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -4,7 +4,10 @@ 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_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 @@ 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 @@ 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 @@ def condition(self) -> str | None: """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 @@ def humidity(self) -> float | None: """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 90e2c426d27b3d..521c1f87ca206a 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -12,7 +12,7 @@ from homeassistant.const import UnitOfTemperature from homeassistant.helpers import sun from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( @@ -21,7 +21,10 @@ 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 @@ 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 @@ def _convert_weather_response(self, weather_response): 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), @@ -159,7 +164,7 @@ def _get_forecast_from_weather_response(self, weather_response): def _convert_forecast(self, entry): """Convert the forecast data.""" forecast = { - ATTR_API_FORECAST_TIME: dt.utc_from_timestamp( + ATTR_API_FORECAST_TIME: dt_util.utc_from_timestamp( entry.reference_time("unix") ).isoformat(), ATTR_API_FORECAST_PRECIPITATION: self._calc_precipitation( @@ -174,7 +179,11 @@ def _convert_forecast(self, entry): 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") @@ -252,7 +261,7 @@ def _get_condition(self, weather_code, timestamp=None): """Get weather condition from weather data.""" if weather_code == WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT: if timestamp: - timestamp = dt.utc_from_timestamp(timestamp) + timestamp = dt_util.utc_from_timestamp(timestamp) if sun.is_up(self.hass, timestamp): return ATTR_CONDITION_SUNNY diff --git a/homeassistant/components/opnsense/__init__.py b/homeassistant/components/opnsense/__init__.py index bd074cf5e5ea94..0111379df44e8d 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 89e8efa34260ed..bf8a41d17855d8 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/opower/__init__.py b/homeassistant/components/opower/__init__.py new file mode 100644 index 00000000000000..f4fca22c9b4424 --- /dev/null +++ b/homeassistant/components/opower/__init__.py @@ -0,0 +1,31 @@ +"""The Opower integration.""" +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 OpowerCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Opower from a config entry.""" + + coordinator = OpowerCoordinator(hass, entry.data) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py new file mode 100644 index 00000000000000..fdf007c3b681a4 --- /dev/null +++ b/homeassistant/components/opower/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow for Opower integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from opower import CannotConnect, InvalidAuth, Opower, get_supported_utility_names +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .const import CONF_UTILITY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names()), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +async def _validate_login( + hass: HomeAssistant, login_data: dict[str, str] +) -> dict[str, str]: + """Validate login data and return any errors.""" + api = Opower( + async_create_clientsession(hass), + login_data[CONF_UTILITY], + login_data[CONF_USERNAME], + login_data[CONF_PASSWORD], + ) + errors: dict[str, str] = {} + try: + await api.async_login() + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + return errors + + +class OpowerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Opower.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize a new OpowerConfigFlow.""" + self.reauth_entry: config_entries.ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_UTILITY: user_input[CONF_UTILITY], + CONF_USERNAME: user_input[CONF_USERNAME], + } + ) + errors = await _validate_login(self.hass, user_input) + if not errors: + return self.async_create_entry( + title=f"{user_input[CONF_UTILITY]} ({user_input[CONF_USERNAME]})", + data=user_input, + ) + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + assert self.reauth_entry + errors: dict[str, str] = {} + if user_input is not None: + data = {**self.reauth_entry.data, **user_input} + errors = await _validate_login(self.hass, data) + if not errors: + self.hass.config_entries.async_update_entry( + self.reauth_entry, data=data + ) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): self.reauth_entry.data[CONF_USERNAME], + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/opower/const.py b/homeassistant/components/opower/const.py new file mode 100644 index 00000000000000..b996a214a05916 --- /dev/null +++ b/homeassistant/components/opower/const.py @@ -0,0 +1,5 @@ +"""Constants for the Opower integration.""" + +DOMAIN = "opower" + +CONF_UTILITY = "utility" diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py new file mode 100644 index 00000000000000..c331f45bc4977c --- /dev/null +++ b/homeassistant/components/opower/coordinator.py @@ -0,0 +1,220 @@ +"""Coordinator to handle Opower connections.""" +from datetime import datetime, timedelta +import logging +from types import MappingProxyType +from typing import Any, cast + +from opower import ( + Account, + AggregateType, + CostRead, + Forecast, + InvalidAuth, + MeterType, + Opower, +) + +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, + statistics_during_period, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_UTILITY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): + """Handle fetching Opower data, updating sensors and inserting statistics.""" + + def __init__( + self, + hass: HomeAssistant, + entry_data: MappingProxyType[str, Any], + ) -> None: + """Initialize the data handler.""" + super().__init__( + hass, + _LOGGER, + name="Opower", + # Data is updated daily on Opower. + # Refresh every 12h to be at most 12h behind. + update_interval=timedelta(hours=12), + ) + self.api = Opower( + aiohttp_client.async_get_clientsession(hass), + entry_data[CONF_UTILITY], + entry_data[CONF_USERNAME], + entry_data[CONF_PASSWORD], + ) + + async def _async_update_data( + self, + ) -> dict[str, Forecast]: + """Fetch data from API endpoint.""" + try: + # Login expires after a few minutes. + # Given the infrequent updating (every 12h) + # assume previous session has expired and re-login. + await self.api.async_login() + except InvalidAuth as err: + raise ConfigEntryAuthFailed from err + forecasts: list[Forecast] = await self.api.async_get_forecast() + _LOGGER.debug("Updating sensor data with: %s", forecasts) + await self._insert_statistics([forecast.account for forecast in forecasts]) + return {forecast.account.utility_account_id: forecast for forecast in forecasts} + + async def _insert_statistics(self, accounts: list[Account]) -> None: + """Insert Opower statistics.""" + for account in accounts: + id_prefix = "_".join( + ( + self.api.utility.subdomain(), + account.meter_type.name.lower(), + account.utility_account_id, + ) + ) + cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_cost" + consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption" + _LOGGER.debug( + "Updating Statistics for %s and %s", + cost_statistic_id, + consumption_statistic_id, + ) + + last_stat = await get_instance(self.hass).async_add_executor_job( + get_last_statistics, self.hass, 1, consumption_statistic_id, True, set() + ) + if not last_stat: + _LOGGER.debug("Updating statistic for the first time") + cost_reads = await self._async_get_all_cost_reads(account) + cost_sum = 0.0 + consumption_sum = 0.0 + last_stats_time = None + else: + cost_reads = await self._async_get_recent_cost_reads( + account, last_stat[consumption_statistic_id][0]["start"] + ) + if not cost_reads: + _LOGGER.debug("No recent usage/cost data. Skipping update") + continue + stats = await get_instance(self.hass).async_add_executor_job( + statistics_during_period, + self.hass, + cost_reads[0].start_time, + None, + {cost_statistic_id, consumption_statistic_id}, + "hour" if account.meter_type == MeterType.ELEC else "day", + None, + {"sum"}, + ) + cost_sum = cast(float, stats[cost_statistic_id][0]["sum"]) + consumption_sum = cast(float, stats[consumption_statistic_id][0]["sum"]) + last_stats_time = stats[cost_statistic_id][0]["start"] + + cost_statistics = [] + consumption_statistics = [] + + for cost_read in cost_reads: + start = cost_read.start_time + if last_stats_time is not None and start.timestamp() <= last_stats_time: + continue + cost_sum += cost_read.provided_cost + consumption_sum += cost_read.consumption + + cost_statistics.append( + StatisticData( + start=start, state=cost_read.provided_cost, sum=cost_sum + ) + ) + consumption_statistics.append( + StatisticData( + start=start, state=cost_read.consumption, sum=consumption_sum + ) + ) + + name_prefix = " ".join( + ( + "Opower", + self.api.utility.subdomain(), + account.meter_type.name.lower(), + account.utility_account_id, + ) + ) + cost_metadata = StatisticMetaData( + has_mean=False, + has_sum=True, + name=f"{name_prefix} cost", + source=DOMAIN, + statistic_id=cost_statistic_id, + unit_of_measurement=None, + ) + consumption_metadata = StatisticMetaData( + has_mean=False, + has_sum=True, + name=f"{name_prefix} consumption", + source=DOMAIN, + statistic_id=consumption_statistic_id, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR + if account.meter_type == MeterType.ELEC + else UnitOfVolume.CENTUM_CUBIC_FEET, + ) + + async_add_external_statistics(self.hass, cost_metadata, cost_statistics) + async_add_external_statistics( + self.hass, consumption_metadata, consumption_statistics + ) + + async def _async_get_all_cost_reads(self, account: Account) -> list[CostRead]: + """Get all cost reads since account activation but at different resolutions depending on age. + + - month resolution for all years (since account activation) + - day resolution for past 3 years + - hour resolution for past 2 months, only for electricity, not gas + """ + cost_reads = [] + start = None + end = datetime.now() - timedelta(days=3 * 365) + cost_reads += await self.api.async_get_cost_reads( + account, AggregateType.BILL, start, end + ) + start = end if not cost_reads else cost_reads[-1].end_time + end = ( + datetime.now() - timedelta(days=2 * 30) + if account.meter_type == MeterType.ELEC + else datetime.now() + ) + cost_reads += await self.api.async_get_cost_reads( + account, AggregateType.DAY, start, end + ) + if account.meter_type == MeterType.ELEC: + start = end if not cost_reads else cost_reads[-1].end_time + end = datetime.now() + cost_reads += await self.api.async_get_cost_reads( + account, AggregateType.HOUR, start, end + ) + return cost_reads + + async def _async_get_recent_cost_reads( + self, account: Account, last_stat_time: float + ) -> list[CostRead]: + """Get cost reads within the past 30 days to allow corrections in data from utilities. + + Hourly for electricity, daily for gas. + """ + return await self.api.async_get_cost_reads( + account, + AggregateType.HOUR + if account.meter_type == MeterType.ELEC + else AggregateType.DAY, + datetime.fromtimestamp(last_stat_time) - timedelta(days=30), + datetime.now(), + ) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json new file mode 100644 index 00000000000000..3b48e96a35194c --- /dev/null +++ b/homeassistant/components/opower/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "opower", + "name": "Opower", + "codeowners": ["@tronikos"], + "config_flow": true, + "dependencies": ["recorder"], + "documentation": "https://www.home-assistant.io/integrations/opower", + "iot_class": "cloud_polling", + "requirements": ["opower==0.0.12"] +} diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py new file mode 100644 index 00000000000000..36f88a36e8aba1 --- /dev/null +++ b/homeassistant/components/opower/sensor.py @@ -0,0 +1,235 @@ +"""Support for Opower sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from opower import Forecast, MeterType, UnitOfMeasure + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfEnergy, UnitOfVolume +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OpowerCoordinator + + +@dataclass +class OpowerEntityDescriptionMixin: + """Mixin values for required keys.""" + + value_fn: Callable[[Forecast], str | float] + + +@dataclass +class OpowerEntityDescription(SensorEntityDescription, OpowerEntityDescriptionMixin): + """Class describing Opower sensors entities.""" + + +# suggested_display_precision=0 for all sensors since +# Opower provides 0 decimal points for all these. +# (for the statistics in the energy dashboard Opower does provide decimal points) +ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( + OpowerEntityDescription( + key="elec_usage_to_date", + name="Current bill electric usage to date", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.usage_to_date, + ), + OpowerEntityDescription( + key="elec_forecasted_usage", + name="Current bill electric forecasted usage", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.forecasted_usage, + ), + OpowerEntityDescription( + key="elec_typical_usage", + name="Typical monthly electric usage", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.typical_usage, + ), + OpowerEntityDescription( + key="elec_cost_to_date", + name="Current bill electric cost to date", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="USD", + suggested_unit_of_measurement="USD", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.cost_to_date, + ), + OpowerEntityDescription( + key="elec_forecasted_cost", + name="Current bill electric forecasted cost", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="USD", + suggested_unit_of_measurement="USD", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.forecasted_cost, + ), + OpowerEntityDescription( + key="elec_typical_cost", + name="Typical monthly electric cost", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="USD", + suggested_unit_of_measurement="USD", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.typical_cost, + ), +) +GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( + OpowerEntityDescription( + key="gas_usage_to_date", + name="Current bill gas usage to date", + device_class=SensorDeviceClass.GAS, + native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, + suggested_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.usage_to_date, + ), + OpowerEntityDescription( + key="gas_forecasted_usage", + name="Current bill gas forecasted usage", + device_class=SensorDeviceClass.GAS, + native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, + suggested_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.forecasted_usage, + ), + OpowerEntityDescription( + key="gas_typical_usage", + name="Typical monthly gas usage", + device_class=SensorDeviceClass.GAS, + native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, + suggested_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.typical_usage, + ), + OpowerEntityDescription( + key="gas_cost_to_date", + name="Current bill gas cost to date", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="USD", + suggested_unit_of_measurement="USD", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.cost_to_date, + ), + OpowerEntityDescription( + key="gas_forecasted_cost", + name="Current bill gas forecasted cost", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="USD", + suggested_unit_of_measurement="USD", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.forecasted_cost, + ), + OpowerEntityDescription( + key="gas_typical_cost", + name="Typical monthly gas cost", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="USD", + suggested_unit_of_measurement="USD", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.typical_cost, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Opower sensor.""" + + coordinator: OpowerCoordinator = hass.data[DOMAIN][entry.entry_id] + entities: list[OpowerSensor] = [] + forecasts = coordinator.data.values() + for forecast in forecasts: + device_id = f"{coordinator.api.utility.subdomain()}_{forecast.account.utility_account_id}" + device = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + name=f"{forecast.account.meter_type.name} account {forecast.account.utility_account_id}", + manufacturer="Opower", + model=coordinator.api.utility.name(), + entry_type=DeviceEntryType.SERVICE, + ) + sensors: tuple[OpowerEntityDescription, ...] = () + if ( + forecast.account.meter_type == MeterType.ELEC + and forecast.unit_of_measure == UnitOfMeasure.KWH + ): + sensors = ELEC_SENSORS + elif ( + forecast.account.meter_type == MeterType.GAS + and forecast.unit_of_measure == UnitOfMeasure.THERM + ): + sensors = GAS_SENSORS + for sensor in sensors: + entities.append( + OpowerSensor( + coordinator, + sensor, + forecast.account.utility_account_id, + device, + device_id, + ) + ) + + async_add_entities(entities) + + +class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity): + """Representation of an Opower sensor.""" + + entity_description: OpowerEntityDescription + + def __init__( + self, + coordinator: OpowerCoordinator, + description: OpowerEntityDescription, + utility_account_id: str, + device: DeviceInfo, + device_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{description.key}" + self._attr_device_info = device + self.utility_account_id = utility_account_id + + @property + def native_value(self) -> StateType: + """Return the state.""" + if self.coordinator.data is not None: + return self.entity_description.value_fn( + self.coordinator.data[self.utility_account_id] + ) + return None diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json new file mode 100644 index 00000000000000..037983eb6ffd49 --- /dev/null +++ b/homeassistant/components/opower/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "data": { + "utility": "Utility name", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "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%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/opple/manifest.json b/homeassistant/components/opple/manifest.json index 9d87114c2d045f..174907dfd0faab 100644 --- a/homeassistant/components/opple/manifest.json +++ b/homeassistant/components/opple/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/opple", "iot_class": "local_polling", "loggers": ["pyoppleio"], - "requirements": ["pyoppleio==1.0.5"] + "requirements": ["pyoppleio-legacy==1.0.8"] } diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 5942d67b50d5ab..8f8810b5f33b17 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -10,6 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType @@ -17,6 +18,8 @@ from .const import DOMAIN from .util import OTBRData, update_issues +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Open Thread Border Router component.""" diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 32842ad6cc7809..67c8412102d6a5 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -2,19 +2,29 @@ from __future__ import annotations import asyncio +from contextlib import suppress import logging +from typing import cast import aiohttp import python_otbr_api from python_otbr_api import tlv_parser +from python_otbr_api.tlv_parser import MeshcopTLVType import voluptuous as vol import yarl -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 @@ -23,6 +33,32 @@ _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.""" @@ -38,8 +74,8 @@ async def _connect_and_set_dataset(self, otbr_url: str) -> None: thread_dataset_tlv = await async_get_preferred_dataset(self.hass) if thread_dataset_tlv: dataset = tlv_parser.parse_tlv(thread_dataset_tlv) - if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): - thread_dataset_channel = int(channel_str, base=16) + if channel := dataset.get(MeshcopTLVType.CHANNEL): + thread_dataset_channel = cast(tlv_parser.Channel, channel).channel if thread_dataset_tlv is not None and ( not allowed_channel or allowed_channel == thread_dataset_channel @@ -50,7 +86,7 @@ async def _connect_and_set_dataset(self, otbr_url: str) -> None: "not importing TLV with channel %s", thread_dataset_channel ) await api.create_active_dataset( - python_otbr_api.OperationalDataSet( + python_otbr_api.ActiveDataSet( channel=allowed_channel if allowed_channel else DEFAULT_CHANNEL, network_name="home-assistant", ) @@ -122,6 +158,6 @@ async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResu 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/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index c10a2417dc6a38..94659df8547d03 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==1.0.9"] + "requirements": ["python-otbr-api==2.2.0"] } diff --git a/homeassistant/components/otbr/silabs_multiprotocol.py b/homeassistant/components/otbr/silabs_multiprotocol.py new file mode 100644 index 00000000000000..9a462c4610bda0 --- /dev/null +++ b/homeassistant/components/otbr/silabs_multiprotocol.py @@ -0,0 +1,87 @@ +"""Silicon Labs Multiprotocol support.""" + +from __future__ import annotations + +import asyncio +import logging + +import aiohttp +from python_otbr_api import tlv_parser +from python_otbr_api.tlv_parser import MeshcopTLVType + +from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, +) +from homeassistant.components.thread import async_add_dataset +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import DOMAIN +from .util import OTBRData + +_LOGGER = logging.getLogger(__name__) + + +async def async_change_channel(hass: HomeAssistant, channel: int, delay: float) -> None: + """Set the channel to be used. + + Does nothing if not configured. + """ + if DOMAIN not in hass.data: + return + + data: OTBRData = hass.data[DOMAIN] + await data.set_channel(channel, delay) + + # Import the new dataset + dataset_tlvs = await data.get_pending_dataset_tlvs() + if dataset_tlvs is None: + # The activation timer may have expired already + dataset_tlvs = await data.get_active_dataset_tlvs() + if dataset_tlvs is None: + # Don't try to import a None dataset + return + + dataset = tlv_parser.parse_tlv(dataset_tlvs.hex()) + dataset.pop(MeshcopTLVType.DELAYTIMER, None) + dataset.pop(MeshcopTLVType.PENDINGTIMESTAMP, None) + dataset_tlvs_str = tlv_parser.encode_tlv(dataset) + await async_add_dataset(hass, DOMAIN, dataset_tlvs_str) + + +async def async_get_channel(hass: HomeAssistant) -> int | None: + """Return the channel. + + Returns None if not configured. + """ + if DOMAIN not in hass.data: + return None + + data: OTBRData = hass.data[DOMAIN] + + try: + dataset = await data.get_active_dataset() + except ( + HomeAssistantError, + aiohttp.ClientError, + asyncio.TimeoutError, + ) as err: + _LOGGER.warning("Failed to communicate with OTBR %s", err) + return None + + if dataset is None: + return None + + return dataset.channel + + +async def async_using_multipan(hass: HomeAssistant) -> bool: + """Return if the multiprotocol device is used. + + Returns False if not configured. + """ + if DOMAIN not in hass.data: + return False + + data: OTBRData = hass.data[DOMAIN] + return is_multiprotocol_url(data.url) diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index b2ce05f280ca40..2d6217ea585a4c 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -2,21 +2,22 @@ from __future__ import annotations from collections.abc import Callable, Coroutine -import contextlib import dataclasses from functools import wraps -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate, ParamSpec, TypeVar, cast import python_otbr_api -from python_otbr_api import tlv_parser +from python_otbr_api import PENDING_DATASET_DELAY_TIMER, tlv_parser from python_otbr_api.pskc import compute_pskc +from python_otbr_api.tlv_parser import MeshcopTLVType from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + MultiprotocolAddonManager, + get_addon_manager, is_multiprotocol_url, multi_pan_addon_using_device, ) from homeassistant.components.homeassistant_yellow import RADIO_DEVICE as YELLOW_RADIO -from homeassistant.components.zha import api as zha_api from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -72,60 +73,59 @@ async def set_enabled(self, enabled: bool) -> None: """Enable or disable the router.""" return await self.api.set_enabled(enabled) + @_handle_otbr_error + async def get_active_dataset(self) -> python_otbr_api.ActiveDataSet | None: + """Get current active operational dataset, or None.""" + return await self.api.get_active_dataset() + @_handle_otbr_error async def get_active_dataset_tlvs(self) -> bytes | None: """Get current active operational dataset in TLVS format, or None.""" return await self.api.get_active_dataset_tlvs() + @_handle_otbr_error + async def get_pending_dataset_tlvs(self) -> bytes | None: + """Get current pending operational dataset in TLVS format, or None.""" + return await self.api.get_pending_dataset_tlvs() + @_handle_otbr_error async def create_active_dataset( - self, dataset: python_otbr_api.OperationalDataSet + self, dataset: python_otbr_api.ActiveDataSet ) -> None: """Create an active operational dataset.""" return await self.api.create_active_dataset(dataset) + @_handle_otbr_error + async def delete_active_dataset(self) -> None: + """Delete the active operational dataset.""" + return await self.api.delete_active_dataset() + @_handle_otbr_error async def set_active_dataset_tlvs(self, dataset: bytes) -> None: """Set current active operational dataset in TLVS format.""" await self.api.set_active_dataset_tlvs(dataset) + @_handle_otbr_error + async def set_channel( + self, channel: int, delay: float = PENDING_DATASET_DELAY_TIMER / 1000 + ) -> None: + """Set current channel.""" + await self.api.set_channel(channel, delay=int(delay * 1000)) + @_handle_otbr_error async def get_extended_address(self) -> bytes: """Get extended address (EUI-64).""" return await self.api.get_extended_address() -def _get_zha_url(hass: HomeAssistant) -> str | None: - """Get ZHA radio path, or None if there's no ZHA config entry.""" - with contextlib.suppress(ValueError): - return zha_api.async_get_radio_path(hass) - return None - - -async def _get_zha_channel(hass: HomeAssistant) -> int | None: - """Get ZHA channel, or None if there's no ZHA config entry.""" - zha_network_settings: zha_api.NetworkBackup | None - with contextlib.suppress(ValueError): - zha_network_settings = await zha_api.async_get_network_settings(hass) - if not zha_network_settings: - return None - channel: int = zha_network_settings.network_info.channel - # ZHA uses channel 0 when no channel is set - return channel or None - - async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None: """Return the allowed channel, or None if there's no restriction.""" if not is_multiprotocol_url(otbr_url): # The OTBR is not sharing the radio, no restriction return None - zha_url = _get_zha_url(hass) - if not zha_url or not is_multiprotocol_url(zha_url): - # ZHA is not configured or not sharing the radio with this OTBR, no restriction - return None - - return await _get_zha_channel(hass) + addon_manager: MultiprotocolAddonManager = await get_addon_manager(hass) + return addon_manager.async_get_channel() async def _warn_on_channel_collision( @@ -146,14 +146,10 @@ def delete_issue() -> None: dataset = tlv_parser.parse_tlv(dataset_tlvs.hex()) - if (channel_s := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None: - delete_issue() - return - try: - channel = int(channel_s, 16) - except ValueError: + if (channel_s := dataset.get(MeshcopTLVType.CHANNEL)) is None: delete_issue() return + channel = cast(tlv_parser.Channel, channel_s).channel if channel == allowed_channel: delete_issue() @@ -186,20 +182,20 @@ def _warn_on_default_network_settings( insecure = False if ( - network_key := dataset.get(tlv_parser.MeshcopTLVType.NETWORKKEY) - ) is not None and bytes.fromhex(network_key) in INSECURE_NETWORK_KEYS: + network_key := dataset.get(MeshcopTLVType.NETWORKKEY) + ) is not None and network_key.data in INSECURE_NETWORK_KEYS: insecure = True if ( not insecure - and tlv_parser.MeshcopTLVType.EXTPANID in dataset - and tlv_parser.MeshcopTLVType.NETWORKNAME in dataset - and tlv_parser.MeshcopTLVType.PSKC in dataset + and MeshcopTLVType.EXTPANID in dataset + and MeshcopTLVType.NETWORKNAME in dataset + and MeshcopTLVType.PSKC in dataset ): - ext_pan_id = dataset[tlv_parser.MeshcopTLVType.EXTPANID] - network_name = dataset[tlv_parser.MeshcopTLVType.NETWORKNAME] - pskc = bytes.fromhex(dataset[tlv_parser.MeshcopTLVType.PSKC]) + ext_pan_id = dataset[MeshcopTLVType.EXTPANID] + network_name = cast(tlv_parser.NetworkName, dataset[MeshcopTLVType.NETWORKNAME]) + pskc = dataset[MeshcopTLVType.PSKC].data for passphrase in INSECURE_PASSPHRASES: - if pskc == compute_pskc(ext_pan_id, network_name, passphrase): + if pskc == compute_pskc(ext_pan_id.data, network_name.name, passphrase): insecure = True break diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 2189df363ba066..06bbca3a4abc27 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -1,7 +1,10 @@ """Websocket API for OTBR.""" +from typing import cast + import python_otbr_api from python_otbr_api import tlv_parser +from python_otbr_api.tlv_parser import MeshcopTLVType import voluptuous as vol from homeassistant.components import websocket_api @@ -78,9 +81,15 @@ async def websocket_create_network( connection.send_error(msg["id"], "set_enabled_failed", str(exc)) return + try: + await data.delete_active_dataset() + except HomeAssistantError as exc: + connection.send_error(msg["id"], "delete_active_dataset_failed", str(exc)) + return + try: await data.create_active_dataset( - python_otbr_api.OperationalDataSet( + python_otbr_api.ActiveDataSet( channel=channel, network_name="home-assistant" ) ) @@ -133,8 +142,8 @@ async def websocket_set_network( connection.send_error(msg["id"], "unknown_dataset", "Unknown dataset") return dataset = tlv_parser.parse_tlv(dataset_tlv) - if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): - thread_dataset_channel = int(channel_str, base=16) + if channel := dataset.get(MeshcopTLVType.CHANNEL): + thread_dataset_channel = cast(tlv_parser.Channel, channel).channel data: OTBRData = hass.data[DOMAIN] allowed_channel = await get_allowed_channel(hass, data.url) diff --git a/homeassistant/components/overkiz/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 d79d2fca68674c..5807ccecd74de8 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 @@ 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 @@ def __init__( ) @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 c8e4920a1139b9..0c378d088c5ddc 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 @@ 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 @@ def __init__( 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 @@ def hvac_mode(self) -> str: 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 b6835d93ebb019..7722269a48bbd2 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 ..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 @@ def current_temperature(self) -> float | None: 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 @@ async def async_set_heating_mode(self, mode: str) -> None: 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 33c1f0c4a2a263..74f7637b9975b6 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 ..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 @@ def hvac_mode(self) -> str: ) ] - 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 aaae64e0454cf8..7409b5307cf8da 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -74,7 +74,7 @@ def __init__( ) @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 fdaf0d61f1f7af..3d883738de22a9 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 @@ def __init__( ) @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/const.py b/homeassistant/components/overkiz/const.py index 0db01a2d84c2b7..102d09a76b1402 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -87,6 +87,7 @@ UIWidget.ATLANTIC_HEAT_RECOVERY_VENTILATION: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.ATLANTIC_PASS_APC_DHW: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) + UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py index 6306a11acf0531..16ea12a5d9669d 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 @@ def __init__( # 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 2d81b7bab07132..d88996c7e024a0 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.7.9"], + "requirements": ["pyoverkiz==1.9.0"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 41405780124e0c..a82284c24af2d6 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -59,9 +59,9 @@ "select": { "open_closed_pedestrian": { "state": { - "open": "Open", + "open": "[%key:common::state::open%]", "pedestrian": "Pedestrian", - "closed": "Closed" + "closed": "[%key:common::state::closed%]" } }, "memorized_simple_volume": { @@ -121,8 +121,8 @@ }, "three_way_handle_direction": { "state": { - "closed": "Closed", - "open": "Open", + "closed": "[%key:common::state::closed%]", + "open": "[%key:common::state::open%]", "tilt": "Tilt" } } diff --git a/homeassistant/components/owntracks/config_flow.py b/homeassistant/components/owntracks/config_flow.py index b82a87273883a9..13b2051ffa24dc 100644 --- a/homeassistant/components/owntracks/config_flow.py +++ b/homeassistant/components/owntracks/config_flow.py @@ -34,8 +34,8 @@ async def async_step_user(self, user_input=None): 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 a127d9d6a4a98b..2486e01223ffc5 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/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index f192dd44300866..21a878fa18743a 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -4,7 +4,6 @@ from typing import Literal from homeassistant.components.sensor import ( - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -39,7 +38,7 @@ SENSORS_SMARTMETER: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="gas_consumption", - name="Gas Consumption", + translation_key="gas_consumption", entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.GAS, @@ -47,49 +46,49 @@ ), SensorEntityDescription( key="power_consumption", - name="Power Consumption", + translation_key="power_consumption", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="energy_consumption_high", - name="Energy Consumption - High Tariff", + translation_key="energy_consumption_high", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="energy_consumption_low", - name="Energy Consumption - Low Tariff", + translation_key="energy_consumption_low", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="power_production", - name="Power Production", + translation_key="power_production", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="energy_production_high", - name="Energy Production - High Tariff", + translation_key="energy_production_high", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="energy_production_low", - name="Energy Production - Low Tariff", + translation_key="energy_production_low", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="energy_tariff_period", - name="Energy Tariff Period", + translation_key="energy_tariff_period", icon="mdi:calendar-clock", ), ) @@ -97,84 +96,84 @@ SENSORS_PHASES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="voltage_phase_l1", - name="Voltage Phase L1", + translation_key="voltage_phase_l1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="voltage_phase_l2", - name="Voltage Phase L2", + translation_key="voltage_phase_l2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="voltage_phase_l3", - name="Voltage Phase L3", + translation_key="voltage_phase_l3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="current_phase_l1", - name="Current Phase L1", + translation_key="current_phase_l1", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="current_phase_l2", - name="Current Phase L2", + translation_key="current_phase_l2", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="current_phase_l3", - name="Current Phase L3", + translation_key="current_phase_l3", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="power_consumed_phase_l1", - name="Power Consumed Phase L1", + translation_key="power_consumed_phase_l1", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="power_consumed_phase_l2", - name="Power Consumed Phase L2", + translation_key="power_consumed_phase_l2", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="power_consumed_phase_l3", - name="Power Consumed Phase L3", + translation_key="power_consumed_phase_l3", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="power_produced_phase_l1", - name="Power Produced Phase L1", + translation_key="power_produced_phase_l1", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="power_produced_phase_l2", - name="Power Produced Phase L2", + translation_key="power_produced_phase_l2", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="power_produced_phase_l3", - name="Power Produced Phase L3", + translation_key="power_produced_phase_l3", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -184,32 +183,32 @@ SENSORS_SETTINGS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="gas_consumption_price", - name="Gas Consumption Price", + translation_key="gas_consumption_price", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", ), SensorEntityDescription( key="energy_consumption_price_low", - name="Energy Consumption Price - Low", + translation_key="energy_consumption_price_low", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", ), SensorEntityDescription( key="energy_consumption_price_high", - name="Energy Consumption Price - High", + translation_key="energy_consumption_price_high", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", ), SensorEntityDescription( key="energy_production_price_low", - name="Energy Production Price - Low", + translation_key="energy_production_price_low", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", ), SensorEntityDescription( key="energy_production_price_high", - name="Energy Production Price - High", + translation_key="energy_production_price_high", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", ), @@ -218,21 +217,21 @@ SENSORS_WATERMETER: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="consumption_day", - name="Consumption Day", + translation_key="consumption_day", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.WATER, ), SensorEntityDescription( key="consumption_total", - name="Consumption Total", + translation_key="consumption_total", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.WATER, ), SensorEntityDescription( key="pulse_count", - name="Pulse Count", + translation_key="pulse_count", ), ) @@ -248,7 +247,6 @@ async def async_setup_entry( coordinator=coordinator, description=description, name="SmartMeter", - service_key="smartmeter", service=SERVICE_SMARTMETER, ) for description in SENSORS_SMARTMETER @@ -258,7 +256,6 @@ async def async_setup_entry( coordinator=coordinator, description=description, name="Phases", - service_key="phases", service=SERVICE_PHASES, ) for description in SENSORS_PHASES @@ -268,7 +265,6 @@ async def async_setup_entry( coordinator=coordinator, description=description, name="Settings", - service_key="settings", service=SERVICE_SETTINGS, ) for description in SENSORS_SETTINGS @@ -279,7 +275,6 @@ async def async_setup_entry( coordinator=coordinator, description=description, name="WaterMeter", - service_key="watermeter", service=SERVICE_WATERMETER, ) for description in SENSORS_WATERMETER @@ -292,30 +287,28 @@ class P1MonitorSensorEntity( ): """Defines an P1 Monitor sensor.""" + _attr_has_entity_name = True + def __init__( self, *, coordinator: P1MonitorDataUpdateCoordinator, description: SensorEntityDescription, - service_key: Literal["smartmeter", "watermeter", "phases", "settings"], name: str, - service: str, + service: Literal["smartmeter", "watermeter", "phases", "settings"], ) -> None: """Initialize P1 Monitor sensor.""" super().__init__(coordinator=coordinator) - self._service_key = service_key + self._service_key = service - self.entity_id = f"{SENSOR_DOMAIN}.{service}_{description.key}" self.entity_description = description self._attr_unique_id = ( - f"{coordinator.config_entry.entry_id}_{service_key}_{description.key}" + f"{coordinator.config_entry.entry_id}_{service}_{description.key}" ) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={ - (DOMAIN, f"{coordinator.config_entry.entry_id}_{service_key}") - }, + identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{service}")}, configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}", manufacturer="P1 Monitor", name=name, diff --git a/homeassistant/components/p1_monitor/strings.json b/homeassistant/components/p1_monitor/strings.json index 0c745554e9dbc8..781ca109235196 100644 --- a/homeassistant/components/p1_monitor/strings.json +++ b/homeassistant/components/p1_monitor/strings.json @@ -14,5 +14,93 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "sensor": { + "gas_consumption": { + "name": "Gas consumption" + }, + "power_consumption": { + "name": "Power consumption" + }, + "energy_consumption_high": { + "name": "Energy consumption - High tariff" + }, + "energy_consumption_low": { + "name": "Energy consumption - Low tariff" + }, + "power_production": { + "name": "Power production" + }, + "energy_production_high": { + "name": "Energy production - High tariff" + }, + "energy_production_low": { + "name": "Energy production - Low tariff" + }, + "energy_tariff_period": { + "name": "Energy tariff period" + }, + "voltage_phase_l1": { + "name": "Voltage phase L1" + }, + "voltage_phase_l2": { + "name": "Voltage phase L2" + }, + "voltage_phase_l3": { + "name": "Voltage phase L3" + }, + "current_phase_l1": { + "name": "Current phase L1" + }, + "current_phase_l2": { + "name": "Current phase L2" + }, + "current_phase_l3": { + "name": "Current phase L3" + }, + "power_consumed_phase_l1": { + "name": "Power consumed phase L1" + }, + "power_consumed_phase_l2": { + "name": "Power consumed phase L2" + }, + "power_consumed_phase_l3": { + "name": "Power consumed phase L3" + }, + "power_produced_phase_l1": { + "name": "Power produced phase L1" + }, + "power_produced_phase_l2": { + "name": "Power produced phase L2" + }, + "power_produced_phase_l3": { + "name": "Power produced phase L3" + }, + "gas_consumption_price": { + "name": "Gas consumption price" + }, + "energy_consumption_price_low": { + "name": "Energy consumption price - Low" + }, + "energy_consumption_price_high": { + "name": "Energy consumption price - High" + }, + "energy_production_price_low": { + "name": "Energy production price - Low" + }, + "energy_production_price_high": { + "name": "Energy production price - High" + }, + "consumption_day": { + "name": "Consumption day" + }, + "consumption_total": { + "name": "Consumption total" + }, + "pulse_count": { + "name": "Pulse count" + } + } } } diff --git a/homeassistant/components/panasonic_viera/manifest.json b/homeassistant/components/panasonic_viera/manifest.json index d626ae2bf9e026..2afa6599cb205c 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 36b496ddde218a..581720c2730346 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -1,44 +1,77 @@ """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 +from typing import Any, Final, TypedDict import voluptuous as vol +from homeassistant.backports.enum import StrEnum from homeassistant.components import websocket_api -from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import Context, HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv, singleton +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util import slugify import homeassistant.util.dt as dt_util - -ATTR_CREATED_AT = "created_at" -ATTR_MESSAGE = "message" -ATTR_NOTIFICATION_ID = "notification_id" -ATTR_TITLE = "title" -ATTR_STATUS = "status" +from homeassistant.util.uuid import random_uuid_hex DOMAIN = "persistent_notification" -ENTITY_ID_FORMAT = DOMAIN + ".{}" +ATTR_CREATED_AT: Final = "created_at" +ATTR_MESSAGE: Final = "message" +ATTR_NOTIFICATION_ID: Final = "notification_id" +ATTR_TITLE: Final = "title" +ATTR_STATUS: Final = "status" + +# Remove EVENT_PERSISTENT_NOTIFICATIONS_UPDATED in Home Assistant 2023.9 EVENT_PERSISTENT_NOTIFICATIONS_UPDATED = "persistent_notifications_updated" + +class Notification(TypedDict): + """Persistent notification.""" + + created_at: datetime + message: str + notification_id: str + title: str | None + + +class UpdateType(StrEnum): + """Persistent notification update type.""" + + CURRENT = "current" + ADDED = "added" + REMOVED = "removed" + UPDATED = "updated" + + +SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED = "persistent_notifications_updated" + SCHEMA_SERVICE_NOTIFICATION = vol.Schema( {vol.Required(ATTR_NOTIFICATION_ID): cv.string} ) -DEFAULT_OBJECT_ID = "notification" _LOGGER = logging.getLogger(__name__) -STATE = "notifying" -STATUS_UNREAD = "unread" -STATUS_READ = "read" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +@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 @@ -65,64 +98,64 @@ def async_create( message: str, title: str | None = None, notification_id: str | None = None, - *, - context: Context | None = None, ) -> None: """Generate a notification.""" - if (notifications := hass.data.get(DOMAIN)) is None: - notifications = hass.data[DOMAIN] = {} - - if notification_id is not None: - entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id)) - else: - entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, DEFAULT_OBJECT_ID, hass=hass - ) - notification_id = entity_id.split(".")[1] - - attr: dict[str, str] = {ATTR_MESSAGE: message} - if title is not None: - attr[ATTR_TITLE] = title - attr[ATTR_FRIENDLY_NAME] = title - - hass.states.async_set(entity_id, STATE, attr, context=context) - - # Store notification and fire event - # This will eventually replace state machine storage - notifications[entity_id] = { + notifications = _async_get_or_create_notifications(hass) + if notification_id is None: + notification_id = random_uuid_hex() + notifications[notification_id] = { ATTR_MESSAGE: message, ATTR_NOTIFICATION_ID: notification_id, - ATTR_STATUS: STATUS_UNREAD, ATTR_TITLE: title, ATTR_CREATED_AT: dt_util.utcnow(), } - hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, context=context) + async_dispatcher_send( + hass, + SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, + UpdateType.ADDED, + {notification_id: notifications[notification_id]}, + ) @callback -@bind_hass -def async_dismiss( - hass: HomeAssistant, notification_id: str, *, context: Context | None = None -) -> None: - """Remove a notification.""" - if (notifications := hass.data.get(DOMAIN)) is None: - notifications = hass.data[DOMAIN] = {} +@singleton.singleton(DOMAIN) +def _async_get_or_create_notifications(hass: HomeAssistant) -> dict[str, Notification]: + """Get or create notifications data.""" + return {} - entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id)) - if entity_id not in notifications: +@callback +@bind_hass +def async_dismiss(hass: HomeAssistant, notification_id: str) -> None: + """Remove a notification.""" + notifications = _async_get_or_create_notifications(hass) + if not (notification := notifications.pop(notification_id, None)): return + async_dispatcher_send( + hass, + SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, + UpdateType.REMOVED, + {notification_id: notification}, + ) - hass.states.async_remove(entity_id, context) - del notifications[entity_id] - hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED) +@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.""" - notifications = hass.data.setdefault(DOMAIN, {}) @callback def create_service(call: ServiceCall) -> None: @@ -132,34 +165,17 @@ def create_service(call: ServiceCall) -> None: call.data[ATTR_MESSAGE], call.data.get(ATTR_TITLE), call.data.get(ATTR_NOTIFICATION_ID), - context=call.context, ) @callback def dismiss_service(call: ServiceCall) -> None: """Handle the dismiss notification service call.""" - async_dismiss(hass, call.data[ATTR_NOTIFICATION_ID], context=call.context) + async_dismiss(hass, call.data[ATTR_NOTIFICATION_ID]) @callback - def mark_read_service(call: ServiceCall) -> None: - """Handle the mark_read notification service call.""" - notification_id = call.data.get(ATTR_NOTIFICATION_ID) - entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id)) - - if entity_id not in notifications: - _LOGGER.error( - ( - "Marking persistent_notification read failed: " - "Notification ID %s not found" - ), - notification_id, - ) - return - - notifications[entity_id][ATTR_STATUS] = STATUS_READ - hass.bus.async_fire( - EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, context=call.context - ) + def dismiss_all_service(call: ServiceCall) -> None: + """Handle the dismiss all notification service call.""" + async_dismiss_all(hass) hass.services.async_register( DOMAIN, @@ -178,11 +194,10 @@ def mark_read_service(call: ServiceCall) -> None: DOMAIN, "dismiss", dismiss_service, SCHEMA_SERVICE_NOTIFICATION ) - hass.services.async_register( - DOMAIN, "mark_read", mark_read_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) return True @@ -197,19 +212,36 @@ def websocket_get_notifications( """Return a list of persistent_notifications.""" connection.send_message( websocket_api.result_message( - msg["id"], - [ - { - key: data[key] - for key in ( - ATTR_NOTIFICATION_ID, - ATTR_MESSAGE, - ATTR_STATUS, - ATTR_TITLE, - ATTR_CREATED_AT, - ) - } - for data in hass.data[DOMAIN].values() - ], + msg["id"], list(_async_get_or_create_notifications(hass).values()) ) ) + + +@callback +@websocket_api.websocket_command( + {vol.Required("type"): "persistent_notification/subscribe"} +) +def websocket_subscribe_notifications( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: Mapping[str, Any], +) -> None: + """Return a list of persistent_notifications.""" + notifications = _async_get_or_create_notifications(hass) + msg_id = msg["id"] + + @callback + def _async_send_notification_update( + update_type: UpdateType, notifications: dict[str, Notification] + ) -> None: + connection.send_message( + websocket_api.event_message( + msg["id"], {"type": update_type, "notifications": notifications} + ) + ) + + connection.subscriptions[msg_id] = async_dispatcher_connect( + hass, SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, _async_send_notification_update + ) + connection.send_result(msg_id) + _async_send_notification_update(UpdateType.CURRENT, notifications) diff --git a/homeassistant/components/persistent_notification/services.yaml b/homeassistant/components/persistent_notification/services.yaml index 5695a3c3b82071..046ea237560233 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: @@ -34,14 +34,6 @@ dismiss: selector: text: -mark_read: - name: Mark read - description: Mark a notification read. - fields: - notification_id: - name: Notification ID - description: Target ID of the notification, which should be mark read. - required: true - example: 1234 - selector: - text: +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 00000000000000..4c9c2bd9204f99 --- /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/person/__init__.py b/homeassistant/components/person/__init__.py index fe6925b48478eb..ea325380e111b0 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -47,6 +47,9 @@ ) from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.integration_platform import ( + async_process_integration_platform_for_component, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -330,6 +333,9 @@ async def filter_yaml_data(hass: HomeAssistant, persons: list[dict]) -> list[dic async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the person component.""" + # Process integration platforms right away since + # we will create entities before firing EVENT_COMPONENT_LOADED + await async_process_integration_platform_for_component(hass, DOMAIN) entity_component = EntityComponent[Person](_LOGGER, DOMAIN, hass) id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index 705acefa60f001..46b1340a28dc26 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 c6ca70bdc847db..bdd55bb2dad9d5 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/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 7ec1bf40c665ef..5d1419db8b2e01 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -8,7 +8,6 @@ from hole import Hole from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -39,42 +38,6 @@ class PiHoleBinarySensorEntityDescription( BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = ( - PiHoleBinarySensorEntityDescription( - # Deprecated, scheduled to be removed in 2022.6 - key="core_update_available", - name="Core Update Available", - entity_registry_enabled_default=False, - device_class=BinarySensorDeviceClass.UPDATE, - extra_value=lambda api: { - "current_version": api.versions["core_current"], - "latest_version": api.versions["core_latest"], - }, - state_value=lambda api: bool(api.versions["core_update"]), - ), - PiHoleBinarySensorEntityDescription( - # Deprecated, scheduled to be removed in 2022.6 - key="web_update_available", - name="Web Update Available", - entity_registry_enabled_default=False, - device_class=BinarySensorDeviceClass.UPDATE, - extra_value=lambda api: { - "current_version": api.versions["web_current"], - "latest_version": api.versions["web_latest"], - }, - state_value=lambda api: bool(api.versions["web_update"]), - ), - PiHoleBinarySensorEntityDescription( - # Deprecated, scheduled to be removed in 2022.6 - key="ftl_update_available", - name="FTL Update Available", - entity_registry_enabled_default=False, - device_class=BinarySensorDeviceClass.UPDATE, - extra_value=lambda api: { - "current_version": api.versions["FTL_current"], - "latest_version": api.versions["FTL_latest"], - }, - state_value=lambda api: bool(api.versions["FTL_update"]), - ), PiHoleBinarySensorEntityDescription( key="status", translation_key="status", diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json index ff91b5259b2401..f0e0d93231c1de 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/picotts/tts.py b/homeassistant/components/picotts/tts.py index f9c93edb4fc032..23e94b5206d10f 100644 --- a/homeassistant/components/picotts/tts.py +++ b/homeassistant/components/picotts/tts.py @@ -46,7 +46,7 @@ def supported_languages(self): """Return list of supported languages.""" return SUPPORT_LANGUAGES - def get_tts_audio(self, message, language, options=None): + def get_tts_audio(self, message, language, options): """Load TTS using pico2wave.""" with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmpf: fname = tmpf.name diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 2236b8dc3374ec..3ff36f2e283feb 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -6,6 +6,7 @@ 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,9 +14,11 @@ _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the template integration.""" + """Set up the ping integration.""" await async_setup_reload_service(hass, DOMAIN, PLATFORMS) hass.data[DOMAIN] = { PING_PRIVS: await hass.async_add_executor_job(_can_use_icmp_lib_with_privilege), diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index c8b4ce5a204396..786012d466cb92 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -6,14 +6,14 @@ 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 @@ 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 @@ def extra_state_attributes(self) -> dict[str, Any] | None: 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 @@ async def async_added_to_hass(self) -> None: 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 @@ def __init__( 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 @@ async def async_ping(self): 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 @@ async def async_ping(self): 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 @@ async def async_ping(self): ) 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 68111df89eae5f..f546bd6bacc966 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 @@ 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 @@ 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 @@ def __init__(self, ip_address, dev_id, hass, config, privileged): 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_update(now): 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_update(now): ) ) - 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 59ae14b8ca9e95..4ce5a359dcdb13 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -18,7 +18,11 @@ 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 @@ _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 4c4ed8d8d0a7a6..bc0c54c49bf195 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 be572679605981..6585c011c2d1a5 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -4,7 +4,7 @@ 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 @@ def device_info(self) -> DeviceInfo | None: 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 9684c79792a08d..1c3c944c9c4eb6 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -199,7 +199,8 @@ def _update_plexdirect_hostname(): 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 62576471448d33..10d005d1043f06 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 f7941d1f02d9b1..36626c2324e46a 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/select.py b/homeassistant/components/plugwise/select.py index c45d47004b8f62..b78fd689cb932e 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any -from plugwise import Smile +from plugwise import DeviceData, Smile from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -23,8 +23,8 @@ class PlugwiseSelectDescriptionMixin: """Mixin values for Plugwise Select entities.""" command: Callable[[Smile, str, str], Awaitable[Any]] - current_option_key: str - options_key: str + value_fn: Callable[[DeviceData], str] + options_fn: Callable[[DeviceData], list[str]] @dataclass @@ -40,8 +40,8 @@ class PlugwiseSelectEntityDescription( translation_key="select_schedule", icon="mdi:calendar-clock", command=lambda api, loc, opt: api.set_schedule_state(loc, opt, STATE_ON), - current_option_key="selected_schedule", - options_key="available_schedules", + value_fn=lambda data: data["selected_schedule"], + options_fn=lambda data: data.get("available_schedules"), ), PlugwiseSelectEntityDescription( key="select_regulation_mode", @@ -49,8 +49,8 @@ class PlugwiseSelectEntityDescription( icon="mdi:hvac", entity_category=EntityCategory.CONFIG, command=lambda api, loc, opt: api.set_regulation_mode(opt), - current_option_key="regulation_mode", - options_key="regulation_modes", + value_fn=lambda data: data["regulation_mode"], + options_fn=lambda data: data.get("regulation_modes"), ), PlugwiseSelectEntityDescription( key="select_dhw_mode", @@ -58,8 +58,8 @@ class PlugwiseSelectEntityDescription( icon="mdi:shower", entity_category=EntityCategory.CONFIG, command=lambda api, loc, opt: api.set_dhw_mode(opt), - current_option_key="dhw_mode", - options_key="dhw_modes", + value_fn=lambda data: data["dhw_mode"], + options_fn=lambda data: data.get("dhw_modes"), ), ) @@ -77,10 +77,7 @@ async def async_setup_entry( entities: list[PlugwiseSelectEntity] = [] for device_id, device in coordinator.data.devices.items(): for description in SELECT_TYPES: - if ( - description.options_key in device - and len(device[description.options_key]) > 1 - ): + if (options := description.options_fn(device)) and len(options) > 1: entities.append( PlugwiseSelectEntity(coordinator, device_id, description) ) @@ -107,12 +104,12 @@ def __init__( @property def current_option(self) -> str: """Return the selected entity option to represent the entity state.""" - return self.device[self.entity_description.current_option_key] + return self.entity_description.value_fn(self.device) @property def options(self) -> list[str]: """Return the selectable entity options.""" - return self.device[self.entity_description.options_key] + return self.entity_description.options_fn(self.device) async def async_select_option(self, option: str) -> None: """Change to the selected entity option.""" diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index d708fe741c2ee0..7a504a0db84b08 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -343,6 +343,7 @@ 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/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index 21469c2c5a5c1f..bfffb934407892 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -67,7 +67,7 @@ def __init__(self, point_client: MinutPointClient, home_id: str) -> None: self._attr_device_info = DeviceInfo( identifiers={(POINT_DOMAIN, home_id)}, manufacturer="Minut", - name=self._home["name"], + name=self._attr_name, ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/progettihwsw/__init__.py b/homeassistant/components/progettihwsw/__init__.py index bce25c07b17568..d1d27b78769a26 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 058d76cdf05d5d..b2019389fe3e42 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 e22abd6dd4a797..6cad66e136040f 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 956848a659426e..dc7f838bcbc1ad 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -3,8 +3,8 @@ 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 dbbe8a1c9fcf3f..8ec332c1daf50a 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 0567c551d981bb..a45204351619db 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 7ebaa6e53dd610..88a2a6c9b0f91f 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 4f93fd3407e675..1ee4274e5bb021 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -47,6 +47,7 @@ class 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 0f5c57c5e4cb87..1c87a275126594 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -53,6 +53,8 @@ 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 23438dd80c47a4..42bc15cf0ca1be 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -192,12 +192,10 @@ def _parse_status(self) -> None: 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/pulseaudio_loopback/manifest.json b/homeassistant/components/pulseaudio_loopback/manifest.json index f04538c01bbdfa..a67dc614c50b2f 100644 --- a/homeassistant/components/pulseaudio_loopback/manifest.json +++ b/homeassistant/components/pulseaudio_loopback/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/pulseaudio_loopback", "iot_class": "local_polling", - "requirements": ["pulsectl==20.2.4"] + "requirements": ["pulsectl==23.5.2"] } diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py index 7d584c7c1a8af2..9f67665d66c5d5 100644 --- a/homeassistant/components/pure_energie/sensor.py +++ b/homeassistant/components/pure_energie/sensor.py @@ -39,7 +39,7 @@ class PureEnergieSensorEntityDescription( SENSORS: tuple[PureEnergieSensorEntityDescription, ...] = ( PureEnergieSensorEntityDescription( key="power_flow", - name="Power Flow", + translation_key="power_flow", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -47,7 +47,7 @@ class PureEnergieSensorEntityDescription( ), PureEnergieSensorEntityDescription( key="energy_consumption_total", - name="Energy Consumption", + translation_key="energy_consumption_total", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -55,7 +55,7 @@ class PureEnergieSensorEntityDescription( ), PureEnergieSensorEntityDescription( key="energy_production_total", - name="Energy Production", + translation_key="energy_production_total", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -83,6 +83,7 @@ class PureEnergieSensorEntity( ): """Defines an Pure Energie sensor.""" + _attr_has_entity_name = True entity_description: PureEnergieSensorEntityDescription def __init__( diff --git a/homeassistant/components/pure_energie/strings.json b/homeassistant/components/pure_energie/strings.json index a76b4a001e611c..3545f62d667650 100644 --- a/homeassistant/components/pure_energie/strings.json +++ b/homeassistant/components/pure_energie/strings.json @@ -22,5 +22,18 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "sensor": { + "power_flow": { + "name": "Power flow" + }, + "energy_consumption_total": { + "name": "Energy consumption" + }, + "energy_production_total": { + "name": "Energy production" + } + } } } diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index c90f4c9031ce2f..f5c4090dc87077 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -1,6 +1,9 @@ """The PurpleAir integration.""" from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from aiopurpleair.models.sensors import SensorModel from homeassistant.config_entries import ConfigEntry @@ -9,7 +12,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import CONF_SHOW_ON_MAP, DOMAIN from .coordinator import PurpleAirDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -60,16 +63,30 @@ def __init__( self._attr_device_info = DeviceInfo( configuration_url=self.coordinator.async_get_map_url(sensor_index), hw_version=self.sensor_data.hardware, - identifiers={(DOMAIN, str(self._sensor_index))}, + identifiers={(DOMAIN, str(sensor_index))}, manufacturer="PurpleAir, Inc.", model=self.sensor_data.model, name=self.sensor_data.name, sw_version=self.sensor_data.firmware_version, ) - self._attr_extra_state_attributes = { - ATTR_LATITUDE: self.sensor_data.latitude, - ATTR_LONGITUDE: self.sensor_data.longitude, - } + self._entry = entry + + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return entity specific state attributes.""" + attrs = {} + + # Displaying the geography on the map relies upon putting the latitude/longitude + # in the entity attributes with "latitude" and "longitude" as the keys. + # Conversely, we can hide the location on the map by using other keys, like + # "lati" and "long": + if self._entry.options.get(CONF_SHOW_ON_MAP): + attrs[ATTR_LATITUDE] = self.sensor_data.latitude + attrs[ATTR_LONGITUDE] = self.sensor_data.longitude + else: + attrs["lati"] = self.sensor_data.latitude + attrs["long"] = self.sensor_data.longitude + return attrs @property def sensor_data(self) -> SensorModel: diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 604bcb28c0e3e9..c7988c02e6ae0a 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -31,7 +31,7 @@ SelectSelectorMode, ) -from .const import CONF_SENSOR_INDICES, DOMAIN, LOGGER +from .const import CONF_SENSOR_INDICES, CONF_SHOW_ON_MAP, DOMAIN, LOGGER CONF_DISTANCE = "distance" CONF_NEARBY_SENSOR_OPTIONS = "nearby_sensor_options" @@ -318,6 +318,22 @@ def __init__(self, config_entry: ConfigEntry) -> None: self._flow_data: dict[str, Any] = {} self.config_entry = config_entry + @property + def settings_schema(self) -> vol.Schema: + """Return the settings schema.""" + return vol.Schema( + { + vol.Optional( + CONF_SHOW_ON_MAP, + description={ + "suggested_value": self.config_entry.options.get( + CONF_SHOW_ON_MAP + ) + }, + ): bool + } + ) + async def async_step_add_sensor( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -352,7 +368,7 @@ async def async_step_add_sensor( async def async_step_choose_sensor( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Handle the selection of a sensor.""" + """Choose a sensor.""" if user_input is None: options = self._flow_data.pop(CONF_NEARBY_SENSOR_OPTIONS) return self.async_show_form( @@ -375,13 +391,13 @@ async def async_step_init( """Manage the options.""" return self.async_show_menu( step_id="init", - menu_options=["add_sensor", "remove_sensor"], + menu_options=["add_sensor", "remove_sensor", "settings"], ) async def async_step_remove_sensor( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Add a sensor.""" + """Remove a sensor.""" if user_input is None: return self.async_show_form( step_id="remove_sensor", @@ -437,3 +453,15 @@ def async_device_entity_state_changed(_: Event) -> None: options[CONF_SENSOR_INDICES].remove(removed_sensor_index) return self.async_create_entry(data=options) + + async def async_step_settings( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage settings.""" + if user_input is None: + return self.async_show_form( + step_id="settings", data_schema=self.settings_schema + ) + + options = deepcopy({**self.config_entry.options}) + return self.async_create_entry(data=options | user_input) diff --git a/homeassistant/components/purpleair/const.py b/homeassistant/components/purpleair/const.py index 60f51a9e7ddf9c..e3ea7807a21f00 100644 --- a/homeassistant/components/purpleair/const.py +++ b/homeassistant/components/purpleair/const.py @@ -7,3 +7,4 @@ CONF_READ_KEY = "read_key" CONF_SENSOR_INDICES = "sensor_indices" +CONF_SHOW_ON_MAP = "show_on_map" diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index 23370f8a20c2d0..fffceffa343ce4 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -50,7 +50,6 @@ class PurpleAirSensorEntityDescription( SENSOR_DESCRIPTIONS = [ PurpleAirSensorEntityDescription( key="humidity", - name="Humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -58,7 +57,7 @@ class PurpleAirSensorEntityDescription( ), PurpleAirSensorEntityDescription( key="pm0.3_count_concentration", - name="PM0.3 count concentration", + translation_key="pm0_3_count_concentration", entity_registry_enabled_default=False, icon="mdi:blur", native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, @@ -67,7 +66,7 @@ class PurpleAirSensorEntityDescription( ), PurpleAirSensorEntityDescription( key="pm0.5_count_concentration", - name="PM0.5 count concentration", + translation_key="pm0_5_count_concentration", entity_registry_enabled_default=False, icon="mdi:blur", native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, @@ -76,7 +75,7 @@ class PurpleAirSensorEntityDescription( ), PurpleAirSensorEntityDescription( key="pm1.0_count_concentration", - name="PM1.0 count concentration", + translation_key="pm1_0_count_concentration", entity_registry_enabled_default=False, icon="mdi:blur", native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, @@ -85,7 +84,6 @@ class PurpleAirSensorEntityDescription( ), PurpleAirSensorEntityDescription( key="pm1.0_mass_concentration", - name="PM1.0 mass concentration", device_class=SensorDeviceClass.PM1, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -93,7 +91,7 @@ class PurpleAirSensorEntityDescription( ), PurpleAirSensorEntityDescription( key="pm10.0_count_concentration", - name="PM10.0 count concentration", + translation_key="pm10_0_count_concentration", entity_registry_enabled_default=False, icon="mdi:blur", native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, @@ -102,7 +100,6 @@ class PurpleAirSensorEntityDescription( ), PurpleAirSensorEntityDescription( key="pm10.0_mass_concentration", - name="PM10.0 mass concentration", device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -110,7 +107,7 @@ class PurpleAirSensorEntityDescription( ), PurpleAirSensorEntityDescription( key="pm2.5_count_concentration", - name="PM2.5 count concentration", + translation_key="pm2_5_count_concentration", entity_registry_enabled_default=False, icon="mdi:blur", native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, @@ -119,7 +116,6 @@ class PurpleAirSensorEntityDescription( ), PurpleAirSensorEntityDescription( key="pm2.5_mass_concentration", - name="PM2.5 mass concentration", device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -127,7 +123,7 @@ class PurpleAirSensorEntityDescription( ), PurpleAirSensorEntityDescription( key="pm5.0_count_concentration", - name="PM5.0 count concentration", + translation_key="pm5_0_count_concentration", entity_registry_enabled_default=False, icon="mdi:blur", native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, @@ -136,7 +132,6 @@ class PurpleAirSensorEntityDescription( ), PurpleAirSensorEntityDescription( key="pressure", - name="Pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, @@ -144,7 +139,7 @@ class PurpleAirSensorEntityDescription( ), PurpleAirSensorEntityDescription( key="rssi", - name="RSSI", + translation_key="rssi", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -154,7 +149,6 @@ class PurpleAirSensorEntityDescription( ), PurpleAirSensorEntityDescription( key="temperature", - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, state_class=SensorStateClass.MEASUREMENT, @@ -162,7 +156,7 @@ class PurpleAirSensorEntityDescription( ), PurpleAirSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="uptime", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, device_class=SensorDeviceClass.DURATION, @@ -171,8 +165,9 @@ class PurpleAirSensorEntityDescription( value_fn=lambda sensor: sensor.uptime, ), PurpleAirSensorEntityDescription( + # This sensor is an air quality index for VOCs. More info at https://github.com/home-assistant/core/pull/84896 key="voc", - name="VOC", + translation_key="voc_aqi", device_class=SensorDeviceClass.AQI, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda sensor: sensor.voc, diff --git a/homeassistant/components/purpleair/strings.json b/homeassistant/components/purpleair/strings.json index 3d18fef3906468..5e7c61c182053c 100644 --- a/homeassistant/components/purpleair/strings.json +++ b/homeassistant/components/purpleair/strings.json @@ -79,7 +79,8 @@ "init": { "menu_options": { "add_sensor": "Add sensor", - "remove_sensor": "Remove sensor" + "remove_sensor": "Remove sensor", + "settings": "Settings" } }, "remove_sensor": { @@ -90,6 +91,12 @@ "data_description": { "sensor_device_id": "The sensor to remove" } + }, + "settings": { + "title": "Settings", + "data": { + "show_on_map": "Show configured sensor locations on the map" + } } }, "error": { @@ -100,5 +107,36 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "pm0_3_count_concentration": { + "name": "PM0.3 count concentration" + }, + "pm0_5_count_concentration": { + "name": "PM0.5 count concentration" + }, + "pm1_0_count_concentration": { + "name": "PM1.0 count concentration" + }, + "pm10_0_count_concentration": { + "name": "PM10.0 count concentration" + }, + "pm2_5_count_concentration": { + "name": "PM2.5 count concentration" + }, + "pm5_0_count_concentration": { + "name": "PM5.0 count concentration" + }, + "rssi": { + "name": "RSSI" + }, + "uptime": { + "name": "Uptime" + }, + "voc_aqi": { + "name": "Volatile organic compounds air quality index" + } + } } } diff --git a/homeassistant/components/pushbullet/__init__.py b/homeassistant/components/pushbullet/__init__.py index bed0e94ccd930d..14d90d4ca0b450 100644 --- a/homeassistant/components/pushbullet/__init__.py +++ b/homeassistant/components/pushbullet/__init__.py @@ -14,7 +14,7 @@ ) 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 @@ _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/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index b61469f6b2a811..84d2998e992a70 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -16,50 +16,50 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="application_name", - name="Application name", + translation_key="application_name", entity_registry_enabled_default=False, ), SensorEntityDescription( key="body", - name="Body", + translation_key="body", ), SensorEntityDescription( key="notification_id", - name="Notification ID", + translation_key="notification_id", entity_registry_enabled_default=False, ), SensorEntityDescription( key="notification_tag", - name="Notification tag", + translation_key="notification_tag", entity_registry_enabled_default=False, ), SensorEntityDescription( key="package_name", - name="Package name", + translation_key="package_name", entity_registry_enabled_default=False, ), SensorEntityDescription( key="receiver_email", - name="Receiver email", + translation_key="receiver_email", entity_registry_enabled_default=False, ), SensorEntityDescription( key="sender_email", - name="Sender email", + translation_key="sender_email", entity_registry_enabled_default=False, ), SensorEntityDescription( key="source_device_iden", - name="Sender device ID", + translation_key="source_device_identifier", entity_registry_enabled_default=False, ), SensorEntityDescription( key="title", - name="Title", + translation_key="title", ), SensorEntityDescription( key="type", - name="Type", + translation_key="type", entity_registry_enabled_default=False, ), ) diff --git a/homeassistant/components/pushbullet/strings.json b/homeassistant/components/pushbullet/strings.json index a6571ae7bf0f24..94d4202ea8c0d7 100644 --- a/homeassistant/components/pushbullet/strings.json +++ b/homeassistant/components/pushbullet/strings.json @@ -15,5 +15,39 @@ } } } + }, + "entity": { + "sensor": { + "application_name": { + "name": "Application name" + }, + "body": { + "name": "Body" + }, + "notification_id": { + "name": "Notification ID" + }, + "notification_tag": { + "name": "Notification tag" + }, + "package_name": { + "name": "Package name" + }, + "receiver_email": { + "name": "Receiver email" + }, + "sender_email": { + "name": "Sender email" + }, + "source_device_identifier": { + "name": "Sender device ID" + }, + "title": { + "name": "Title" + }, + "type": { + "name": "Type" + } + } } } diff --git a/homeassistant/components/pushover/__init__.py b/homeassistant/components/pushover/__init__.py index 551e374fbb644b..c3b15b7c130669 100644 --- a/homeassistant/components/pushover/__init__.py +++ b/homeassistant/components/pushover/__init__.py @@ -7,13 +7,15 @@ 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 d086321c0885ce..3b538f756e0591 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/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index 700757c6d58d38..b681678b098e62 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -45,7 +45,7 @@ class PVOutputSensorEntityDescription( SENSORS: tuple[PVOutputSensorEntityDescription, ...] = ( PVOutputSensorEntityDescription( key="energy_consumption", - name="Energy consumed", + translation_key="energy_consumption", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -53,7 +53,7 @@ class PVOutputSensorEntityDescription( ), PVOutputSensorEntityDescription( key="energy_generation", - name="Energy generated", + translation_key="energy_generation", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -61,7 +61,7 @@ class PVOutputSensorEntityDescription( ), PVOutputSensorEntityDescription( key="normalized_output", - name="Efficiency", + translation_key="efficiency", native_unit_of_measurement=( f"{UnitOfEnergy.KILO_WATT_HOUR}/{UnitOfPower.KILO_WATT}" ), @@ -70,7 +70,7 @@ class PVOutputSensorEntityDescription( ), PVOutputSensorEntityDescription( key="power_consumption", - name="Power consumed", + translation_key="power_consumption", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -78,7 +78,7 @@ class PVOutputSensorEntityDescription( ), PVOutputSensorEntityDescription( key="power_generation", - name="Power generated", + translation_key="power_generation", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -86,7 +86,6 @@ class PVOutputSensorEntityDescription( ), PVOutputSensorEntityDescription( key="temperature", - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -94,7 +93,6 @@ class PVOutputSensorEntityDescription( ), PVOutputSensorEntityDescription( key="voltage", - name="Voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/pvoutput/strings.json b/homeassistant/components/pvoutput/strings.json index 12f30b773d5dcf..06d9897105353e 100644 --- a/homeassistant/components/pvoutput/strings.json +++ b/homeassistant/components/pvoutput/strings.json @@ -23,5 +23,24 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "sensor": { + "energy_consumption": { + "name": "Energy consumed" + }, + "energy_generation": { + "name": "Energy generated" + }, + "efficiency": { + "name": "Efficiency" + }, + "power_consumption": { + "name": "Power consumed" + }, + "power_generation": { + "name": "Power generated" + } + } } } diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index bbb262ac7db037..10751d28c06658 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -221,7 +221,7 @@ def protected_getattr(obj, name, default=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 eb6cfe236e069b..63aa2f2f9162bb 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 5154ae155ec972..53e8d4b96607c4 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 c56bb8102b8619..e2c1526e4f8a56 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 6b758daab0ac03..15a634cf7a9872 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 e21371d96af4de..5e7d9948309253 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 534096628dfd00..2491e69803f2da 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 00000000000000..689fe30a8705ad --- /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 00000000000000..d1bbb64cc47c95 --- /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 00000000000000..b868a931ebd294 --- /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 95ab9264dfca08..608d57a7cc4c67 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 66fc56317182ab..6d214b63e2e184 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.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_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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ ) -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.""" + + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.12.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) - # QNAP is not available - if not api.data: - raise PlatformNotReady + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + ) - monitored_conditions = config[CONF_MONITORED_CONDITIONS] + +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 + 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 @@ def native_value(self): @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 @@ def native_value(self): @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 @@ def native_value(self): @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 @@ def name(self): @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 @@ def native_value(self): @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 00000000000000..36946b81c0ca18 --- /dev/null +++ b/homeassistant/components/qnap/strings.json @@ -0,0 +1,29 @@ +{ + "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" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The QNAP YAML configuration is being removed", + "description": "Configuring QNAP using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the QNAP YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/qnap_qsw/update.py b/homeassistant/components/qnap_qsw/update.py index 70bd4afa880a3f..38a963818d4945 100644 --- a/homeassistant/components/qnap_qsw/update.py +++ b/homeassistant/components/qnap_qsw/update.py @@ -1,7 +1,7 @@ """Support for the QNAP QSW update.""" from __future__ import annotations -from typing import Final +from typing import Any, Final from aioqsw.const import ( QSD_DESCRIPTION, @@ -16,6 +16,7 @@ UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, + UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory @@ -51,6 +52,7 @@ async def async_setup_entry( class QswUpdate(QswFirmwareEntity, UpdateEntity): """Define a QNAP QSW update.""" + _attr_supported_features = UpdateEntityFeature.INSTALL entity_description: UpdateEntityDescription def __init__( @@ -87,3 +89,13 @@ def _async_update_attrs(self) -> None: self._attr_release_summary = self.get_device_value( QSD_FIRMWARE_CHECK, QSD_DESCRIPTION ) + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + await self.coordinator.async_refresh() + await self.coordinator.qsw.live_update() + + self._attr_installed_version = self.latest_version + self.async_write_ha_state() diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 787255187cc9a4..a19760ad989f89 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 14582134e84f45..e58341633b1b07 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 5339588c5fa7a7..5d439680bc2f13 100644 --- a/homeassistant/components/radarr/binary_sensor.py +++ b/homeassistant/components/radarr/binary_sensor.py @@ -18,7 +18,7 @@ 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 0ed64ce30354b4..367e302d56fd38 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -18,8 +18,6 @@ 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 @@ class RadarrSensorEntityDescription( ), "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 @@ class RadarrSensorEntityDescription( ), "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 @@ class RadarrSensorEntityDescription( 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 299dd0a56b0f45..5cd7bcfc449344 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 d93d7c48823b15..fdd7537e9e1cb0 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/radiotherm/manifest.json b/homeassistant/components/radiotherm/manifest.json index aaa8671cd58d80..185a034d7f21ae 100644 --- a/homeassistant/components/radiotherm/manifest.json +++ b/homeassistant/components/radiotherm/manifest.json @@ -1,7 +1,7 @@ { "domain": "radiotherm", "name": "Radio Thermostat", - "codeowners": ["@bdraco", "@vinnyfuria"], + "codeowners": ["@vinnyfuria"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index ee5be0e4617b92..139a17f5181969 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -20,7 +20,7 @@ RAIN_SENSOR_ENTITY_DESCRIPTION = BinarySensorEntityDescription( key="rainsensor", - name="Rainsensor", + translation_key="rainsensor", icon="mdi:water", ) @@ -38,6 +38,8 @@ async def async_setup_entry( class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], BinarySensorEntity): """A sensor implementation for Rain Bird device.""" + _attr_has_entity_name = True + def __init__( self, coordinator: RainbirdUpdateCoordinator, diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 14598921a61c6a..b503e72d3a6e4c 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -69,7 +69,7 @@ def serial_number(self) -> str: def device_info(self) -> DeviceInfo: """Return information about the device.""" return DeviceInfo( - default_name=f"{MANUFACTURER} Controller", + name=f"{MANUFACTURER} Controller", identifiers={(DOMAIN, self._serial_number)}, manufacturer=MANUFACTURER, ) diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index 2216d060f29fc0..a44cfb3ce138ff 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/rainbird", "iot_class": "local_polling", "loggers": ["pyrainbird"], - "requirements": ["pyrainbird==2.0.0"] + "requirements": ["pyrainbird==2.1.0"] } diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index ac1ea9618706b1..febb960d652259 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -32,14 +32,14 @@ async def async_setup_entry( class RainDelayNumber(CoordinatorEntity[RainbirdUpdateCoordinator], NumberEntity): - """A number implemnetaiton for the rain delay.""" + """A number implementation for the rain delay.""" _attr_native_min_value = 0 _attr_native_max_value = 14 _attr_native_step = 1 _attr_native_unit_of_measurement = UnitOfTime.DAYS _attr_icon = "mdi:water-off" - _attr_name = "Rain delay" + _attr_translation_key = "rain_delay" _attr_has_entity_name = True def __init__( diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index de74943baf9c4c..f5cf2390095ad2 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -1,4 +1,4 @@ -"""Support for Rain Bird Irrigation system LNK WiFi Module.""" +"""Support for Rain Bird Irrigation system LNK Wi-Fi Module.""" from __future__ import annotations import logging @@ -18,7 +18,7 @@ RAIN_DELAY_ENTITY_DESCRIPTION = SensorEntityDescription( key="raindelay", - name="Raindelay", + translation_key="raindelay", icon="mdi:water-off", ) @@ -42,6 +42,8 @@ async def async_setup_entry( class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], SensorEntity): """A sensor implementation for Rain Bird device.""" + _attr_has_entity_name = True + def __init__( self, coordinator: RainbirdUpdateCoordinator, diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 3b5ae332dbd436..a98baead976ae6 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -27,5 +27,22 @@ } } } + }, + "entity": { + "binary_sensor": { + "rainsensor": { + "name": "Rainsensor" + } + }, + "number": { + "rain_delay": { + "name": "Rain delay" + } + }, + "sensor": { + "raindelay": { + "name": "Raindelay" + } + } } } diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 38f3c03fb03479..3e2a3115e29390 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -1,4 +1,4 @@ -"""Support for Rain Bird Irrigation system LNK WiFi Module.""" +"""Support for Rain Bird Irrigation system LNK Wi-Fi Module.""" from __future__ import annotations import logging @@ -67,12 +67,13 @@ def __init__( 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 self._attr_unique_id = f"{coordinator.serial_number}-{zone}" self._attr_device_info = DeviceInfo( - default_name=f"{MANUFACTURER} Sprinkler {zone}", + name=f"{MANUFACTURER} Sprinkler {zone}", identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer=MANUFACTURER, via_device=(DOMAIN, coordinator.serial_number), diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 33650cfc2fef13..7f93db67c4cb32 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -44,14 +44,14 @@ class RainMachineBinarySensorDescription( BINARY_SENSOR_DESCRIPTIONS = ( RainMachineBinarySensorDescription( key=TYPE_FLOW_SENSOR, - name="Flow sensor", + translation_key=TYPE_FLOW_SENSOR, icon="mdi:water-pump", api_category=DATA_PROVISION_SETTINGS, data_key="useFlowSensor", ), RainMachineBinarySensorDescription( key=TYPE_FREEZE, - name="Freeze restrictions", + translation_key=TYPE_FREEZE, icon="mdi:cancel", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_CURRENT, @@ -59,7 +59,7 @@ class RainMachineBinarySensorDescription( ), RainMachineBinarySensorDescription( key=TYPE_HOURLY, - name="Hourly restrictions", + translation_key=TYPE_HOURLY, icon="mdi:cancel", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_CURRENT, @@ -67,7 +67,7 @@ class RainMachineBinarySensorDescription( ), RainMachineBinarySensorDescription( key=TYPE_MONTH, - name="Month restrictions", + translation_key=TYPE_MONTH, icon="mdi:cancel", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_CURRENT, @@ -75,7 +75,7 @@ class RainMachineBinarySensorDescription( ), RainMachineBinarySensorDescription( key=TYPE_RAINDELAY, - name="Rain delay restrictions", + translation_key=TYPE_RAINDELAY, icon="mdi:cancel", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_CURRENT, @@ -83,7 +83,7 @@ class RainMachineBinarySensorDescription( ), RainMachineBinarySensorDescription( key=TYPE_RAINSENSOR, - name="Rain sensor restrictions", + translation_key=TYPE_RAINSENSOR, icon="mdi:cancel", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -92,7 +92,7 @@ class RainMachineBinarySensorDescription( ), RainMachineBinarySensorDescription( key=TYPE_WEEKDAY, - name="Weekday restrictions", + translation_key=TYPE_WEEKDAY, icon="mdi:cancel", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_CURRENT, diff --git a/homeassistant/components/rainmachine/button.py b/homeassistant/components/rainmachine/button.py index d4ed17c72e9ee6..82829094957048 100644 --- a/homeassistant/components/rainmachine/button.py +++ b/homeassistant/components/rainmachine/button.py @@ -51,7 +51,6 @@ async def _async_reboot(controller: Controller) -> None: BUTTON_DESCRIPTIONS = ( RainMachineButtonDescription( key=BUTTON_KIND_REBOOT, - name="Reboot", api_category=DATA_PROVISION_SETTINGS, push_action=_async_reboot, ), diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index 574ca3d7f43be3..dabae5ff8c66b3 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -10,7 +10,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["regenmaschine"], - "requirements": ["regenmaschine==2023.05.1"], + "requirements": ["regenmaschine==2023.06.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/rainmachine/select.py b/homeassistant/components/rainmachine/select.py index f482deb4ef4518..2a5bc93f60146f 100644 --- a/homeassistant/components/rainmachine/select.py +++ b/homeassistant/components/rainmachine/select.py @@ -59,7 +59,7 @@ class FreezeProtectionSelectDescription( SELECT_DESCRIPTIONS = ( FreezeProtectionSelectDescription( key=TYPE_FREEZE_PROTECTION_TEMPERATURE, - name="Freeze protection temperature", + translation_key=TYPE_FREEZE_PROTECTION_TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, api_category=DATA_RESTRICTIONS_UNIVERSAL, diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 22943d73fcb4dc..6333dcc82f4699 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -69,7 +69,7 @@ class RainMachineSensorCompletionTimerDescription( SENSOR_DESCRIPTIONS = ( RainMachineSensorDataDescription( key=TYPE_FLOW_SENSOR_CLICK_M3, - name="Flow sensor clicks per cubic meter", + translation_key=TYPE_FLOW_SENSOR_CLICK_M3, icon="mdi:water-pump", native_unit_of_measurement=f"clicks/{UnitOfVolume.CUBIC_METERS}", entity_category=EntityCategory.DIAGNOSTIC, @@ -80,7 +80,7 @@ class RainMachineSensorCompletionTimerDescription( ), RainMachineSensorDataDescription( key=TYPE_FLOW_SENSOR_CONSUMED_LITERS, - name="Flow sensor consumed liters", + translation_key=TYPE_FLOW_SENSOR_CONSUMED_LITERS, icon="mdi:water-pump", device_class=SensorDeviceClass.WATER, entity_category=EntityCategory.DIAGNOSTIC, @@ -92,7 +92,7 @@ class RainMachineSensorCompletionTimerDescription( ), RainMachineSensorDataDescription( key=TYPE_FLOW_SENSOR_LEAK_CLICKS, - name="Flow sensor leak clicks", + translation_key=TYPE_FLOW_SENSOR_LEAK_CLICKS, icon="mdi:pipe-leak", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement="clicks", @@ -103,7 +103,7 @@ class RainMachineSensorCompletionTimerDescription( ), RainMachineSensorDataDescription( key=TYPE_FLOW_SENSOR_LEAK_VOLUME, - name="Flow sensor leak volume", + translation_key=TYPE_FLOW_SENSOR_LEAK_VOLUME, icon="mdi:pipe-leak", device_class=SensorDeviceClass.WATER, entity_category=EntityCategory.DIAGNOSTIC, @@ -115,7 +115,7 @@ class RainMachineSensorCompletionTimerDescription( ), RainMachineSensorDataDescription( key=TYPE_FLOW_SENSOR_START_INDEX, - name="Flow sensor start index", + translation_key=TYPE_FLOW_SENSOR_START_INDEX, icon="mdi:water-pump", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement="index", @@ -125,7 +125,7 @@ class RainMachineSensorCompletionTimerDescription( ), RainMachineSensorDataDescription( key=TYPE_FLOW_SENSOR_WATERING_CLICKS, - name="Flow sensor clicks", + translation_key=TYPE_FLOW_SENSOR_WATERING_CLICKS, icon="mdi:water-pump", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement="clicks", @@ -136,7 +136,7 @@ class RainMachineSensorCompletionTimerDescription( ), RainMachineSensorDataDescription( key=TYPE_LAST_LEAK_DETECTED, - name="Last leak detected", + translation_key=TYPE_LAST_LEAK_DETECTED, icon="mdi:pipe-leak", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -147,7 +147,7 @@ class RainMachineSensorCompletionTimerDescription( ), RainMachineSensorDataDescription( key=TYPE_RAIN_SENSOR_RAIN_START, - name="Rain sensor rain start", + translation_key=TYPE_RAIN_SENSOR_RAIN_START, icon="mdi:weather-pouring", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index 9991fd31e034c5..884d05359a61bb 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -28,5 +28,74 @@ } } } + }, + "entity": { + "binary_sensor": { + "flow_sensor": { + "name": "Flow sensor" + }, + "freeze": { + "name": "Freeze restrictions" + }, + "hourly": { + "name": "Hourly restrictions" + }, + "month": { + "name": "Month restrictions" + }, + "raindelay": { + "name": "Rain delay restrictions" + }, + "rainsensor": { + "name": "Rain sensor restrictions" + }, + "weekday": { + "name": "Weekday restrictions" + } + }, + "select": { + "freeze_protection_temperature": { + "name": "Freeze protection temperature" + } + }, + "sensor": { + "flow_sensor_clicks_cubic_meter": { + "name": "Flow sensor clicks per cubic meter" + }, + "flow_sensor_consumed_liters": { + "name": "Flow sensor consumed liters" + }, + "flow_sensor_leak_clicks": { + "name": "Flow sensor leak clicks" + }, + "flow_sensor_leak_volume": { + "name": "Flow sensor leak volume" + }, + "flow_sensor_start_index": { + "name": "Flow sensor start index" + }, + "flow_sensor_watering_clicks": { + "name": "Flow sensor clicks" + }, + "last_leak_detected": { + "name": "Last leak detected" + }, + "rain_sensor_rain_start": { + "name": "Rain sensor rain start" + } + }, + "switch": { + "freeze_protect_enabled": { + "name": "Freeze protection" + }, + "hot_days_extra_watering": { + "name": "Extra water on hot days" + } + }, + "update": { + "firmware": { + "name": "Firmware" + } + } } } diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 60db5085951cf3..e6ed92d04dc25b 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -161,14 +161,14 @@ class RainMachineRestrictionSwitchDescription( RESTRICTIONS_SWITCH_DESCRIPTIONS = ( RainMachineRestrictionSwitchDescription( key=TYPE_RESTRICTIONS_FREEZE_PROTECT_ENABLED, - name="Freeze protection", + translation_key=TYPE_RESTRICTIONS_FREEZE_PROTECT_ENABLED, icon="mdi:snowflake-alert", api_category=DATA_RESTRICTIONS_UNIVERSAL, data_key="freezeProtectEnabled", ), RainMachineRestrictionSwitchDescription( key=TYPE_RESTRICTIONS_HOT_DAYS_EXTRA_WATERING, - name="Extra water on hot days", + translation_key=TYPE_RESTRICTIONS_HOT_DAYS_EXTRA_WATERING, icon="mdi:heat-wave", api_category=DATA_RESTRICTIONS_UNIVERSAL, data_key="hotDaysExtraWatering", diff --git a/homeassistant/components/rainmachine/update.py b/homeassistant/components/rainmachine/update.py index a811894a0c2895..372319ba9a0f71 100644 --- a/homeassistant/components/rainmachine/update.py +++ b/homeassistant/components/rainmachine/update.py @@ -44,7 +44,7 @@ class UpdateStates(Enum): UPDATE_DESCRIPTION = RainMachineEntityDescription( key="update", - name="Firmware", + translation_key="firmware", api_category=DATA_MACHINE_FIRMWARE_UPDATE_STATUS, ) @@ -52,7 +52,7 @@ class UpdateStates(Enum): async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up WLED update based on a config entry.""" + """Set up Rainmachine update based on a config entry.""" data: RainMachineData = hass.data[DOMAIN][entry.entry_id] async_add_entities([RainMachineUpdateEntity(entry, data, UPDATE_DESCRIPTION)]) @@ -62,6 +62,7 @@ class RainMachineUpdateEntity(RainMachineEntity, UpdateEntity): """Define a RainMachine update entity.""" _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_name = None _attr_supported_features = ( UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS diff --git a/homeassistant/components/rapt_ble/manifest.json b/homeassistant/components/rapt_ble/manifest.json index d3eab0641a6259..1bde135de35099 100644 --- a/homeassistant/components/rapt_ble/manifest.json +++ b/homeassistant/components/rapt_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/rapt_ble", "iot_class": "local_push", - "requirements": ["rapt-ble==0.1.1"] + "requirements": ["rapt-ble==0.1.2"] } diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index 13a045151435bf..9d895f35eb7c60 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -41,13 +41,13 @@ class RDWBinarySensorEntityDescription( BINARY_SENSORS: tuple[RDWBinarySensorEntityDescription, ...] = ( RDWBinarySensorEntityDescription( key="liability_insured", - name="Liability insured", + translation_key="liability_insured", icon="mdi:shield-car", is_on_fn=lambda vehicle: vehicle.liability_insured, ), RDWBinarySensorEntityDescription( key="pending_recall", - name="Pending recall", + translation_key="pending_recall", device_class=BinarySensorDeviceClass.PROBLEM, is_on_fn=lambda vehicle: vehicle.pending_recall, ), diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index 0b5640fe3a41eb..5df34652f2b2ed 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -1,7 +1,7 @@ { "domain": "rdw", "name": "RDW", - "codeowners": ["@frenck"], + "codeowners": ["@frenck", "@joostlek"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rdw", "integration_type": "service", diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index e262665dd63a1e..2c324ca7093b9d 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -42,13 +42,13 @@ class RDWSensorEntityDescription( SENSORS: tuple[RDWSensorEntityDescription, ...] = ( RDWSensorEntityDescription( key="apk_expiration", - name="APK expiration", + translation_key="apk_expiration", device_class=SensorDeviceClass.DATE, value_fn=lambda vehicle: vehicle.apk_expiration, ), RDWSensorEntityDescription( key="ascription_date", - name="Ascription date", + translation_key="ascription_date", device_class=SensorDeviceClass.DATE, value_fn=lambda vehicle: vehicle.ascription_date, ), diff --git a/homeassistant/components/rdw/strings.json b/homeassistant/components/rdw/strings.json index 840802a12b7a92..cf24ec5115c592 100644 --- a/homeassistant/components/rdw/strings.json +++ b/homeassistant/components/rdw/strings.json @@ -14,5 +14,23 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown_license_plate": "Unknown license plate" } + }, + "entity": { + "binary_sensor": { + "liability_insured": { + "name": "Liability insured" + }, + "pending_recall": { + "name": "Pending recall" + } + }, + "sensor": { + "apk_expiration": { + "name": "APK expiration" + }, + "ascription_date": { + "name": "Ascription date" + } + } } } diff --git a/homeassistant/components/recollect_waste/calendar.py b/homeassistant/components/recollect_waste/calendar.py index 120ab77c3b3f36..c439f647da5414 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/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 4883734f47e175..5989fb1cfe347a 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -28,11 +28,11 @@ SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=SENSOR_TYPE_CURRENT_PICKUP, - name="Current pickup", + translation_key=SENSOR_TYPE_CURRENT_PICKUP, ), SensorEntityDescription( key=SENSOR_TYPE_NEXT_PICKUP, - name="Next pickup", + translation_key=SENSOR_TYPE_NEXT_PICKUP, ), ) diff --git a/homeassistant/components/recollect_waste/strings.json b/homeassistant/components/recollect_waste/strings.json index a350b9880fc89b..20aa5982f0d5c4 100644 --- a/homeassistant/components/recollect_waste/strings.json +++ b/homeassistant/components/recollect_waste/strings.json @@ -24,5 +24,15 @@ } } } + }, + "entity": { + "sensor": { + "current_pickup": { + "name": "Current pickup" + }, + "next_pickup": { + "name": "Next pickup" + } + } } } diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 7b43abd8dde93d..72d825d9e78505 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -135,7 +135,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: exclude_attributes_by_domain: dict[str, set[str]] = {} hass.data[EXCLUDE_ATTRIBUTES] = exclude_attributes_by_domain conf = config[DOMAIN] - entity_filter = convert_include_exclude_filter(conf) + entity_filter = convert_include_exclude_filter(conf).get_filter() auto_purge = conf[CONF_AUTO_PURGE] auto_repack = conf[CONF_AUTO_REPACK] keep_days = conf[CONF_PURGE_KEEP_DAYS] diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 43915c0187b6a8..d4a026cfefc921 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -215,6 +215,7 @@ def __init__( self.schema_version = 0 self._commits_without_expire = 0 + self._event_session_has_pending_writes = False self.recorder_runs_manager = RecorderRunsManager() self.states_manager = StatesManager() @@ -298,9 +299,39 @@ def _shutdown_pool(self) -> None: @callback def async_initialize(self) -> None: """Initialize the recorder.""" + entity_filter = self.entity_filter + exclude_event_types = self.exclude_event_types + queue_put = self._queue.put_nowait + event_task = EventTask + + @callback + def _event_listener(event: Event) -> None: + """Listen for new events and put them in the process queue.""" + if event.event_type in exclude_event_types: + return + + if (entity_id := event.data.get(ATTR_ENTITY_ID)) is None: + queue_put(event_task(event)) + return + + if isinstance(entity_id, str): + if entity_filter(entity_id): + queue_put(event_task(event)) + return + + if isinstance(entity_id, list): + for eid in entity_id: + if entity_filter(eid): + queue_put(event_task(event)) + return + return + + # Unknown what it is. + queue_put(event_task(event)) + self._event_listener = self.hass.bus.async_listen( MATCH_ALL, - self.event_listener, + _event_listener, run_immediately=True, ) self._queue_watcher = async_track_time_interval( @@ -322,7 +353,7 @@ def _async_commit(self, now: datetime) -> None: if ( self._event_listener and not self._database_lock_task - and self._event_session_has_pending_writes() + and self._event_session_has_pending_writes ): self.queue_task(COMMIT_TASK) @@ -411,27 +442,6 @@ def _async_stop_listeners(self) -> None: self._periodic_listener() self._periodic_listener = None - @callback - def _async_event_filter(self, event: Event) -> bool: - """Filter events.""" - if event.event_type in self.exclude_event_types: - return False - - if (entity_id := event.data.get(ATTR_ENTITY_ID)) is None: - return True - - if isinstance(entity_id, str): - return self.entity_filter(entity_id) - - if isinstance(entity_id, list): - for eid in entity_id: - if self.entity_filter(eid): - return True - return False - - # Unknown what it is. - return True - async def _async_close(self, event: Event) -> None: """Empty the queue if its still present at close.""" @@ -543,10 +553,10 @@ def _adjust_lru_size(self) -> None: If the number of entities has increased, increase the size of the LRU cache to avoid thrashing. """ - new_size = self.hass.states.async_entity_ids_count() * 2 - self.state_attributes_manager.adjust_lru_size(new_size) - self.states_meta_manager.adjust_lru_size(new_size) - self.statistics_meta_manager.adjust_lru_size(new_size) + if new_size := self.hass.states.async_entity_ids_count() * 2: + self.state_attributes_manager.adjust_lru_size(new_size) + self.states_meta_manager.adjust_lru_size(new_size) + self.statistics_meta_manager.adjust_lru_size(new_size) @callback def async_periodic_statistics(self) -> None: @@ -688,6 +698,11 @@ def run(self) -> None: # anything goes wrong in the run loop self._shutdown() + def _add_to_session(self, session: Session, obj: object) -> None: + """Add an object to the session.""" + self._event_session_has_pending_writes = True + session.add(obj) + def _run(self) -> None: """Start processing events to save.""" self.thread_id = threading.get_ident() @@ -1016,11 +1031,11 @@ def _process_non_state_changed_event_into_session(self, event: Event) -> None: else: event_types = EventTypes(event_type=event.event_type) event_type_manager.add_pending(event_types) - session.add(event_types) + self._add_to_session(session, event_types) dbevent.event_type_rel = event_types if not event.data: - session.add(dbevent) + self._add_to_session(session, dbevent) return event_data_manager = self.event_data_manager @@ -1042,10 +1057,10 @@ def _process_non_state_changed_event_into_session(self, event: Event) -> None: # No matching attributes found, save them in the DB dbevent_data = EventData(shared_data=shared_data, hash=hash_) event_data_manager.add_pending(dbevent_data) - session.add(dbevent_data) + self._add_to_session(session, dbevent_data) dbevent.event_data_rel = dbevent_data - session.add(dbevent) + self._add_to_session(session, dbevent) def _process_state_changed_event_into_session(self, event: Event) -> None: """Process a state_changed event into the session.""" @@ -1090,7 +1105,7 @@ def _process_state_changed_event_into_session(self, event: Event) -> None: else: states_meta = StatesMeta(entity_id=entity_id) states_meta_manager.add_pending(states_meta) - session.add(states_meta) + self._add_to_session(session, states_meta) dbstate.states_meta_rel = states_meta # Map the event data to the StateAttributes table @@ -1115,10 +1130,10 @@ def _process_state_changed_event_into_session(self, event: Event) -> None: # No matching attributes found, save them in the DB dbstate_attributes = StateAttributes(shared_attrs=shared_attrs, hash=hash_) state_attributes_manager.add_pending(dbstate_attributes) - session.add(dbstate_attributes) + self._add_to_session(session, dbstate_attributes) dbstate.state_attributes = dbstate_attributes - session.add(dbstate) + self._add_to_session(session, dbstate) def _handle_database_error(self, err: Exception) -> bool: """Handle a database error that may result in moving away the corrupt db.""" @@ -1130,14 +1145,9 @@ def _handle_database_error(self, err: Exception) -> bool: return True return False - def _event_session_has_pending_writes(self) -> bool: - """Return True if there are pending writes in the event session.""" - session = self.event_session - return bool(session and (session.new or session.dirty)) - def _commit_event_session_or_retry(self) -> None: """Commit the event session if there is work to do.""" - if not self._event_session_has_pending_writes(): + if not self._event_session_has_pending_writes: return tries = 1 while tries <= self.db_max_retries: @@ -1163,6 +1173,7 @@ def _commit_event_session(self) -> None: self._commits_without_expire += 1 session.commit() + self._event_session_has_pending_writes = False # We just committed the state attributes to the database # and we now know the attributes_ids. We can save # many selects for matching attributes by loading them @@ -1255,15 +1266,9 @@ def _send_keep_alive(self) -> None: _LOGGER.debug("Sending keepalive") self.event_session.connection().scalar(select(1)) - @callback - def event_listener(self, event: Event) -> None: - """Listen for new events and put them in the process queue.""" - if self._async_event_filter(event): - self.queue_task(EventTask(event)) - async def async_block_till_done(self) -> None: """Async version of block_till_done.""" - if self._queue.empty() and not self._event_session_has_pending_writes(): + if self._queue.empty() and not self._event_session_has_pending_writes: return event = asyncio.Event() self.queue_task(SynchronizeTask(event)) @@ -1417,6 +1422,8 @@ def _end_session(self) -> None: if self.event_session is None: return if self.recorder_runs_manager.active: + # .end will add to the event session + self._event_session_has_pending_writes = True self.recorder_runs_manager.end(self.event_session) try: self._commit_event_session_or_retry() diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index 74b17d9daa72e4..64ce1aa7d55655 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/history/modern.py b/homeassistant/components/recorder/history/modern.py index 5322074c2058bb..393bcfa3676e09 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -9,7 +9,6 @@ from sqlalchemy import ( CompoundSelect, - Integer, Select, Subquery, and_, @@ -19,7 +18,6 @@ select, union_all, ) -from sqlalchemy.dialects import postgresql from sqlalchemy.engine.row import Row from sqlalchemy.orm.session import Session @@ -52,16 +50,6 @@ } -CASTABLE_DOUBLE_TYPE = ( - # MySQL/MariaDB < 10.4+ does not support casting to DOUBLE so we have to use Integer instead but it doesn't - # matter because we don't use the value as its always set to NULL - # - # sqlalchemy.exc.SAWarning: Datatype DOUBLE does not support CAST on MySQL/MariaDb; the CAST will be skipped. - # - Integer().with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") -) - - def _stmt_and_join_attributes( no_attributes: bool, include_last_changed: bool ) -> Select: @@ -79,13 +67,9 @@ def _stmt_and_join_attributes_for_start_state( ) -> Select: """Return the statement and if StateAttributes should be joined.""" _select = select(States.metadata_id, States.state) - _select = _select.add_columns( - literal(value=None).label("last_updated_ts").cast(CASTABLE_DOUBLE_TYPE) - ) + _select = _select.add_columns(literal(value=0).label("last_updated_ts")) if include_last_changed: - _select = _select.add_columns( - literal(value=None).label("last_changed_ts").cast(CASTABLE_DOUBLE_TYPE) - ) + _select = _select.add_columns(literal(value=0).label("last_changed_ts")) if not no_attributes: _select = _select.add_columns(SHARED_ATTR_OR_LEGACY_ATTRIBUTES) return _select @@ -174,28 +158,29 @@ def _significant_states_stmt( stmt = stmt.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - stmt = stmt.order_by(States.metadata_id, States.last_updated_ts) if not include_start_time_state or not run_start_ts: + stmt = stmt.order_by(States.metadata_id, States.last_updated_ts) return stmt - return _select_from_subquery( - union_all( - _select_from_subquery( - _get_start_time_state_stmt( - run_start_ts, - start_time_ts, - single_metadata_id, - metadata_ids, - no_attributes, - include_last_changed, - ).subquery(), + unioned_subquery = union_all( + _select_from_subquery( + _get_start_time_state_stmt( + run_start_ts, + start_time_ts, + single_metadata_id, + metadata_ids, no_attributes, include_last_changed, - ), - _select_from_subquery(stmt.subquery(), no_attributes, include_last_changed), - ).subquery(), + ).subquery(), + no_attributes, + include_last_changed, + ), + _select_from_subquery(stmt.subquery(), no_attributes, include_last_changed), + ).subquery() + return _select_from_subquery( + unioned_subquery, no_attributes, include_last_changed, - ) + ).order_by(unioned_subquery.c.metadata_id, unioned_subquery.c.last_updated_ts) def get_significant_states_with_session( @@ -279,6 +264,7 @@ def get_significant_states_with_session( entity_id_to_metadata_id, minimal_response, compressed_state_format, + no_attributes=no_attributes, ) @@ -433,6 +419,7 @@ def state_changes_during_period( entity_ids, entity_id_to_metadata_id, descending=descending, + no_attributes=no_attributes, ), ) @@ -528,6 +515,7 @@ def get_last_state_changes( None, entity_ids, entity_id_to_metadata_id, + no_attributes=False, ), ) @@ -651,6 +639,7 @@ def _sorted_states_to_dict( minimal_response: bool = False, compressed_state_format: bool = False, descending: bool = False, + no_attributes: bool = False, ) -> MutableMapping[str, list[State | dict[str, Any]]]: """Convert SQL results into JSON friendly data structure. @@ -665,7 +654,7 @@ def _sorted_states_to_dict( """ field_map = _FIELD_MAP state_class: Callable[ - [Row, dict[str, dict[str, Any]], float | None, str, str, float | None], + [Row, dict[str, dict[str, Any]], float | None, str, str, float | None, bool], State | dict[str, Any], ] if compressed_state_format: @@ -716,6 +705,7 @@ def _sorted_states_to_dict( entity_id, db_state[state_idx], db_state[last_updated_ts_idx], + False, ) for db_state in group ) @@ -738,6 +728,7 @@ def _sorted_states_to_dict( entity_id, prev_state, # type: ignore[arg-type] first_state[last_updated_ts_idx], + no_attributes, ) ) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 33c6a516c65876..2e868542457646 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 b8436da97d58c9..33d8c7b5e67bc7 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/models/state.py b/homeassistant/components/recorder/models/state.py index 523ffdf18527c1..73e7798b9f5cc0 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -53,6 +53,7 @@ def __init__( # pylint: disable=super-init-not-called entity_id: str, state: str, last_updated_ts: float | None, + no_attributes: bool, ) -> None: """Init the lazy state.""" self._row = row @@ -143,14 +144,14 @@ def row_to_compressed_state( entity_id: str, state: str, last_updated_ts: float | None, + no_attributes: bool, ) -> dict[str, Any]: """Convert a database row to a compressed state schema 41 and later.""" - comp_state: dict[str, Any] = { - COMPRESSED_STATE_STATE: state, - COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_source( + comp_state: dict[str, Any] = {COMPRESSED_STATE_STATE: state} + if not no_attributes: + comp_state[COMPRESSED_STATE_ATTRIBUTES] = decode_attributes_from_source( getattr(row, "attributes", None), attr_cache - ), - } + ) row_last_updated_ts: float = last_updated_ts or start_time_ts # type: ignore[assignment] comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts if ( diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index 09b113f03eba94..46f140305e361a 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -92,7 +92,7 @@ def _do_get_db_connection_protected(self) -> ConnectionPoolEntry: 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 70f35d0349a5e0..9bbf35bb40ac2c 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -219,50 +219,34 @@ def _get_statistic_to_display_unit_converter( if display_unit == statistic_unit: return None - convert = converter.convert - - def _from_normalized_unit(val: float | None) -> float | None: - """Return val.""" - if val is None: - return val - return convert(val, statistic_unit, display_unit) - - return _from_normalized_unit + return converter.converter_factory_allow_none( + from_unit=statistic_unit, to_unit=display_unit + ) def _get_display_to_statistic_unit_converter( display_unit: str | None, statistic_unit: str | None, -) -> Callable[[float], float]: +) -> Callable[[float], float] | None: """Prepare a converter from the display unit to the statistics unit.""" - - def no_conversion(val: float) -> float: - """Return val.""" - return val - - if (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None: - return no_conversion - - return partial(converter.convert, from_unit=display_unit, to_unit=statistic_unit) + if ( + display_unit == statistic_unit + or (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None + ): + return None + return converter.converter_factory(from_unit=display_unit, to_unit=statistic_unit) def _get_unit_converter( from_unit: str, to_unit: str -) -> Callable[[float | None], float | None]: +) -> Callable[[float | None], float | None] | None: """Prepare a converter from a unit to another unit.""" - - def convert_units( - val: float | None, conv: type[BaseUnitConverter], from_unit: str, to_unit: str - ) -> float | None: - """Return converted val.""" - if val is None: - return val - return conv.convert(val, from_unit=from_unit, to_unit=to_unit) - for conv in STATISTIC_UNIT_TO_UNIT_CONVERTER.values(): if from_unit in conv.VALID_UNITS and to_unit in conv.VALID_UNITS: - return partial( - convert_units, conv=conv, from_unit=from_unit, to_unit=to_unit + if from_unit == to_unit: + return None + return conv.converter_factory_allow_none( + from_unit=from_unit, to_unit=to_unit ) raise HomeAssistantError @@ -1494,7 +1478,9 @@ def statistic_during_period( state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) convert = _get_statistic_to_display_unit_converter(unit, state_unit, units) - return {key: convert(value) if convert else value for key, value in result.items()} + if not convert: + return result + return {key: convert(value) for key, value in result.items()} _type_column_mapping = { @@ -2290,10 +2276,10 @@ def adjust_statistics( return True statistic_unit = metadata[statistic_id][1]["unit_of_measurement"] - convert = _get_display_to_statistic_unit_converter( + if convert := _get_display_to_statistic_unit_converter( adjustment_unit, statistic_unit - ) - sum_adjustment = convert(sum_adjustment) + ): + sum_adjustment = convert(sum_adjustment) _adjust_sum_statistics( session, @@ -2360,7 +2346,14 @@ def change_statistics_unit( metadata_id = metadata[0] - convert = _get_unit_converter(old_unit, new_unit) + if not (convert := _get_unit_converter(old_unit, new_unit)): + _LOGGER.warning( + "Statistics unit of measurement for %s is already %s", + statistic_id, + new_unit, + ) + return + tables: tuple[type[StatisticsBase], ...] = ( Statistics, StatisticsShortTerm, @@ -2407,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: @@ -2417,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 @@ -2432,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 4e08719e572c07..85266a3793989b 100644 --- a/homeassistant/components/recorder/table_managers/event_data.py +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -10,11 +10,11 @@ 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 d5541c547d513e..fd03bdd14d2f49 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -9,12 +9,12 @@ 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 442277be96ec31..3ae67b932bf4ce 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.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 bc4a8cfd2d9b18..b8f6204d3182db 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -8,11 +8,11 @@ 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 1c50fd0a77c26d..f3de9824a16241 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 09c540b5e01451..936c7aca37a110 100644 --- a/homeassistant/components/remote/device_action.py +++ b/homeassistant/components/remote/device_action.py @@ -3,7 +3,10 @@ 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 @@ # 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 c77ae7fde9cb82..1654cc0c01db84 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 @@ 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 37994830c4d792..bc0e694e8eba88 100644 --- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py +++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py @@ -11,6 +11,7 @@ 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 @@ 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 862efb0f89d97a..962cf6b4f3c23e 100644 --- a/homeassistant/components/remote_rpi_gpio/switch.py +++ b/homeassistant/components/remote_rpi_gpio/switch.py @@ -12,8 +12,8 @@ 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/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 83d86745d90b02..ef2d7196f04852 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -87,7 +87,6 @@ def icon(self) -> str | None: device_class=BinarySensorDeviceClass.PLUG, on_key="plugStatus", on_value=PlugState.PLUGGED.value, - translation_key="plugged_in", ), RenaultBinarySensorEntityDescription( key="charging", @@ -95,7 +94,6 @@ def icon(self) -> str | None: device_class=BinarySensorDeviceClass.BATTERY_CHARGING, on_key="chargingStatus", on_value=ChargeState.CHARGE_IN_PROGRESS.value, - translation_key="charging", ), RenaultBinarySensorEntityDescription( key="hvac_status", @@ -112,7 +110,6 @@ def icon(self) -> str | None: device_class=BinarySensorDeviceClass.LOCK, on_key="lockStatus", on_value="unlocked", - translation_key="lock_status", ), RenaultBinarySensorEntityDescription( key="hatch_status", diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 90ad70521df321..050c5a930f6be6 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -165,7 +165,6 @@ def _get_utc_value(entity: RenaultSensor[T]) -> datetime: entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - translation_key="battery_level", ), RenaultSensorEntityDescription( key="charge_state", diff --git a/homeassistant/components/renault/services.yaml b/homeassistant/components/renault/services.yaml index c8c3e9b12ba12e..5911c453c95863 100644 --- a/homeassistant/components/renault/services.yaml +++ b/homeassistant/components/renault/services.yaml @@ -1,4 +1,5 @@ ac_start: + name: Start A/C description: Start A/C on vehicle. fields: vehicle: @@ -25,6 +26,7 @@ ac_start: text: ac_cancel: + name: Cancel A/C description: Cancel A/C on vehicle. fields: vehicle: @@ -36,6 +38,7 @@ ac_cancel: integration: renault charge_set_schedules: + name: Update charge schedule description: Update charge schedule on vehicle. fields: vehicle: diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 066b49abcc0983..7cf016187be0c9 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -34,9 +34,6 @@ }, "entity": { "binary_sensor": { - "charging": { - "name": "[%key:component::binary_sensor::entity_component::battery_charging::name%]" - }, "hatch_status": { "name": "Hatch" }, @@ -46,15 +43,9 @@ "hvac_status": { "name": "HVAC" }, - "lock_status": { - "name": "[%key:component::binary_sensor::entity_component::lock::name%]" - }, "passenger_door_status": { "name": "Passenger door" }, - "plugged_in": { - "name": "[%key:component::binary_sensor::entity_component::plug::name%]" - }, "rear_left_door_status": { "name": "Rear left door" }, @@ -101,9 +92,6 @@ "battery_last_activity": { "name": "Last battery activity" }, - "battery_level": { - "name": "Battery level" - }, "battery_temperature": { "name": "Battery temperature" }, diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py new file mode 100644 index 00000000000000..211f7c88e4089d --- /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 00000000000000..9883772ce02a54 --- /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 00000000000000..840e1ce428ae8f --- /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 00000000000000..526077d2d7f37f --- /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 00000000000000..5ff219cc26c880 --- /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 00000000000000..9817951b094a05 --- /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 00000000000000..16c5de158a9ae1 --- /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 76c0963e2c066f..923df261d843fc 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -8,7 +8,6 @@ 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: @@ -75,6 +72,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) ) + starting = True + async def async_device_config_update() -> None: """Update the host state cache and renew the ONVIF-subscription.""" async with async_timeout.timeout(host.api.timeout): @@ -96,9 +95,19 @@ async def async_check_firmware_update() -> str | Literal[False]: async with async_timeout.timeout(host.api.timeout): try: return await host.api.check_new_firmware() - except ReolinkError as err: + except (ReolinkError, asyncio.exceptions.CancelledError) as err: + if starting: + _LOGGER.debug( + "Error checking Reolink firmware update at startup " + "from %s, possibly internet access is blocked", + host.api.nvr_name, + ) + return False + raise UpdateFailed( - f"Error checking Reolink firmware update {host.api.nvr_name}" + f"Error checking Reolink firmware update from {host.api.nvr_name}, " + "if the camera is blocked from accessing the internet, " + "disable the update entity" ) from err device_coordinator = DataUpdateCoordinator( @@ -120,7 +129,7 @@ async def async_check_firmware_update() -> str | Literal[False]: # If camera WAN blocked, firmware check fails, do not prevent setup await asyncio.gather( device_coordinator.async_config_entry_first_refresh(), - firmware_coordinator.async_refresh(), + firmware_coordinator.async_config_entry_first_refresh(), ) except ConfigEntryNotReady: await host.stop() @@ -138,6 +147,7 @@ async def async_check_firmware_update() -> str | Literal[False]: config_entry.add_update_listener(entry_update_listener) ) + starting = False return True diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index a34f8c85d36c68..b012649ec4c5ce 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -28,13 +28,18 @@ async def async_setup_entry( cameras = [] for channel in host.api.stream_channels: - streams = ["sub", "main", "snapshots"] + streams = ["sub", "main", "snapshots_sub", "snapshots_main"] if host.api.protocol in ["rtmp", "flv"]: streams.append("ext") + if host.api.supported(channel, "autotrack_stream"): + streams.extend( + ["autotrack_sub", "autotrack_snapshots_sub", "autotrack_snapshots_main"] + ) + for stream in streams: stream_url = await host.api.get_stream_source(channel, stream) - if stream_url is None and stream != "snapshots": + if stream_url is None and "snapshots" not in stream: continue cameras.append(ReolinkCamera(reolink_data, channel, stream)) @@ -58,12 +63,16 @@ def __init__( self._stream = stream + stream_name = self._stream.replace("_", " ") if self._host.api.model in DUAL_LENS_MODELS: - self._attr_name = f"{self._stream} lens {self._channel}" + self._attr_name = f"{stream_name} lens {self._channel}" else: - self._attr_name = self._stream - self._attr_unique_id = f"{self._host.unique_id}_{self._channel}_{self._stream}" - self._attr_entity_registry_enabled_default = stream == "sub" + self._attr_name = stream_name + stream_id = self._stream + if stream_id == "snapshots_main": + stream_id = "snapshots" + self._attr_unique_id = f"{self._host.unique_id}_{self._channel}_{stream_id}" + self._attr_entity_registry_enabled_default = stream in ["sub", "autotrack_sub"] async def stream_source(self) -> str | None: """Return the source of the stream.""" @@ -73,4 +82,4 @@ async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - return await self._host.api.get_snapshot(self._channel) + return await self._host.api.get_snapshot(self._channel, self._stream) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index df5bf968ae13ee..75ad26665c3bd8 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -79,6 +79,10 @@ async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: 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 5ff79029bd1c61..81fbda63fef00c 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -8,8 +8,8 @@ import aiohttp from aiohttp.web import Request -import async_timeout from reolink_aio.api import Host +from reolink_aio.enums import SubType from reolink_aio.exceptions import ReolinkError, SubscriptionError from homeassistant.components import webhook @@ -25,9 +25,11 @@ 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__) @@ -60,9 +62,14 @@ def __init__( 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 @@ -148,54 +155,88 @@ async def async_init(self) -> None: await self.subscribe() - _LOGGER.debug( - "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url - ) - try: - async with async_timeout.timeout(FIRST_ONVIF_TIMEOUT): - await self._webhook_reachable.wait() - except asyncio.TimeoutError: + 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, + "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url + ) + else: + _LOGGER.debug( + "Camera model %s most likely does not push its initial state" + "upon ONVIF subscription, do not check", + self._api.model, ) + self._cancel_onvif_check = async_call_later( + self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif + ) + + if self._api.sw_version_update_required: ir.async_create_issue( self._hass, DOMAIN, - "webhook_url", + "firmware_update", is_fixable=False, severity=ir.IssueSeverity.WARNING, - translation_key="webhook_url", + translation_key="firmware_update", translation_placeholders={ + "required_firmware": self._api.sw_version_required.version_string, + "current_firmware": self._api.sw_version, + "model": self._api.model, + "hw_version": self._api.hardware_version, "name": self._api.nvr_name, - "base_url": self._base_url, - "network_link": "https://my.home-assistant.io/redirect/network/", + "download_link": "https://reolink.com/download-center/", }, ) - await self._async_poll_all_motion() else: + ir.async_delete_issue(self._hass, DOMAIN, "firmware_update") + + async def _async_check_onvif(self, *_) -> None: + """Check the ONVIF subscription.""" + 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, + ) - if self._api.sw_version_update_required: + # 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, - "firmware_update", + "webhook_url", is_fixable=False, severity=ir.IssueSeverity.WARNING, - translation_key="firmware_update", + translation_key="webhook_url", translation_placeholders={ - "required_firmware": self._api.sw_version_required.version_string, - "current_firmware": self._api.sw_version, - "model": self._api.model, - "hw_version": self._api.hardware_version, "name": self._api.nvr_name, - "download_link": "https://reolink.com/download-center/", + "base_url": self._base_url, + "network_link": "https://my.home-assistant.io/redirect/network/", }, ) else: - ir.async_delete_issue(self._hass, DOMAIN, "firmware_update") + ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") + + # If no ONVIF push or long polling state is received, start fast polling + await self._async_poll_all_motion() + + self._cancel_long_poll_check = None async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" @@ -205,11 +246,7 @@ async def disconnect(self): """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, @@ -219,11 +256,7 @@ async def disconnect(self): 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, @@ -231,11 +264,32 @@ async def disconnect(self): 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: self._cancel_poll() self._cancel_poll = None + 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() @@ -244,7 +298,7 @@ async def subscribe(self) -> None: 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, @@ -263,7 +317,9 @@ async def subscribe(self) -> None: 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 @@ -275,22 +331,27 @@ async def renew(self) -> None: 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: @@ -298,25 +359,29 @@ async def _renew(self) -> None: 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: @@ -367,31 +432,56 @@ def unregister_webhook(self): 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: @@ -399,10 +489,7 @@ async def _async_poll_all_motion(self, *_) -> None: 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 @@ -450,8 +537,8 @@ async def _process_webhook_data( """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: @@ -474,9 +561,13 @@ async def _process_webhook_data( ) 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 6a4ae98a1546e7..69b3d5db6f7ed1 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.15"] + "requirements": ["reolink-aio==0.7.1"] } diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index f208e3e4035f59..7dc89ddbaf3f55 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -60,7 +60,7 @@ "select": { "floodlight_mode": { "state": { - "off": "Off", + "off": "[%key:common::state::off%]", "auto": "Auto", "schedule": "Schedule" } @@ -74,7 +74,7 @@ }, "auto_quick_reply_message": { "state": { - "off": "Off" + "off": "[%key:common::state::off%]" } }, "auto_track_method": { diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 1a4deda17e3f1e..aa121911758018 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -98,6 +98,50 @@ class ReolinkNVRSwitchEntityDescription( 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 aeb44cb7740839..fbbb037080b50e 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -5,6 +5,7 @@ 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 @@ def latest_version(self) -> str | None: 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/repairs/__init__.py b/homeassistant/components/repairs/__init__.py index aa578c098d50dc..228972e8718f8b 100644 --- a/homeassistant/components/repairs/__init__.py +++ b/homeassistant/components/repairs/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import issue_handler, websocket_api @@ -16,6 +17,7 @@ "RepairsFlow", "RepairsFlowManager", ] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) def repairs_flow_manager(hass: HomeAssistant) -> RepairsFlowManager | None: diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index 24c97c74b0f615..784555e6c73640 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from 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 b249b7536b5291..ee79c45921c107 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -6,7 +6,7 @@ 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 0bf0ea9743decd..8fb08f766faa5c 100644 --- a/homeassistant/components/rest/const.py +++ b/homeassistant/components/rest/const.py @@ -26,3 +26,10 @@ 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 95086f68d70a6d..1f331651165f5f 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -3,14 +3,19 @@ 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 @@ def set_url(self, url: str) -> None: """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 6fc0b69d1fd225..18d0b6c7e760db 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -3,11 +3,9 @@ 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.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 @@ def __init__( 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 89b6529d48384e..342808f32508de 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -232,12 +232,11 @@ async def get_device_state(self, hass: HomeAssistant) -> httpx.Response: 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 b563275297fd9a..8df2d7ec343d51 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 de8a9fc6b8de20..3544abcfdd1ad5 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -8,8 +8,8 @@ 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 b729138f73e79c..03cf65a49ffcc0 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 2aa3bd20b8cc53..8d55208cbb7a35 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -2,11 +2,13 @@ from __future__ import annotations import asyncio +from contextlib import suppress import copy import itertools import os from typing import Any, TypedDict, cast +from async_timeout import timeout import RFXtrx as rfxtrxmod import serial import serial.tools.list_ports @@ -346,34 +348,57 @@ async def _async_replace_device(self, replace_device: str) -> None: entity_migration_map[new_entity_id] = entry @callback - def _handle_state_change( + def _handle_state_removed( entity_id: str, old_state: State | None, new_state: State | None ) -> None: # Wait for entities to finish cleanup - if new_state is None and entity_id in pending_entities: - pending_entities.remove(entity_id) - if not pending_entities: + if new_state is None and entity_id in entities_to_be_removed: + entities_to_be_removed.remove(entity_id) + if not entities_to_be_removed: wait_for_entities.set() # Create a set with entities to be removed which are currently in the state # machine - pending_entities = { + entities_to_be_removed = { entry.entity_id for entry in entity_migration_map.values() if not self.hass.states.async_available(entry.entity_id) } wait_for_entities = asyncio.Event() remove_track_state_changes = async_track_state_change( - self.hass, pending_entities, _handle_state_change + self.hass, entities_to_be_removed, _handle_state_removed ) for entry in entity_migration_map.values(): entity_registry.async_remove(entry.entity_id) # Wait for entities to finish cleanup - await wait_for_entities.wait() + with suppress(asyncio.TimeoutError): + async with timeout(10): + await wait_for_entities.wait() remove_track_state_changes() + @callback + def _handle_state_added( + entity_id: str, old_state: State | None, new_state: State | None + ) -> None: + # Wait for entities to be added + if old_state is None and entity_id in entities_to_be_added: + entities_to_be_added.remove(entity_id) + if not entities_to_be_added: + wait_for_entities.set() + + # Create a set with entities to be added to the state machine + entities_to_be_added = { + entry.entity_id + for entry in entity_migration_map.values() + if self.hass.states.async_available(entry.entity_id) + } + wait_for_entities = asyncio.Event() + remove_track_state_changes = async_track_state_change( + self.hass, entities_to_be_added, _handle_state_added + ) + for entity_id, entry in entity_migration_map.items(): entity_registry.async_update_entity( entity_id, @@ -382,6 +407,12 @@ def _handle_state_change( icon=entry.icon, ) + # Wait for entities to finish renaming + with suppress(asyncio.TimeoutError): + async with timeout(10): + await wait_for_entities.wait() + remove_track_state_changes() + device_registry.async_remove_device(old_device) def _can_add_device(self, new_rfx_obj: rfxtrxmod.RFXtrxEvent) -> bool: diff --git a/homeassistant/components/rfxtrx/helpers.py b/homeassistant/components/rfxtrx/helpers.py index 2badc6d4ca5484..cfc16126359d66 100644 --- a/homeassistant/components/rfxtrx/helpers.py +++ b/homeassistant/components/rfxtrx/helpers.py @@ -6,6 +6,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from . import get_device_tuple_from_identifiers + @callback def async_get_device_object(hass: HomeAssistant, device_id: str) -> RFXtrxDevice: @@ -15,7 +17,9 @@ def async_get_device_object(hass: HomeAssistant, device_id: str) -> RFXtrxDevice if registry_device is None: raise ValueError(f"Device {device_id} not found") - device_tuple = list(list(registry_device.identifiers)[0]) + device_tuple = get_device_tuple_from_identifiers(registry_device.identifiers) + assert device_tuple + return get_device( - int(device_tuple[1], 16), int(device_tuple[2], 16), device_tuple[3] + int(device_tuple[0], 16), int(device_tuple[1], 16), device_tuple[2] ) diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index e594b47c93a551..60f35a93d1a89e 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -70,14 +70,12 @@ class RfxtrxSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES = ( RfxtrxSensorEntityDescription( key="Barometer", - name="Barometer", device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.HPA, ), RfxtrxSensorEntityDescription( key="Battery numeric", - name="Battery", device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, @@ -86,49 +84,46 @@ class RfxtrxSensorEntityDescription(SensorEntityDescription): ), RfxtrxSensorEntityDescription( key="Current", - name="Current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), RfxtrxSensorEntityDescription( key="Current Ch. 1", - name="Current Ch. 1", + translation_key="current_ch_1", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), RfxtrxSensorEntityDescription( key="Current Ch. 2", - name="Current Ch. 2", + translation_key="current_ch_2", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), RfxtrxSensorEntityDescription( key="Current Ch. 3", - name="Current Ch. 3", + translation_key="current_ch_3", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), RfxtrxSensorEntityDescription( key="Energy usage", - name="Instantaneous power", + translation_key="instantaneous_power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, ), RfxtrxSensorEntityDescription( key="Humidity", - name="Humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), RfxtrxSensorEntityDescription( key="Rssi numeric", - name="Signal strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -137,108 +132,104 @@ class RfxtrxSensorEntityDescription(SensorEntityDescription): ), RfxtrxSensorEntityDescription( key="Temperature", - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), RfxtrxSensorEntityDescription( key="Temperature2", - name="Temperature 2", + translation_key="temperature_2", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), RfxtrxSensorEntityDescription( key="Total usage", - name="Total energy usage", + translation_key="total_energy_usage", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), RfxtrxSensorEntityDescription( key="Voltage", - name="Voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, ), RfxtrxSensorEntityDescription( key="Wind direction", - name="Wind direction", + translation_key="wind_direction", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=DEGREE, ), RfxtrxSensorEntityDescription( key="Rain rate", - name="Rain rate", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), RfxtrxSensorEntityDescription( key="Sound", - name="Sound", + translation_key="sound", ), RfxtrxSensorEntityDescription( key="Sensor Status", - name="Sensor status", + translation_key="sensor_status", ), RfxtrxSensorEntityDescription( key="Count", - name="Count", + translation_key="count", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement="count", ), RfxtrxSensorEntityDescription( key="Counter value", - name="Counter value", + translation_key="counter_value", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement="count", ), RfxtrxSensorEntityDescription( key="Chill", - name="Chill", + translation_key="chill", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), RfxtrxSensorEntityDescription( key="Wind average speed", - name="Wind average speed", + translation_key="wind_average_speed", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.WIND_SPEED, ), RfxtrxSensorEntityDescription( key="Wind gust", - name="Wind gust", + translation_key="wind_gust", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.WIND_SPEED, ), RfxtrxSensorEntityDescription( key="Rain total", - name="Rain total", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), RfxtrxSensorEntityDescription( key="Forecast", - name="Forecast status", + translation_key="forecast_status", ), RfxtrxSensorEntityDescription( key="Forecast numeric", - name="Forecast", + translation_key="forecast", ), RfxtrxSensorEntityDescription( key="Humidity status", - name="Humidity status", + translation_key="humidity_status", ), RfxtrxSensorEntityDescription( key="UV", - name="UV index", + translation_key="uv_index", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UV_INDEX, ), @@ -281,6 +272,7 @@ def _constructor( ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class RfxtrxSensor(RfxtrxEntity, SensorEntity): """Representation of a RFXtrx sensor. diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index 4469fd59801f41..7e68f960fca6db 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -78,5 +78,63 @@ "status": "Received status: {subtype}", "command": "Received command: {subtype}" } + }, + "entity": { + "sensor": { + "current_ch_1": { + "name": "Current Ch. 1" + }, + "current_ch_2": { + "name": "Current Ch. 2" + }, + "current_ch_3": { + "name": "Current Ch. 3" + }, + "instantaneous_power": { + "name": "Instantaneous power" + }, + "temperature_2": { + "name": "Temperature 2" + }, + "total_energy_usage": { + "name": "Total energy usage" + }, + "wind_direction": { + "name": "Wind direction" + }, + "sound": { + "name": "Sound" + }, + "sensor_status": { + "name": "Sensor status" + }, + "count": { + "name": "Count" + }, + "counter_value": { + "name": "Counter value" + }, + "chill": { + "name": "Chill" + }, + "wind_average_speed": { + "name": "Wind average speed" + }, + "wind_gust": { + "name": "Wind gust" + }, + "forecast_status": { + "name": "Forecast status" + }, + "forecast": { + "name": "Forecast" + }, + "humidity_status": { + "name": "Humidity status" + }, + "uv_index": { + "name": "UV index" + } + } } } diff --git a/homeassistant/components/ridwell/calendar.py b/homeassistant/components/ridwell/calendar.py index 57919ed1feba6b..3ef3bbdc5ae626 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 fb037eca05d024..56aad1a845b4b5 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -6,7 +6,6 @@ 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.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 @@ ] -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 7cb34b4d71fafe..355c630272e111 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 3c49dc14f516a4..73499fb5cccc8d 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", - name="Battery Charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, entity_category=EntityCategory.DIAGNOSTIC, is_on_fn=lambda diffuser: diffuser.charging, @@ -71,15 +70,6 @@ class RitualsBinarySensorEntity(DiffuserEntity, BinarySensorEntity): entity_description: RitualsBinarySensorEntityDescription - def __init__( - self, - coordinator: RitualsDataUpdateCoordinator, - description: RitualsBinarySensorEntityDescription, - ) -> None: - """Initialize Rituals binary sensor entity.""" - super().__init__(coordinator, description) - self._attr_name = f"{coordinator.diffuser.name} {description.name}" - @property def is_on(self) -> bool: """Return the state of the binary sensor.""" diff --git a/homeassistant/components/rituals_perfume_genie/diagnostics.py b/homeassistant/components/rituals_perfume_genie/diagnostics.py new file mode 100644 index 00000000000000..75b622b48b11a0 --- /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/entity.py b/homeassistant/components/rituals_perfume_genie/entity.py index e5b9f3ebd6fede..713c3905f05c42 100644 --- a/homeassistant/components/rituals_perfume_genie/entity.py +++ b/homeassistant/components/rituals_perfume_genie/entity.py @@ -15,6 +15,8 @@ class DiffuserEntity(CoordinatorEntity[RitualsDataUpdateCoordinator]): """Representation of a diffuser entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: RitualsDataUpdateCoordinator, diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 8049e53fa0d42f..3e6af33315f658 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -35,7 +35,7 @@ class RitualsNumberEntityDescription( ENTITY_DESCRIPTIONS = ( RitualsNumberEntityDescription( key="perfume_amount", - name="Perfume Amount", + translation_key="perfume_amount", icon="mdi:gauge", native_min_value=1, native_max_value=3, @@ -66,15 +66,6 @@ class RitualsNumberEntity(DiffuserEntity, NumberEntity): entity_description: RitualsNumberEntityDescription - def __init__( - self, - coordinator: RitualsDataUpdateCoordinator, - description: RitualsNumberEntityDescription, - ) -> None: - """Initialize the diffuser perfume amount number.""" - super().__init__(coordinator, description) - self._attr_name = f"{coordinator.diffuser.name} {description.name}" - @property def native_value(self) -> int: """Return the number value.""" diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index 10ad5dbf3bbb61..42e18624d13b5a 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -35,7 +35,7 @@ class RitualsSelectEntityDescription( ENTITY_DESCRIPTIONS = ( RitualsSelectEntityDescription( key="room_size_square_meter", - name="Room Size", + translation_key="room_size_square_meter", icon="mdi:ruler-square", unit_of_measurement=AREA_SQUARE_METERS, entity_category=EntityCategory.CONFIG, @@ -80,7 +80,6 @@ def __init__( self._attr_entity_registry_enabled_default = ( self.coordinator.diffuser.has_battery ) - self._attr_name = f"{coordinator.diffuser.name} {description.name}" @property def current_option(self) -> str: diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index e48bf4de2193a1..09189dabfad62a 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", - name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, value_fn=lambda diffuser: diffuser.battery_percentage, @@ -48,19 +47,19 @@ class RitualsSensorEntityDescription( ), RitualsSensorEntityDescription( key="fill", - name="Fill", + translation_key="fill", icon="mdi:beaker", value_fn=lambda diffuser: diffuser.fill, ), RitualsSensorEntityDescription( key="perfume", - name="Perfume", + translation_key="perfume", icon="mdi:tag", value_fn=lambda diffuser: diffuser.perfume, ), RitualsSensorEntityDescription( key="wifi_percentage", - name="Wifi", + translation_key="wifi_percentage", icon="mdi:wifi", native_unit_of_measurement=PERCENTAGE, value_fn=lambda diffuser: diffuser.wifi_percentage, @@ -92,15 +91,6 @@ class RitualsSensorEntity(DiffuserEntity, SensorEntity): entity_description: RitualsSensorEntityDescription _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__( - self, - coordinator: RitualsDataUpdateCoordinator, - description: RitualsSensorEntityDescription, - ) -> None: - """Initialize the diffuser sensor.""" - super().__init__(coordinator, description) - self._attr_name = f"{coordinator.diffuser.name} {description.name}" - @property def native_value(self) -> str | int: """Return the sensor value.""" diff --git a/homeassistant/components/rituals_perfume_genie/strings.json b/homeassistant/components/rituals_perfume_genie/strings.json index 8824923c3138bf..48e9be670ecd26 100644 --- a/homeassistant/components/rituals_perfume_genie/strings.json +++ b/homeassistant/components/rituals_perfume_genie/strings.json @@ -17,5 +17,28 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "number": { + "perfume_amount": { + "name": "Perfume amount" + } + }, + "select": { + "room_size_square_meter": { + "name": "Room size" + } + }, + "sensor": { + "fill": { + "name": "Fill" + }, + "perfume": { + "name": "Perfume" + }, + "wifi_percentage": { + "name": "Wi-Fi signal" + } + } } } diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index 44ff951f532696..77776704a6053e 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(), @@ -73,7 +74,6 @@ def __init__( ) -> None: """Initialize the diffuser switch.""" super().__init__(coordinator, description) - self._attr_name = coordinator.diffuser.name self._attr_is_on = description.is_on_fn(coordinator.diffuser) async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 61a9a70dd207c4..287229c9fd1d74 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -6,4 +6,4 @@ CONF_BASE_URL = "base_url" CONF_USER_DATA = "user_data" -PLATFORMS = [Platform.VACUUM, Platform.SELECT] +PLATFORMS = [Platform.VACUUM, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index acaa2bfa3f25cf..ba9571a95f5d4a 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -10,6 +10,7 @@ from roborock.roborock_typing import DeviceProp from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -32,14 +33,21 @@ def __init__( ) -> None: """Initialize.""" super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - self.device_info = RoborockHassDeviceInfo( + self.roborock_device_info = RoborockHassDeviceInfo( device, device_networking, product_info, DeviceProp(), ) - device_info = DeviceData(device, product_info.model, device_networking.ip) - self.api = RoborockLocalClient(device_info) + device_data = DeviceData(device, product_info.model, device_networking.ip) + self.api = RoborockLocalClient(device_data) + self.device_info = DeviceInfo( + name=self.roborock_device_info.device.name, + identifiers={(DOMAIN, self.roborock_device_info.device.duid)}, + manufacturer="Roborock", + model=self.roborock_device_info.product.model, + sw_version=self.roborock_device_info.device.fv, + ) async def release(self) -> None: """Disconnect from API.""" @@ -49,10 +57,10 @@ async def _update_device_prop(self) -> None: """Update device properties.""" device_prop = await self.api.get_prop() if device_prop: - if self.device_info.props: - self.device_info.props.update(device_prop) + if self.roborock_device_info.props: + self.roborock_device_info.props.update(device_prop) else: - self.device_info.props = device_prop + self.roborock_device_info.props = device_prop async def _async_update_data(self) -> DeviceProp: """Update data via library.""" @@ -60,4 +68,4 @@ async def _async_update_data(self) -> DeviceProp: await self._update_device_prop() except RoborockException as ex: raise UpdateFailed(ex) from ex - return self.device_info.props + return self.roborock_device_info.props diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 39a9524226df93..90ca13c5146ebe 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -2,19 +2,59 @@ 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 from roborock.roborock_typing import RoborockCommand from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import RoborockDataUpdateCoordinator -from .const import DOMAIN -class RoborockCoordinatedEntity(CoordinatorEntity[RoborockDataUpdateCoordinator]): +class RoborockEntity(Entity): + """Representation of a base Roborock Entity.""" + + _attr_has_entity_name = True + + def __init__( + self, unique_id: str, device_info: DeviceInfo, api: RoborockLocalClient + ) -> None: + """Initialize the coordinated Roborock Device.""" + self._attr_unique_id = unique_id + self._attr_device_info = device_info + self._api = api + + @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: + """Send a command to a vacuum cleaner.""" + try: + response = await self._api.send_command(command, params) + except RoborockException as err: + raise HomeAssistantError( + f"Error while calling {command.name} with {params}" + ) from err + + return response + + +class RoborockCoordinatedEntity( + RoborockEntity, CoordinatorEntity[RoborockDataUpdateCoordinator] +): """Representation of a base a coordinated Roborock Entity.""" _attr_has_entity_name = True @@ -25,7 +65,13 @@ def __init__( coordinator: RoborockDataUpdateCoordinator, ) -> None: """Initialize the coordinated Roborock Device.""" - super().__init__(coordinator) + RoborockEntity.__init__( + self, + unique_id=unique_id, + device_info=coordinator.device_info, + api=coordinator.api, + ) + CoordinatorEntity.__init__(self, coordinator=coordinator) self._attr_unique_id = unique_id @property @@ -38,27 +84,12 @@ def _device_status(self) -> Status: return status return Status({}) - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - name=self.coordinator.device_info.device.name, - identifiers={(DOMAIN, self.coordinator.device_info.device.duid)}, - manufacturer="Roborock", - model=self.coordinator.device_info.product.model, - sw_version=self.coordinator.device_info.device.fv, - ) - async def send( - self, command: RoborockCommand, params: dict[str, Any] | list[Any] | None = None + self, + command: RoborockCommand, + params: dict[str, Any] | list[Any] | None = None, ) -> dict: - """Send a command to a vacuum cleaner.""" - try: - response = await self.coordinator.api.send_command(command, params) - except RoborockException as err: - raise HomeAssistantError( - f"Error while calling {command.name} with {params}" - ) from err - - await self.coordinator.async_request_refresh() - return response + """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/diagnostics.py b/homeassistant/components/roborock/diagnostics.py new file mode 100644 index 00000000000000..e5fcc834267796 --- /dev/null +++ b/homeassistant/components/roborock/diagnostics.py @@ -0,0 +1,38 @@ +"""Support for the Airzone diagnostics.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator + +TO_REDACT_CONFIG = ["token", "sn", "rruid", CONF_UNIQUE_ID, "username", "uid"] + +TO_REDACT_COORD = ["duid", "localKey", "mac", "bssid"] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + + return { + "config_entry": async_redact_data(config_entry.data, TO_REDACT_CONFIG), + "coordinators": { + f"**REDACTED-{i}**": { + "roborock_device_info": async_redact_data( + coordinator.roborock_device_info.as_dict(), TO_REDACT_COORD + ), + "api": coordinator.api.diagnostic_data, + } + for i, coordinator in enumerate(coordinators.values()) + }, + } diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 44a4cba89c98f8..baab687e64a3cd 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.17.0"] + "requirements": ["python-roborock==0.29.2"] } diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index a30c84ce1da4da..c1d32df2d6d346 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -1,5 +1,6 @@ """Roborock Models.""" from dataclasses import dataclass +from typing import Any from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo from roborock.roborock_typing import DeviceProp @@ -13,3 +14,12 @@ class RoborockHassDeviceInfo: network_info: NetworkInfo product: HomeDataProduct props: DeviceProp + + def as_dict(self) -> dict[str, dict[str, Any]]: + """Turn RoborockHassDeviceInfo into a dictionary.""" + return { + "device": self.device.as_dict(), + "network_info": self.network_info.as_dict(), + "product": self.product.as_dict(), + "props": self.props.as_dict(), + } diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index d27888a17793c2..2d76aac33d3b34 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -77,7 +77,8 @@ async def async_setup_entry( ) for device_id, coordinator in coordinators.items() for description in SELECT_DESCRIPTIONS - if description.options_lambda(coordinator.device_info.props.status) is not None + if description.options_lambda(coordinator.roborock_device_info.props.status) + is not None ) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py new file mode 100644 index 00000000000000..8398995462ff73 --- /dev/null +++ b/homeassistant/components/roborock/sensor.py @@ -0,0 +1,160 @@ +"""Support for Roborock sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from roborock.containers import RoborockStateCode +from roborock.roborock_typing import DeviceProp + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import AREA_SQUARE_METERS, EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockCoordinatedEntity + + +@dataclass +class RoborockSensorDescriptionMixin: + """A class that describes sensor entities.""" + + value_fn: Callable[[DeviceProp], int] + + +@dataclass +class RoborockSensorDescription( + SensorEntityDescription, RoborockSensorDescriptionMixin +): + """A class that describes Roborock sensors.""" + + +SENSOR_DESCRIPTIONS = [ + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="main_brush_time_left", + icon="mdi:brush", + device_class=SensorDeviceClass.DURATION, + translation_key="main_brush_time_left", + value_fn=lambda data: data.consumable.main_brush_time_left, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="side_brush_time_left", + icon="mdi:brush", + device_class=SensorDeviceClass.DURATION, + translation_key="side_brush_time_left", + value_fn=lambda data: data.consumable.side_brush_time_left, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="filter_time_left", + icon="mdi:air-filter", + device_class=SensorDeviceClass.DURATION, + translation_key="filter_time_left", + value_fn=lambda data: data.consumable.filter_time_left, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="sensor_time_left", + icon="mdi:eye-outline", + device_class=SensorDeviceClass.DURATION, + translation_key="sensor_time_left", + value_fn=lambda data: data.consumable.sensor_time_left, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="cleaning_time", + translation_key="cleaning_time", + device_class=SensorDeviceClass.DURATION, + value_fn=lambda data: data.status.clean_time, + ), + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="total_cleaning_time", + translation_key="total_cleaning_time", + icon="mdi:history", + device_class=SensorDeviceClass.DURATION, + value_fn=lambda data: data.clean_summary.clean_time, + ), + RoborockSensorDescription( + key="status", + icon="mdi:information-outline", + device_class=SensorDeviceClass.ENUM, + translation_key="status", + value_fn=lambda data: data.status.state.name, + entity_category=EntityCategory.DIAGNOSTIC, + options=RoborockStateCode.keys(), + ), + 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, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Roborock vacuum sensors.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + RoborockSensorEntity( + f"{description.key}_{slugify(device_id)}", + coordinator, + description, + ) + for device_id, coordinator in coordinators.items() + for description in SENSOR_DESCRIPTIONS + if description.value_fn(coordinator.roborock_device_info.props) is not None + ) + + +class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity): + """Representation of a Roborock sensor.""" + + entity_description: RoborockSensorDescription + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + description: RoborockSensorDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(unique_id, coordinator) + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn( + self.coordinator.roborock_device_info.props + ) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 64c9d268e56cd6..e595b7abff4e8a 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -27,6 +27,60 @@ } }, "entity": { + "sensor": { + "cleaning_area": { + "name": "Cleaning area" + }, + "cleaning_time": { + "name": "Cleaning time" + }, + "main_brush_time_left": { + "name": "Main brush time left" + }, + "side_brush_time_left": { + "name": "Side brush time left" + }, + "filter_time_left": { + "name": "Filter time left" + }, + "sensor_time_left": { + "name": "Sensor time left" + }, + "status": { + "name": "Status", + "state": { + "starting": "Starting", + "charger_disconnected": "Charger disconnected", + "idle": "Idle", + "remote_control_active": "Remote control active", + "cleaning": "Cleaning", + "returning_home": "Returning home", + "manual_mode": "Manual mode", + "charging": "Charging", + "charging_problem": "Charging problem", + "paused": "Paused", + "spot_cleaning": "Spot cleaning", + "error": "Error", + "shutting_down": "Shutting down", + "updating": "Updating", + "docking": "Docking", + "going_to_target": "Going to target", + "zoned_cleaning": "Zoned cleaning", + "segment_cleaning": "Segment cleaning", + "emptying_the_bin": "Emptying the bin", + "washing_the_mop": "Washing the mop", + "going_to_wash_the_mop": "Going to wash the mop", + "charging_complete": "Charging complete", + "device_offline": "Device offline" + } + }, + "total_cleaning_time": { + "name": "Total cleaning time" + }, + "total_cleaning_area": { + "name": "Total cleaning area" + } + }, "select": { "mop_mode": { "name": "Mop mode", @@ -41,13 +95,55 @@ "mop_intensity": { "name": "Mop intensity", "state": { - "off": "Off", + "off": "[%key:common::state::off%]", + "low": "Low", "mild": "Mild", + "medium": "Medium", "moderate": "Moderate", + "high": "High", "intense": "Intense", "custom": "Custom" } } + }, + "switch": { + "child_lock": { + "name": "Child lock" + }, + "dnd_switch": { + "name": "Do not disturb" + }, + "status_indicator": { + "name": "Status indicator light" + } + }, + "vacuum": { + "roborock": { + "state_attributes": { + "fan_speed": { + "state": { + "auto": "Auto", + "balanced": "Balanced", + "custom": "Custom", + "gentle": "Gentle", + "off": "[%key:common::state::off%]", + "max": "Max", + "max_plus": "Max plus", + "medium": "Medium", + "quiet": "Quiet", + "silent": "Silent", + "standard": "Standard", + "turbo": "Turbo" + } + } + } + } + } + }, + "issues": { + "service_deprecation_start_pause": { + "title": "Roborock vacuum support for vacuum.start_pause is being removed", + "description": "Roborock vacuum support for the vacuum.start_pause service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call vacuum.pause or vacuum.start and select submit below to mark this issue as resolved." } } } diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py new file mode 100644 index 00000000000000..a0b3d5be29597a --- /dev/null +++ b/homeassistant/components/roborock/switch.py @@ -0,0 +1,168 @@ +"""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.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 + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class RoborockSwitchDescriptionMixin: + """Define an entity description mixin for switch entities.""" + + # Gets the status of the switch + cache_key: CacheableAttribute + # Sets the status of the switch + update_value: Callable[[AttributeCache, bool], Coroutine[Any, Any, dict]] + # Attribute from cache + attribute: str + + +@dataclass +class RoborockSwitchDescription( + SwitchEntityDescription, RoborockSwitchDescriptionMixin +): + """Class to describe an Roborock switch entity.""" + + +SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ + RoborockSwitchDescription( + cache_key=CacheableAttribute.child_lock_status, + update_value=lambda cache, value: cache.update_value( + {"lock_status": 1 if value else 0} + ), + attribute="lock_status", + key="child_lock", + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + RoborockSwitchDescription( + cache_key=CacheableAttribute.flow_led_status, + update_value=lambda cache, value: cache.update_value( + {"status": 1 if value else 0} + ), + 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, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roborock switch platform.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + possible_entities: list[ + tuple[RoborockDataUpdateCoordinator, RoborockSwitchDescription] + ] = [ + (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( + *( + coordinator.api.cache.get(description.cache_key).async_value() + for coordinator, description in possible_entities + ), + return_exceptions=True, + ) + 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( + RoborockSwitch( + f"{description.key}_{slugify(coordinator.roborock_device_info.device.duid)}", + coordinator.device_info, + description, + coordinator.api, + ) + ) + async_add_entities(valid_entities) + + +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, + device_info: DeviceInfo, + description: RoborockSwitchDescription, + api: RoborockLocalClient, + ) -> None: + """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.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.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 666b8488d80333..804c082657882a 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -16,6 +16,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify @@ -75,13 +76,14 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.BATTERY - | VacuumEntityFeature.STATUS | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.LOCATE | VacuumEntityFeature.CLEAN_SPOT | VacuumEntityFeature.STATE | VacuumEntityFeature.START ) + _attr_translation_key = DOMAIN + _attr_name = None def __init__( self, @@ -108,11 +110,6 @@ def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" return self._device_status.fan_power.name - @property - def status(self) -> str | None: - """Return the status of the vacuum cleaner.""" - return self._device_status.state.name - async def async_start(self) -> None: """Start the vacuum.""" await self.send(RoborockCommand.APP_START) @@ -143,7 +140,6 @@ async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: 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.""" @@ -151,6 +147,16 @@ async def async_start_pause(self) -> None: await self.async_pause() else: await self.async_start() + ir.async_create_issue( + self.hass, + DOMAIN, + "service_deprecation_start_pause", + breaks_in_ha_version="2024.2.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation_start_pause", + ) async def async_send_command( self, diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index 7f922c2eea520f..583d26a4a5b684 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -22,13 +22,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Roku from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - if not (coordinator := hass.data[DOMAIN].get(entry.entry_id)): - coordinator = RokuDataUpdateCoordinator(hass, host=entry.data[CONF_HOST]) - hass.data[DOMAIN][entry.entry_id] = coordinator - + coordinator = RokuDataUpdateCoordinator(hass, host=entry.data[CONF_HOST]) await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -36,7 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + 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/roku/manifest.json b/homeassistant/components/roku/manifest.json index 944304aa6453d8..f9b81dc8ddd160 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -11,7 +11,7 @@ "iot_class": "local_polling", "loggers": ["rokuecp"], "quality_scale": "silver", - "requirements": ["rokuecp==0.17.1"], + "requirements": ["rokuecp==0.18.0"], "ssdp": [ { "st": "roku:ecp", diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 877e58233d5b00..a8c1cf4698c447 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 fceac67a477696..0271e4a0f730f0 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/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index 8ec91acf96540c..317209886bd2b6 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -39,7 +39,6 @@ | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.START | VacuumEntityFeature.STATE - | VacuumEntityFeature.STATUS | VacuumEntityFeature.STOP | VacuumEntityFeature.LOCATE ) diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 3bcafe4ba9a2df..6d096ea8b1a309 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 @@ def device_info(self) -> DeviceInfo | None: 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/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 728c40121e0c40..4f35bf697363dc 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/russound_rio", "iot_class": "local_push", "loggers": ["russound_rio"], - "requirements": ["russound_rio==0.1.8"] + "requirements": ["russound-rio==1.0.0"] } diff --git a/homeassistant/components/ruuvi_gateway/bluetooth.py b/homeassistant/components/ruuvi_gateway/bluetooth.py index 4dd973155a9c53..47a9bbfdde0b65 100644 --- a/homeassistant/components/ruuvi_gateway/bluetooth.py +++ b/homeassistant/components/ruuvi_gateway/bluetooth.py @@ -9,6 +9,7 @@ from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + MONOTONIC_TIME, BaseHaRemoteScanner, async_get_advertisement_callback, async_register_scanner, @@ -47,6 +48,7 @@ def __init__( @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 @@ def _async_handle_new_data(self) -> None: 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 6cabecb7912cea..fa8ec80423c47b 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/rympro/sensor.py b/homeassistant/components/rympro/sensor.py index 80675d9dec8cf9..2c1a3ecee118e8 100644 --- a/homeassistant/components/rympro/sensor.py +++ b/homeassistant/components/rympro/sensor.py @@ -34,7 +34,7 @@ class RymProSensor(CoordinatorEntity[RymProDataUpdateCoordinator], SensorEntity) """Sensor for RymPro meters.""" _attr_has_entity_name = True - _attr_name = "Total consumption" + _attr_translation_key = "total_consumption" _attr_device_class = SensorDeviceClass.WATER _attr_native_unit_of_measurement = UnitOfVolume.CUBIC_METERS _attr_state_class = SensorStateClass.TOTAL_INCREASING diff --git a/homeassistant/components/rympro/strings.json b/homeassistant/components/rympro/strings.json index b6e7adc9631e99..2909d6c1b9b848 100644 --- a/homeassistant/components/rympro/strings.json +++ b/homeassistant/components/rympro/strings.json @@ -16,5 +16,12 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "total_consumption": { + "name": "Total consumption" + } + } } } diff --git a/homeassistant/components/safe_mode/__init__.py b/homeassistant/components/safe_mode/__init__.py index 2f010fe79c9dff..3ed2d4476af96a 100644 --- a/homeassistant/components/safe_mode/__init__.py +++ b/homeassistant/components/safe_mode/__init__.py @@ -1,10 +1,13 @@ """The Safe Mode integration.""" from homeassistant.components import persistent_notification from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DOMAIN = "safe_mode" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Safe Mode component.""" diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index a5deb3ca629e87..b7d400ce8316f4 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -49,7 +49,7 @@ UPNP_SVC_RENDERING_CONTROL, ) -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index a2558367995a6f..0cc4dd556d5542 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -6,6 +6,7 @@ from asyncio.exceptions import TimeoutError as AsyncioTimeoutError from collections.abc import Callable, Iterable, Mapping import contextlib +from datetime import datetime, timedelta from typing import Any, Generic, TypeVar, cast from samsungctl import Remote @@ -43,8 +44,10 @@ CONF_TOKEN, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import entity_component from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.util import dt as dt_util from .const import ( CONF_DESCRIPTION, @@ -67,6 +70,13 @@ WEBSOCKET_PORTS, ) +# Since the TV will take a few seconds to go to sleep +# and actually be seen as off, we need to wait just a bit +# more than the next scan interval +SCAN_INTERVAL_PLUS_OFF_TIME = entity_component.DEFAULT_SCAN_INTERVAL + timedelta( + seconds=5 +) + KEY_PRESS_TIMEOUT = 1.2 ENCRYPTED_MODEL_USES_POWER_OFF = {"H6400", "H6410"} @@ -161,6 +171,10 @@ def __init__( self._update_config_entry: Callable[[Mapping[str, Any]], None] | None = None self._app_list_callback: Callable[[dict[str, str]], None] | None = None + # Mark the end of a shutdown command (need to wait 15 seconds before + # sending the next command to avoid turning the TV back ON). + self._end_of_power_off: datetime | None = None + def register_reauth_callback(self, func: CALLBACK_TYPE) -> None: """Register a callback function.""" self._reauth_callback = func @@ -203,8 +217,17 @@ async def async_is_on(self) -> bool: async def async_send_keys(self, keys: list[str]) -> None: """Send a list of keys to the tv.""" + @property + def power_off_in_progress(self) -> bool: + """Return if power off has been recently requested.""" + return ( + self._end_of_power_off is not None + and self._end_of_power_off > dt_util.utcnow() + ) + async def async_power_off(self) -> None: """Send power off command to remote and close.""" + self._end_of_power_off = dt_util.utcnow() + SCAN_INTERVAL_PLUS_OFF_TIME await self._async_send_power_off() # Force closing of remote session to provide instant UI feedback await self.async_close_remote() @@ -526,7 +549,7 @@ async def async_try_connect(self) -> str: 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 f98e3667b592ba..124dab73004e63 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -184,7 +184,6 @@ async def _async_create_bridge(self) -> None: 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 new file mode 100644 index 00000000000000..4d5ea3d5fab068 --- /dev/null +++ b/homeassistant/components/samsungtv/entity.py @@ -0,0 +1,36 @@ +"""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 +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .bridge import SamsungTVBridge +from .const import CONF_MANUFACTURER, DOMAIN + + +class SamsungTVEntity(Entity): + """Defines a base SamsungTV entity.""" + + def __init__(self, *, bridge: SamsungTVBridge, config_entry: ConfigEntry) -> None: + """Initialize the SamsungTV entity.""" + self._bridge = bridge + self._mac = config_entry.data.get(CONF_MAC) + self._attr_name = config_entry.data.get(CONF_NAME) + self._attr_unique_id = config_entry.unique_id + self._attr_device_info = DeviceInfo( + # 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), + ) + if self.unique_id: + self._attr_device_info["identifiers"] = {(DOMAIN, self.unique_id)} + if self._mac: + self._attr_device_info["connections"] = { + (dr.CONNECTION_NETWORK_MAC, self._mac) + } diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index d4c04942e63c91..2f82c979b9405b 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -3,7 +3,6 @@ import asyncio from collections.abc import Coroutine, Sequence -from datetime import datetime, timedelta from typing import Any import async_timeout @@ -31,26 +30,16 @@ MediaType, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_NAME +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_component, -) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.trigger import PluggableAction -from homeassistant.util import dt as dt_util from .bridge import SamsungTVBridge, SamsungTVWSBridge -from .const import ( - CONF_MANUFACTURER, - CONF_SSDP_RENDERING_CONTROL_LOCATION, - DOMAIN, - LOGGER, -) +from .const import CONF_SSDP_RENDERING_CONTROL_LOCATION, DOMAIN, LOGGER +from .entity import SamsungTVEntity from .triggers.turn_on import async_get_turn_on_trigger SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"} @@ -67,12 +56,6 @@ | MediaPlayerEntityFeature.PLAY_MEDIA ) -# Since the TV will take a few seconds to go to sleep -# and actually be seen as off, we need to wait just a bit -# more than the next scan interval -SCAN_INTERVAL_PLUS_OFF_TIME = entity_component.DEFAULT_SCAN_INTERVAL + timedelta( - seconds=5 -) # Max delay waiting for app_list to return, as some TVs simply ignore the request APP_LIST_DELAY = 3 @@ -86,7 +69,7 @@ async def async_setup_entry( async_add_entities([SamsungTVDevice(bridge, entry)], True) -class SamsungTVDevice(MediaPlayerEntity): +class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): """Representation of a Samsung TV.""" _attr_source_list: list[str] @@ -97,9 +80,9 @@ def __init__( config_entry: ConfigEntry, ) -> None: """Initialize the Samsung device.""" + super().__init__(bridge=bridge, config_entry=config_entry) self._config_entry = config_entry self._host: str | None = config_entry.data[CONF_HOST] - self._mac: str | None = config_entry.data.get(CONF_MAC) self._ssdp_rendering_control_location: str | None = config_entry.data.get( CONF_SSDP_RENDERING_CONTROL_LOCATION ) @@ -107,8 +90,6 @@ def __init__( # Assume that the TV is in Play mode self._playing: bool = True - self._attr_name: str | None = config_entry.data.get(CONF_NAME) - self._attr_unique_id = config_entry.unique_id self._attr_is_volume_muted: bool = False self._attr_device_class = MediaPlayerDeviceClass.TV self._attr_source_list = list(SOURCES) @@ -123,22 +104,6 @@ def __init__( if self._ssdp_rendering_control_location: self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET - self._attr_device_info = DeviceInfo( - name=self.name, - manufacturer=config_entry.data.get(CONF_MANUFACTURER), - model=config_entry.data.get(CONF_MODEL), - ) - if self.unique_id: - self._attr_device_info["identifiers"] = {(DOMAIN, self.unique_id)} - if self._mac: - self._attr_device_info["connections"] = { - (dr.CONNECTION_NETWORK_MAC, self._mac) - } - - # Mark the end of a shutdown command (need to wait 15 seconds before - # sending the next command to avoid turning the TV back ON). - self._end_of_power_off: datetime | None = None - self._bridge = bridge self._auth_failed = False self._bridge.register_reauth_callback(self.access_denied) self._bridge.register_app_list_callback(self._app_list_callback) @@ -190,7 +155,7 @@ async def async_update(self) -> None: if self._auth_failed or self.hass.is_stopping: return old_state = self._attr_state - if self._power_off_in_progress(): + if self._bridge.power_off_in_progress: self._attr_state = MediaPlayerState.OFF else: self._attr_state = ( @@ -333,7 +298,7 @@ def _on_upnp_event( async def _async_launch_app(self, app_id: str) -> None: """Send launch_app to the tv.""" - if self._power_off_in_progress(): + if self._bridge.power_off_in_progress: LOGGER.info("TV is powering off, not sending launch_app command") return assert isinstance(self._bridge, SamsungTVWSBridge) @@ -342,17 +307,11 @@ async def _async_launch_app(self, app_id: str) -> None: async def _async_send_keys(self, keys: list[str]) -> None: """Send a key to the tv and handles exceptions.""" assert keys - if self._power_off_in_progress() and keys[0] != "KEY_POWEROFF": + if self._bridge.power_off_in_progress and keys[0] != "KEY_POWEROFF": LOGGER.info("TV is powering off, not sending keys: %s", keys) return await self._bridge.async_send_keys(keys) - def _power_off_in_progress(self) -> bool: - return ( - self._end_of_power_off is not None - and self._end_of_power_off > dt_util.utcnow() - ) - @property def available(self) -> bool: """Return the availability of the device.""" @@ -362,7 +321,7 @@ def available(self) -> bool: self.state == MediaPlayerState.ON or bool(self._turn_on) or self._mac is not None - or self._power_off_in_progress() + or self._bridge.power_off_in_progress ) async def async_added_to_hass(self) -> None: @@ -378,7 +337,6 @@ async def async_added_to_hass(self) -> None: async def async_turn_off(self) -> None: """Turn off media player.""" - self._end_of_power_off = dt_util.utcnow() + SCAN_INTERVAL_PLUS_OFF_TIME await self._bridge.async_power_off() async def async_set_volume_level(self, volume: float) -> None: diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py new file mode 100644 index 00000000000000..22857d96659638 --- /dev/null +++ b/homeassistant/components/samsungtv/remote.py @@ -0,0 +1,47 @@ +"""Support for the SamsungTV remote.""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, LOGGER +from .entity import SamsungTVEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Samsung TV from a config entry.""" + bridge = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SamsungTVRemote(bridge=bridge, config_entry=entry)]) + + +class SamsungTVRemote(SamsungTVEntity, RemoteEntity): + """Device that sends commands to a SamsungTV.""" + + _attr_should_poll = False + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self._bridge.async_power_off() + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a command to a device. + + Supported keys vary between models. + See https://github.com/jaruba/ha-samsungtv-tizen/blob/master/Key_codes.md + """ + if self._bridge.power_off_in_progress: + LOGGER.info("TV is powering off, not sending keys: %s", command) + return + + num_repeats = kwargs[ATTR_NUM_REPEATS] + command_list = list(command) + + for _ in range(num_repeats): + await self._bridge.async_send_keys(command_list) diff --git a/homeassistant/components/satel_integra/binary_sensor.py b/homeassistant/components/satel_integra/binary_sensor.py index 389bde884ef62e..5d2ce2c193c348 100644 --- a/homeassistant/components/satel_integra/binary_sensor.py +++ b/homeassistant/components/satel_integra/binary_sensor.py @@ -80,11 +80,10 @@ async def async_added_to_hass(self) -> None: 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 ffb2c1a3af2d15..828261aa466eea 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 857d53eb5276ff..e5ed8613fc48fb 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 6662c20ad4f6f1..3370c196c3c902 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -168,4 +168,3 @@ async def _async_update_data(self) -> 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 9c4137c1beac2d..8530aa3b04c143 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 @@ 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 @@ -160,9 +168,25 @@ def scripts_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list[str ] +@callback +def blueprint_in_script(hass: HomeAssistant, entity_id: str) -> str | None: + """Return the blueprint the script is based on or None.""" + if DOMAIN not in hass.data: + return None + + component: EntityComponent[BaseScriptEntity] = hass.data[DOMAIN] + + if (script_entity := component.get_entity(entity_id)) is None: + return None + + return script_entity.referenced_blueprint + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Load the scripts from the configuration.""" - hass.data[DOMAIN] = component = EntityComponent[ScriptEntity](LOGGER, DOMAIN, hass) + hass.data[DOMAIN] = component = EntityComponent[BaseScriptEntity]( + LOGGER, DOMAIN, hass + ) # Process integration platforms right away since # we will create entities before firing EVENT_COMPONENT_LOADED @@ -239,6 +263,7 @@ class ScriptEntityConfig: key: str raw_blueprint_inputs: ConfigType | None raw_config: ConfigType | None + validation_failed: bool async def _prepare_script_config( @@ -253,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 @@ -263,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, @@ -281,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. @@ -317,7 +358,7 @@ def find_matches( 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) @@ -338,7 +379,78 @@ def find_matches( 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 @@ -400,6 +512,11 @@ def is_on(self): """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.""" @@ -407,6 +524,16 @@ def referenced_blueprint(self): 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.""" @@ -422,6 +549,12 @@ async def async_turn_on(self, **kwargs): 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, @@ -430,8 +563,7 @@ async def async_turn_on(self, **kwargs): ) 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 @@ -443,6 +575,7 @@ async def async_turn_on(self, **kwargs): # 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( @@ -469,16 +602,25 @@ async def async_turn_off(self, **kwargs): """ 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.