diff --git a/.core_files.yaml b/.core_files.yaml index 7bf7a09b36..7933556b60 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -125,6 +125,7 @@ tests: &tests - tests/mock/** - tests/pylint/** - tests/scripts/** + - tests/syrupy.py - tests/test_util/** - tests/testing_config/** - tests/util/** diff --git a/.coveragerc b/.coveragerc index 20ee077ffa..4827d93ed5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -36,6 +36,7 @@ omit = homeassistant/components/airnow/__init__.py homeassistant/components/airnow/sensor.py homeassistant/components/airq/__init__.py + homeassistant/components/airq/coordinator.py homeassistant/components/airq/sensor.py homeassistant/components/airthings/__init__.py homeassistant/components/airthings/sensor.py @@ -197,7 +198,6 @@ omit = homeassistant/components/denonavr/__init__.py homeassistant/components/denonavr/media_player.py homeassistant/components/denonavr/receiver.py - homeassistant/components/devolo_home_control/switch.py homeassistant/components/digital_ocean/* homeassistant/components/discogs/sensor.py homeassistant/components/discord/__init__.py @@ -226,6 +226,7 @@ omit = homeassistant/components/dublin_bus_transport/sensor.py homeassistant/components/dunehd/__init__.py homeassistant/components/dunehd/media_player.py + homeassistant/components/dwd_weather_warnings/const.py homeassistant/components/dwd_weather_warnings/sensor.py homeassistant/components/dweet/* homeassistant/components/ebox/sensor.py @@ -385,7 +386,10 @@ omit = homeassistant/components/foscam/camera.py homeassistant/components/foursquare/* homeassistant/components/free_mobile/notify.py + homeassistant/components/freebox/camera.py homeassistant/components/freebox/device_tracker.py + homeassistant/components/freebox/home_base.py + homeassistant/components/freebox/router.py homeassistant/components/freebox/sensor.py homeassistant/components/freebox/switch.py homeassistant/components/fritz/common.py @@ -479,8 +483,6 @@ omit = homeassistant/components/homematic/sensor.py homeassistant/components/homematic/switch.py homeassistant/components/homeworks/* - homeassistant/components/honeywell/__init__.py - homeassistant/components/honeywell/climate.py homeassistant/components/horizon/media_player.py homeassistant/components/hp_ilo/sensor.py homeassistant/components/huawei_lte/__init__.py @@ -518,9 +520,6 @@ omit = homeassistant/components/ifttt/alarm_control_panel.py homeassistant/components/iglo/light.py homeassistant/components/ihc/* - homeassistant/components/imap/__init__.py - homeassistant/components/imap/coordinator.py - homeassistant/components/imap/sensor.py homeassistant/components/imap_email_content/sensor.py homeassistant/components/incomfort/* homeassistant/components/insteon/binary_sensor.py @@ -629,9 +628,6 @@ omit = homeassistant/components/lg_netcast/media_player.py homeassistant/components/lg_soundbar/__init__.py homeassistant/components/lg_soundbar/media_player.py - homeassistant/components/lidarr/__init__.py - homeassistant/components/lidarr/coordinator.py - homeassistant/components/lidarr/sensor.py homeassistant/components/life360/__init__.py homeassistant/components/life360/coordinator.py homeassistant/components/life360/device_tracker.py @@ -642,6 +638,7 @@ omit = homeassistant/components/linux_battery/sensor.py homeassistant/components/lirc/* homeassistant/components/livisi/__init__.py + homeassistant/components/livisi/binary_sensor.py homeassistant/components/livisi/climate.py homeassistant/components/livisi/coordinator.py homeassistant/components/livisi/entity.py @@ -677,7 +674,6 @@ omit = homeassistant/components/lyric/api.py homeassistant/components/lyric/climate.py homeassistant/components/lyric/sensor.py - homeassistant/components/magicseaweed/sensor.py homeassistant/components/mailgun/notify.py homeassistant/components/map/* homeassistant/components/mastodon/notify.py @@ -778,7 +774,11 @@ omit = homeassistant/components/nexia/climate.py homeassistant/components/nexia/entity.py homeassistant/components/nexia/switch.py - homeassistant/components/nextcloud/* + homeassistant/components/nextcloud/__init__.py + homeassistant/components/nextcloud/binary_sensor.py + homeassistant/components/nextcloud/coordinator.py + homeassistant/components/nextcloud/entity.py + homeassistant/components/nextcloud/sensor.py homeassistant/components/nfandroidtv/__init__.py homeassistant/components/nfandroidtv/notify.py homeassistant/components/nibe_heatpump/__init__.py @@ -833,6 +833,7 @@ omit = homeassistant/components/onvif/event.py homeassistant/components/onvif/parsers.py homeassistant/components/onvif/sensor.py + homeassistant/components/onvif/util.py homeassistant/components/open_meteo/weather.py homeassistant/components/opencv/* homeassistant/components/openevse/sensor.py @@ -940,6 +941,7 @@ omit = homeassistant/components/pushover/notify.py homeassistant/components/pushsafer/notify.py homeassistant/components/pyload/sensor.py + homeassistant/components/qbittorrent/__init__.py homeassistant/components/qbittorrent/sensor.py homeassistant/components/qnap/sensor.py homeassistant/components/qrcode/image_processing.py @@ -992,10 +994,12 @@ omit = homeassistant/components/rest/notify.py homeassistant/components/rest/switch.py homeassistant/components/ridwell/__init__.py + homeassistant/components/ridwell/calendar.py homeassistant/components/ridwell/coordinator.py homeassistant/components/ridwell/switch.py homeassistant/components/ring/camera.py homeassistant/components/ripple/sensor.py + homeassistant/components/roborock/coordinator.py homeassistant/components/rocketchat/notify.py homeassistant/components/roomba/__init__.py homeassistant/components/roomba/binary_sensor.py @@ -1101,7 +1105,9 @@ omit = homeassistant/components/sms/notify.py homeassistant/components/sms/sensor.py homeassistant/components/smtp/notify.py - homeassistant/components/snapcast/* + homeassistant/components/snapcast/__init__.py + homeassistant/components/snapcast/media_player.py + homeassistant/components/snapcast/server.py homeassistant/components/snmp/device_tracker.py homeassistant/components/snmp/sensor.py homeassistant/components/snmp/switch.py @@ -1362,6 +1368,7 @@ omit = homeassistant/components/velbus/entity.py homeassistant/components/velbus/light.py homeassistant/components/velbus/sensor.py + homeassistant/components/velbus/select.py homeassistant/components/velbus/switch.py homeassistant/components/velux/__init__.py homeassistant/components/velux/cover.py @@ -1379,7 +1386,6 @@ omit = homeassistant/components/verisure/sensor.py homeassistant/components/verisure/switch.py homeassistant/components/versasense/* - homeassistant/components/vesync/common.py homeassistant/components/vesync/fan.py homeassistant/components/vesync/light.py homeassistant/components/vesync/sensor.py @@ -1435,7 +1441,6 @@ omit = homeassistant/components/xbox/media_player.py homeassistant/components/xbox/remote.py homeassistant/components/xbox/sensor.py - homeassistant/components/xbox_live/sensor.py homeassistant/components/xeoma/camera.py homeassistant/components/xiaomi/camera.py homeassistant/components/xiaomi_aqara/__init__.py @@ -1508,8 +1513,8 @@ omit = homeassistant/components/zeversolar/coordinator.py homeassistant/components/zeversolar/entity.py homeassistant/components/zeversolar/sensor.py - homeassistant/components/zha/api.py - homeassistant/components/zha/core/channels/* + homeassistant/components/zha/websocket_api.py + homeassistant/components/zha/core/cluster_handlers/* homeassistant/components/zha/core/device.py homeassistant/components/zha/core/gateway.py homeassistant/components/zha/core/helpers.py diff --git a/.gitattributes b/.gitattributes index e70ab0a2c7..eca98fc228 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,5 +8,6 @@ *.png binary *.zip binary *.mp3 binary +*.pcm binary Dockerfile.dev linguist-language=Dockerfile diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5bb755750e..237fc2888a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -31,9 +31,9 @@ body: label: What version of Home Assistant Core has the issue? placeholder: core- description: > - Can be found in: [Settings -> About](https://my.home-assistant.io/redirect/info/). + Can be found in: [Settings ⇒ System ⇒ Repairs ⇒ Three Dots in Upper Right ⇒ System information](https://my.home-assistant.io/redirect/system_health/). - [![Open your Home Assistant instance and show your Home Assistant version information.](https://my.home-assistant.io/badges/info.svg)](https://my.home-assistant.io/redirect/info/) + [![Open your Home Assistant instance and show the system information.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) - type: input attributes: label: What was the last working version of Home Assistant Core? @@ -46,9 +46,9 @@ body: attributes: label: What type of installation are you running? description: > - Can be found in: [Settings -> System-> Repairs -> Three Dots in Upper Right -> System information](https://my.home-assistant.io/redirect/system_health/). + Can be found in: [Settings ⇒ System ⇒ Repairs ⇒ Three Dots in Upper Right ⇒ System information](https://my.home-assistant.io/redirect/system_health/). - [![Open your Home Assistant instance and show health information about your system.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) + [![Open your Home Assistant instance and show the system information.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) options: - Home Assistant OS - Home Assistant Container diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index bc21a2e3c7..06a95f4cc9 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,12 +24,12 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 with: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -67,10 +67,10 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -105,7 +105,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -131,7 +131,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -249,7 +249,7 @@ jobs: - yellow steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set build additional args run: | @@ -292,7 +292,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -331,7 +331,7 @@ jobs: - "homeassistant" steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Login to DockerHub if: matrix.registry == 'homeassistant' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d0dafda421..6fad657344 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,5 @@ name: CI +run-name: "${{ github.event_name == 'workflow_dispatch' && format('CI: {0}', github.ref_name) || '' }}" # yamllint disable-line rule:truthy on: @@ -31,7 +32,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 - HA_SHORT_VERSION: 2023.4 + HA_SHORT_VERSION: 2023.5 DEFAULT_PYTHON: "3.10" ALL_PYTHON_VERSIONS: "['3.10', '3.11']" # 10.3 is the oldest supported version @@ -40,7 +41,9 @@ env: # - 10.6.10 is the version currently shipped with the Add-on (as of 31 Jan 2023) # 10.10 is the latest short-term-support # - 10.10.3 is the latest (as of 6 Feb 2023) - MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3']" + # mysql 8.0.32 does not always behave the same as MariaDB + # and some queries that work on MariaDB do not work on MySQL + MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mysql:8.0.32']" # 12 is the oldest supported version # - 12.14 is the latest (as of 9 Feb 2023) # 15 is the latest version @@ -79,7 +82,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -203,10 +206,10 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -248,9 +251,9 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -294,9 +297,9 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -343,9 +346,9 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -381,9 +384,9 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -434,6 +437,7 @@ jobs: shell: bash run: | . venv/bin/activate + shopt -s globstar pre-commit run --hook-stage manual prettier --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} - name: Register check executables problem matcher @@ -487,10 +491,10 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -539,7 +543,7 @@ jobs: python -m venv venv . venv/bin/activate python --version - pip install --cache-dir=$PIP_CACHE -U "pip>=21.0,<23.1" setuptools wheel + pip install --cache-dir=$PIP_CACHE -U "pip>=21.0,<23.2" setuptools wheel pip install --cache-dir=$PIP_CACHE -r requirements_all.txt --use-deprecated=legacy-resolver pip install --cache-dir=$PIP_CACHE -r requirements_test.txt --use-deprecated=legacy-resolver pip install -e . @@ -555,10 +559,10 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -587,10 +591,10 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -620,10 +624,10 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -664,10 +668,10 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -730,10 +734,10 @@ jobs: name: Run pip check ${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -783,10 +787,10 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -909,10 +913,10 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1017,10 +1021,10 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1091,16 +1095,28 @@ jobs: needs: - info - pytest + timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v3.1.1 + uses: Wandalen/wretry.action@v1.0.36 with: - flags: full-suite + action: codecov/codecov-action@v3.1.3 + with: | + fail_ci_if_error: true + flags: full-suite + attempt_limit: 5 + attempt_delay: 30000 - name: Upload coverage to Codecov (partial coverage) if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v3.1.1 + uses: Wandalen/wretry.action@v1.0.36 + with: + action: codecov/codecov-action@v3.1.3 + with: | + fail_ci_if_error: true + attempt_limit: 5 + attempt_delay: 30000 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d8aaa998ac..5fb977f74d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: # - No PRs marked as no-stale # - No issues (-1) - name: 90 days stale PRs policy - uses: actions/stale@v7.0.0 + uses: actions/stale@v8.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 90 @@ -53,7 +53,7 @@ jobs: # - No issues marked as no-stale or help-wanted # - No PRs (-1) - name: 90 days stale issues - uses: actions/stale@v7.0.0 + uses: actions/stale@v8.0.0 with: repo-token: ${{ steps.token.outputs.token }} days-before-stale: 90 @@ -83,7 +83,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v7.0.0 + uses: actions/stale@v8.0.0 with: repo-token: ${{ steps.token.outputs.token }} only-labels: "needs-more-information" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index b8cbd9204b..a18c050024 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,10 +19,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 1086738283..b6a00492e3 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -13,6 +13,10 @@ on: - "requirements.txt" - "requirements_all.txt" +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name}} + cancel-in-progress: true + jobs: init: name: Initialize wheels builder @@ -22,7 +26,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Get information id: info @@ -72,17 +76,18 @@ jobs: path: ./requirements_diff.txt core: - name: Build musllinux wheels with musllinux_1_2 / cp310 at ${{ matrix.arch }} for core + name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2) if: github.repository_owner == 'home-assistant' needs: init runs-on: ubuntu-latest strategy: fail-fast: false matrix: + abi: ["cp310", "cp311"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Download env_file uses: actions/download-artifact@v3 @@ -95,9 +100,9 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2022.10.1 + uses: home-assistant/wheels@2023.04.0 with: - abi: cp310 + abi: ${{ matrix.abi }} tag: musllinux_1_2 arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} @@ -108,18 +113,19 @@ jobs: requirements-diff: "requirements_diff.txt" requirements: "requirements.txt" - integrations: - name: Build musllinux wheels with musllinux_1_2 / cp310 at ${{ matrix.arch }} for integrations + integrations_cp310: + name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }} if: github.repository_owner == 'home-assistant' needs: init runs-on: ubuntu-latest strategy: fail-fast: false matrix: + abi: ["cp310"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.2 - name: Download env_file uses: actions/download-artifact@v3 @@ -135,6 +141,7 @@ jobs: run: | requirement_files="requirements_all.txt requirements_diff.txt" for requirement_file in ${requirement_files}; do + sed -i "s|# azure-servicebus|azure-servicebus|g" ${requirement_file} sed -i "s|# pybluez|pybluez|g" ${requirement_file} sed -i "s|# beacontools|beacontools|g" ${requirement_file} sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} @@ -171,30 +178,177 @@ jobs: sed -i "/numpy/d" homeassistant/package_constraints.txt - name: Build wheels (part 1) - uses: home-assistant/wheels@2022.10.1 + uses: home-assistant/wheels@2023.04.0 with: - abi: cp310 + abi: ${{ matrix.abi }} tag: musllinux_1_2 arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio;sqlalchemy + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + skip-binary: aiohttp;grpcio;sqlalchemy;protobuf legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" - name: Build wheels (part 2) - uses: home-assistant/wheels@2022.10.1 + uses: home-assistant/wheels@2023.04.0 with: - abi: cp310 + abi: ${{ matrix.abi }} tag: musllinux_1_2 arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio;sqlalchemy + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + skip-binary: aiohttp;grpcio;sqlalchemy;protobuf + legacy: true + constraints: "homeassistant/package_constraints.txt" + requirements-diff: "requirements_diff.txt" + requirements: "requirements_all.txtab" + + # Wheels building for the cp311 ABI is currently split + # This is mainly until we have figured out to get all wheels built. + # Without harming our current workflow. + integrations_cp311: + name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }} + if: github.repository_owner == 'home-assistant' + needs: init + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + abi: ["cp311"] + arch: ${{ fromJson(needs.init.outputs.architectures) }} + steps: + - name: Checkout the repository + uses: actions/checkout@v3.5.2 + + - name: Write alternative env-file for cp311 + run: | + ( + echo "GRPC_BUILD_WITH_BORING_SSL_ASM=false" + echo "GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=true" + echo "GRPC_PYTHON_BUILD_WITH_CYTHON=true" + echo "GRPC_PYTHON_DISABLE_LIBC_COMPATIBILITY=true" + + # GRPC on armv7 needed -lexecinfo (issue #56669) since home assistant installed + # execinfo-dev when building wheels. However, this package is no longer available + # Alpine 3.17, which we use for the cp311 ABI, so the flag should no longer be needed. + echo "GRPC_PYTHON_LDFLAGS=-lpthread -Wl,-wrap,memcpy -static-libgcc" # -lexecinfo + + # Fix out of memory issues with rust + echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" + + # OpenCV headless installation + echo "CI_BUILD=1" + echo "ENABLE_HEADLESS=1" + + # Use C-Extension for sqlalchemy + echo "REQUIRE_SQLALCHEMY_CEXT=1" + ) > .env_file + + - name: Download requirements_diff + uses: actions/download-artifact@v3 + with: + name: requirements_diff + + - name: (Un)comment packages + run: | + requirement_files="requirements_all.txt requirements_diff.txt" + for requirement_file in ${requirement_files}; do + + # PyBluez no longer compiles. Commented it out for now. + # It need further cleanup down the line, as all machine images + # try to install it. + # sed -i "s|# pybluez|pybluez|g" ${requirement_file} + + # beacontools requires PyBluez. + # sed -i "s|# beacontools|beacontools|g" ${requirement_file} + + # azure-servicebus requires uamqp, which requires OpenSSL 1.1 to + # compile/build. This is not available on Alpine 3.17. The compat + # layer offered by Alpine conflicts, so we have no way to build + # this package. + # sed -i "s|# azure-servicebus|azure-servicebus|g" ${requirement_file} + + # It doesn't build for some reason, so we skip it for now. + # Bumping to the latest version (4.7.0.72) supporting Python 3.11 + # doesn't help. Reverted bump in #91871. There are 8 registered + # instances using this integration according to analytics. + # sed -i "s|# opencv-python-headless|opencv-python-headless|g" ${requirement_file} + + sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} + sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} + sed -i "s|# evdev|evdev|g" ${requirement_file} + sed -i "s|# pycups|pycups|g" ${requirement_file} + sed -i "s|# homekit|homekit|g" ${requirement_file} + sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} + sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} + + # Some packages are not buildable on armhf anymore + if [ "${{ matrix.arch }}" = "armhf" ]; then + + # Pandas has issues building on armhf, it is expected they + # will drop the platform in the near future (they consider it + # "flimsy" on 386). The following packages depend on pandas, + # so we comment them out. + sed -i "s|env_canada|# env_canada|g" ${requirement_file} + sed -i "s|noaa-coops|# noaa-coops|g" ${requirement_file} + sed -i "s|pyezviz|# pyezviz|g" ${requirement_file} + sed -i "s|pykrakenapi|# pykrakenapi|g" ${requirement_file} + fi + done + + - name: Split requirements all + run: | + # We split requirements all into two different files. + # This is to prevent the build from running out of memory when + # resolving packages on 32-bits systems (like armhf, armv7). + + split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 2) requirements_all.txt requirements_all.txt + + - name: Adjust build env + run: | + if [ "${{ matrix.arch }}" = "i386" ]; then + echo "NPY_DISABLE_SVML=1" >> .env_file + fi + + # Probably not an issue anymore. Removing for now. + # ( + # # cmake > 3.22.2 have issue on arm + # # Tested until 3.22.5 + # echo "cmake==3.22.2" + # ) >> homeassistant/package_constraints.txt + + # Do not pin numpy in wheels building + sed -i "/numpy/d" homeassistant/package_constraints.txt + + - name: Build wheels (part 1) + uses: home-assistant/wheels@2023.04.0 + with: + abi: ${{ matrix.abi }} + tag: musllinux_1_2 + arch: ${{ matrix.arch }} + wheels-key: ${{ secrets.WHEELS_KEY }} + env-file: true + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + skip-binary: aiohttp;grpcio;sqlalchemy;protobuf + legacy: true + constraints: "homeassistant/package_constraints.txt" + requirements-diff: "requirements_diff.txt" + requirements: "requirements_all.txtaa" + + - name: Build wheels (part 2) + uses: home-assistant/wheels@2023.04.0 + with: + abi: ${{ matrix.abi }} + tag: musllinux_1_2 + arch: ${{ matrix.arch }} + wheels-key: ${{ secrets.WHEELS_KEY }} + env-file: true + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + skip-binary: aiohttp;grpcio;sqlalchemy;protobuf legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fd196f19db..8e8fef9769 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.256 + rev: v0.0.262 hooks: - id: ruff args: - --fix - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.3.0 hooks: - id: black args: diff --git a/.prettierignore b/.prettierignore index a4d1d99079..aab23e2307 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,5 @@ azure-*.yml docs/source/_templates/* homeassistant/components/*/translations/*.json homeassistant/generated/* +tests/components/lidarr/fixtures/initialize.js +tests/components/lidarr/fixtures/initialize-wrong.js diff --git a/.strict-typing b/.strict-typing index 9db9500892..a5f084116a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -57,10 +57,12 @@ homeassistant.components.ambient_station.* homeassistant.components.amcrest.* homeassistant.components.ampio.* homeassistant.components.analytics.* +homeassistant.components.anova.* homeassistant.components.anthemav.* homeassistant.components.apcupsd.* homeassistant.components.aqualogic.* homeassistant.components.aseko_pool_live.* +homeassistant.components.assist_pipeline.* homeassistant.components.asuswrt.* homeassistant.components.auth.* homeassistant.components.automation.* @@ -137,6 +139,7 @@ homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* homeassistant.components.history.* +homeassistant.components.homeassistant.exposed_entities homeassistant.components.homeassistant.triggers.event homeassistant.components.homeassistant_alerts.* homeassistant.components.homeassistant_hardware.* @@ -311,7 +314,7 @@ homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* homeassistant.components.tts.* homeassistant.components.twentemilieu.* -homeassistant.components.unifi.update +homeassistant.components.unifi.* homeassistant.components.unifiprotect.* homeassistant.components.upcloud.* homeassistant.components.update.* diff --git a/.vscode/launch.json b/.vscode/launch.json index 39b32fd556..c165e252b1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,7 +23,7 @@ "preLaunchTask": "Compile English translations" }, { - // Debug by attaching to local Home Asistant server using Remote Python Debugger. + // Debug by attaching to local Home Assistant server using Remote Python Debugger. // See https://www.home-assistant.io/integrations/debugpy/ "name": "Home Assistant: Attach Local", "type": "python", @@ -38,7 +38,7 @@ ] }, { - // Debug by attaching to remote Home Asistant server using Remote Python Debugger. + // Debug by attaching to remote Home Assistant server using Remote Python Debugger. // See https://www.home-assistant.io/integrations/debugpy/ "name": "Home Assistant: Attach Remote", "type": "python", diff --git a/CODEOWNERS b/CODEOWNERS index afab5f8885..e426d5f98b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -80,6 +80,10 @@ build.json @home-assistant/supervisor /tests/components/android_ip_webcam/ @engrbm87 /homeassistant/components/androidtv/ @JeffLIrion @ollo69 /tests/components/androidtv/ @JeffLIrion @ollo69 +/homeassistant/components/androidtv_remote/ @tronikos +/tests/components/androidtv_remote/ @tronikos +/homeassistant/components/anova/ @Lash-L +/tests/components/anova/ @Lash-L /homeassistant/components/anthemav/ @hyralex /tests/components/anthemav/ @hyralex /homeassistant/components/apache_kafka/ @bachya @@ -103,6 +107,8 @@ build.json @home-assistant/supervisor /homeassistant/components/arris_tg2492lg/ @vanbalken /homeassistant/components/aseko_pool_live/ @milanmeu /tests/components/aseko_pool_live/ @milanmeu +/homeassistant/components/assist_pipeline/ @balloob @synesthesiam +/tests/components/assist_pipeline/ @balloob @synesthesiam /homeassistant/components/asuswrt/ @kennedyshead @ollo69 /tests/components/asuswrt/ @kennedyshead @ollo69 /homeassistant/components/atag/ @MatsNL @@ -168,6 +174,8 @@ build.json @home-assistant/supervisor /tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am /homeassistant/components/brother/ @bieniu /tests/components/brother/ @bieniu +/homeassistant/components/brottsplatskartan/ @gjohansson-ST +/tests/components/brottsplatskartan/ @gjohansson-ST /homeassistant/components/brunt/ @eavanvalkenburg /tests/components/brunt/ @eavanvalkenburg /homeassistant/components/bsblan/ @liudger @@ -215,8 +223,6 @@ build.json @home-assistant/supervisor /tests/components/conversation/ @home-assistant/core @synesthesiam /homeassistant/components/coolmaster/ @OnFreund /tests/components/coolmaster/ @OnFreund -/homeassistant/components/coronavirus/ @home-assistant/core -/tests/components/coronavirus/ @home-assistant/core /homeassistant/components/counter/ @fabaff /tests/components/counter/ @fabaff /homeassistant/components/cover/ @home-assistant/core @@ -228,8 +234,6 @@ build.json @home-assistant/supervisor /homeassistant/components/cups/ @fabaff /homeassistant/components/daikin/ @fredrike /tests/components/daikin/ @fredrike -/homeassistant/components/darksky/ @fabaff -/tests/components/darksky/ @fabaff /homeassistant/components/debugpy/ @frenck /tests/components/debugpy/ @frenck /homeassistant/components/deconz/ @Kane610 @@ -283,7 +287,7 @@ build.json @home-assistant/supervisor /tests/components/dsmr_reader/ @depl0y @glodenox /homeassistant/components/dunehd/ @bieniu /tests/components/dunehd/ @bieniu -/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 +/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo /homeassistant/components/dynalite/ @ziv1234 /tests/components/dynalite/ @ziv1234 /homeassistant/components/eafm/ @Jc2k @@ -546,8 +550,8 @@ build.json @home-assistant/supervisor /tests/components/image_processing/ @home-assistant/core /homeassistant/components/image_upload/ @home-assistant/core /tests/components/image_upload/ @home-assistant/core -/homeassistant/components/imap/ @engrbm87 -/tests/components/imap/ @engrbm87 +/homeassistant/components/imap/ @engrbm87 @jbouwh +/tests/components/imap/ @engrbm87 @jbouwh /homeassistant/components/incomfort/ @zxdavb /homeassistant/components/influxdb/ @mdegat01 /tests/components/influxdb/ @mdegat01 @@ -651,8 +655,8 @@ build.json @home-assistant/supervisor /tests/components/lidarr/ @tkdrob /homeassistant/components/life360/ @pnbruckner /tests/components/life360/ @pnbruckner -/homeassistant/components/lifx/ @bdraco @Djelibeybi -/tests/components/lifx/ @bdraco @Djelibeybi +/homeassistant/components/lifx/ @bdraco +/tests/components/lifx/ @bdraco /homeassistant/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core /homeassistant/components/linux_battery/ @fabaff @@ -785,7 +789,8 @@ build.json @home-assistant/supervisor /tests/components/nexia/ @bdraco /homeassistant/components/nextbus/ @vividboarder /tests/components/nextbus/ @vividboarder -/homeassistant/components/nextcloud/ @meichthys +/homeassistant/components/nextcloud/ @mib1185 +/tests/components/nextcloud/ @mib1185 /homeassistant/components/nextdns/ @bieniu /tests/components/nextdns/ @bieniu /homeassistant/components/nfandroidtv/ @tkdrob @@ -820,8 +825,8 @@ build.json @home-assistant/supervisor /tests/components/numato/ @clssn /homeassistant/components/number/ @home-assistant/core @Shulyaka /tests/components/number/ @home-assistant/core @Shulyaka -/homeassistant/components/nut/ @bdraco @ollo69 -/tests/components/nut/ @bdraco @ollo69 +/homeassistant/components/nut/ @bdraco @ollo69 @pestevez +/tests/components/nut/ @bdraco @ollo69 @pestevez /homeassistant/components/nws/ @MatthewFlamm @kamiyo /tests/components/nws/ @MatthewFlamm @kamiyo /homeassistant/components/nzbget/ @chriscla @@ -894,8 +899,8 @@ build.json @home-assistant/supervisor /tests/components/plaato/ @JohNan /homeassistant/components/plex/ @jjlawren /tests/components/plex/ @jjlawren -/homeassistant/components/plugwise/ @CoMPaTech @bouwew @brefra @frenck -/tests/components/plugwise/ @CoMPaTech @bouwew @brefra @frenck +/homeassistant/components/plugwise/ @CoMPaTech @bouwew @frenck +/tests/components/plugwise/ @CoMPaTech @bouwew @frenck /homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa /tests/components/plum_lightpad/ @ColinHarrington @prystupa /homeassistant/components/point/ @fredrike @@ -932,6 +937,7 @@ build.json @home-assistant/supervisor /homeassistant/components/pvpc_hourly_pricing/ @azogue /tests/components/pvpc_hourly_pricing/ @azogue /homeassistant/components/qbittorrent/ @geoffreylagaisse +/tests/components/qbittorrent/ @geoffreylagaisse /homeassistant/components/qingping/ @bdraco @skgsergio /tests/components/qingping/ @bdraco @skgsergio /homeassistant/components/qld_bushfire/ @exxamalte @@ -959,6 +965,8 @@ build.json @home-assistant/supervisor /tests/components/rainmachine/ @bachya /homeassistant/components/random/ @fabaff /tests/components/random/ @fabaff +/homeassistant/components/rapt_ble/ @sairon +/tests/components/rapt_ble/ @sairon /homeassistant/components/raspberry_pi/ @home-assistant/core /tests/components/raspberry_pi/ @home-assistant/core /homeassistant/components/rdw/ @frenck @@ -977,6 +985,8 @@ build.json @home-assistant/supervisor /homeassistant/components/repairs/ @home-assistant/core /tests/components/repairs/ @home-assistant/core /homeassistant/components/repetier/ @MTrab @ShadowBr0ther +/homeassistant/components/rest/ @epenet +/tests/components/rest/ @epenet /homeassistant/components/rflink/ @javicalle /tests/components/rflink/ @javicalle /homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221 @@ -991,6 +1001,8 @@ build.json @home-assistant/supervisor /tests/components/rituals_perfume_genie/ @milanmeu /homeassistant/components/rmvtransport/ @cgtobi /tests/components/rmvtransport/ @cgtobi +/homeassistant/components/roborock/ @humbertogontijo @Lash-L +/tests/components/roborock/ @humbertogontijo @Lash-L /homeassistant/components/roku/ @ctalkington /tests/components/roku/ @ctalkington /homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @@ -1103,6 +1115,7 @@ build.json @home-assistant/supervisor /tests/components/smhi/ @gjohansson-ST /homeassistant/components/sms/ @ocalvo /homeassistant/components/snapcast/ @luar123 +/tests/components/snapcast/ @luar123 /homeassistant/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst /homeassistant/components/solaredge/ @frenck @@ -1131,8 +1144,8 @@ build.json @home-assistant/supervisor /homeassistant/components/splunk/ @Bre77 /homeassistant/components/spotify/ @frenck /tests/components/spotify/ @frenck -/homeassistant/components/sql/ @dgomes @gjohansson-ST -/tests/components/sql/ @dgomes @gjohansson-ST +/homeassistant/components/sql/ @dgomes @gjohansson-ST @dougiteixeira +/tests/components/sql/ @dgomes @gjohansson-ST @dougiteixeira /homeassistant/components/squeezebox/ @rajlaud /tests/components/squeezebox/ @rajlaud /homeassistant/components/srp_energy/ @briglx @@ -1154,8 +1167,8 @@ build.json @home-assistant/supervisor /tests/components/stookwijzer/ @fwestenberg /homeassistant/components/stream/ @hunterjm @uvjustin @allenporter /tests/components/stream/ @hunterjm @uvjustin @allenporter -/homeassistant/components/stt/ @pvizeli -/tests/components/stt/ @pvizeli +/homeassistant/components/stt/ @home-assistant/core @pvizeli +/tests/components/stt/ @home-assistant/core @pvizeli /homeassistant/components/subaru/ @G-Two /tests/components/subaru/ @G-Two /homeassistant/components/suez_water/ @ooii @@ -1250,8 +1263,8 @@ build.json @home-assistant/supervisor /tests/components/trafikverket_weatherstation/ @endor-force @gjohansson-ST /homeassistant/components/transmission/ @engrbm87 @JPHutchins /tests/components/transmission/ @engrbm87 @JPHutchins -/homeassistant/components/tts/ @pvizeli -/tests/components/tts/ @pvizeli +/homeassistant/components/tts/ @home-assistant/core @pvizeli +/tests/components/tts/ @home-assistant/core @pvizeli /homeassistant/components/tuya/ @Tuya @zlinoliver @frenck /tests/components/tuya/ @Tuya @zlinoliver @frenck /homeassistant/components/twentemilieu/ @frenck @@ -1263,8 +1276,8 @@ build.json @home-assistant/supervisor /homeassistant/components/unifi/ @Kane610 /tests/components/unifi/ @Kane610 /homeassistant/components/unifiled/ @florisvdk -/homeassistant/components/unifiprotect/ @briis @AngellusMortis @bdraco -/tests/components/unifiprotect/ @briis @AngellusMortis @bdraco +/homeassistant/components/unifiprotect/ @AngellusMortis @bdraco +/tests/components/unifiprotect/ @AngellusMortis @bdraco /homeassistant/components/upb/ @gwww /tests/components/upb/ @gwww /homeassistant/components/upc_connect/ @pvizeli @fabaff @@ -1293,15 +1306,13 @@ build.json @home-assistant/supervisor /homeassistant/components/velux/ @Julius2342 /homeassistant/components/venstar/ @garbled1 /tests/components/venstar/ @garbled1 -/homeassistant/components/verisure/ @frenck -/tests/components/verisure/ @frenck +/homeassistant/components/verisure/ @frenck @niro1987 +/tests/components/verisure/ @frenck @niro1987 /homeassistant/components/versasense/ @flamm3blemuff1n /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey -/homeassistant/components/vicare/ @oischinger -/tests/components/vicare/ @oischinger /homeassistant/components/vilfo/ @ManneW /tests/components/vilfo/ @ManneW /homeassistant/components/vivotek/ @HarlemSquirrel @@ -1309,8 +1320,8 @@ build.json @home-assistant/supervisor /tests/components/vizio/ @raman325 /homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare /tests/components/vlc_telnet/ @rodripf @MartinHjelmare -/homeassistant/components/voice_assistant/ @balloob @synesthesiam -/tests/components/voice_assistant/ @balloob @synesthesiam +/homeassistant/components/voip/ @balloob @synesthesiam +/tests/components/voip/ @balloob @synesthesiam /homeassistant/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund /homeassistant/components/volvooncall/ @molobrakos @@ -1356,15 +1367,16 @@ build.json @home-assistant/supervisor /tests/components/wled/ @frenck /homeassistant/components/wolflink/ @adamkrol93 /tests/components/wolflink/ @adamkrol93 -/homeassistant/components/workday/ @fabaff -/tests/components/workday/ @fabaff +/homeassistant/components/workday/ @fabaff @gjohansson-ST +/tests/components/workday/ @fabaff @gjohansson-ST /homeassistant/components/worldclock/ @fabaff /tests/components/worldclock/ @fabaff /homeassistant/components/ws66i/ @ssaenger /tests/components/ws66i/ @ssaenger +/homeassistant/components/wyoming/ @balloob @synesthesiam +/tests/components/wyoming/ @balloob @synesthesiam /homeassistant/components/xbox/ @hunterjm /tests/components/xbox/ @hunterjm -/homeassistant/components/xbox_live/ @MartinHjelmare /homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi /tests/components/xiaomi_aqara/ @danielhiversen @syssi /homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 0982804761..fab04fe397 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -132,8 +132,8 @@ For answers to common questions about this code of conduct, see the FAQ at . Translations are available at . -[coc-blog]: /blog/2017/01/21/home-assistant-governance/ -[coc2-blog]: /blog/2020/05/25/code-of-conduct-updated/ +[coc-blog]: https://www.home-assistant.io/blog/2017/01/21/home-assistant-governance/ +[coc2-blog]: https://www.home-assistant.io/blog/2020/05/25/code-of-conduct-updated/ [email]: mailto:safety@home-assistant.io [homepage]: http://contributor-covenant.org [mozilla]: https://github.com/mozilla/diversity diff --git a/Dockerfile.dev b/Dockerfile.dev index 116446d181..336648ae1c 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -4,11 +4,12 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Uninstall pre-installed formatting and linting tools # They would conflict with our pinned versions -RUN pipx uninstall black -RUN pipx uninstall pydocstyle -RUN pipx uninstall pycodestyle -RUN pipx uninstall mypy -RUN pipx uninstall pylint +RUN \ + pipx uninstall black \ + && pipx uninstall pydocstyle \ + && pipx uninstall pycodestyle \ + && pipx uninstall mypy \ + && pipx uninstall pylint RUN \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ diff --git a/build.yaml b/build.yaml index 22b9301497..0bc38d7226 100644 --- a/build.yaml +++ b/build.yaml @@ -1,11 +1,11 @@ image: homeassistant/{arch}-homeassistant shadow_repository: ghcr.io/home-assistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.02.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.02.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.02.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.02.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.02.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.04.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.04.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.04.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.04.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.04.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/docs/screenshot-integrations.png b/docs/screenshot-integrations.png index 23202a578f..bc304f11b1 100644 Binary files a/docs/screenshot-integrations.png and b/docs/screenshot-integrations.png differ diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 5c401570de..9a53717427 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -14,7 +14,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.util import dt as dt_util -from . import auth_store, models +from . import auth_store, jwt_wrapper, models from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config from .providers import AuthProvider, LoginFlow, auth_provider_from_config @@ -555,9 +555,7 @@ class AuthManager: ) -> models.RefreshToken | None: """Return refresh token if an access token is valid.""" try: - unverif_claims = jwt.decode( - token, algorithms=["HS256"], options={"verify_signature": False} - ) + unverif_claims = jwt_wrapper.unverified_hs256_token_decode(token) except jwt.InvalidTokenError: return None @@ -573,7 +571,9 @@ class AuthManager: issuer = refresh_token.id try: - jwt.decode(token, jwt_key, leeway=10, issuer=issuer, algorithms=["HS256"]) + jwt_wrapper.verify_and_decode( + token, jwt_key, leeway=10, issuer=issuer, algorithms=["HS256"] + ) except jwt.InvalidTokenError: return None diff --git a/homeassistant/auth/jwt_wrapper.py b/homeassistant/auth/jwt_wrapper.py new file mode 100644 index 0000000000..546e4afdcf --- /dev/null +++ b/homeassistant/auth/jwt_wrapper.py @@ -0,0 +1,116 @@ +"""Provide a wrapper around JWT that caches decoding tokens. + +Since we decode the same tokens over and over again +we can cache the result of the decode of valid tokens +to speed up the process. +""" +from __future__ import annotations + +from datetime import timedelta +from functools import lru_cache, partial +from typing import Any + +from jwt import DecodeError, PyJWS, PyJWT + +from homeassistant.util.json import json_loads + +JWT_TOKEN_CACHE_SIZE = 16 +MAX_TOKEN_SIZE = 8192 + +_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss") + +_VERIFY_OPTIONS: dict[str, Any] = {f"verify_{key}": True for key in _VERIFY_KEYS} | { + "require": [] +} +_NO_VERIFY_OPTIONS = {f"verify_{key}": False for key in _VERIFY_KEYS} + + +class _PyJWSWithLoadCache(PyJWS): + """PyJWS with a dedicated load implementation.""" + + @lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE) + # We only ever have a global instance of this class + # so we do not have to worry about the LRU growing + # each time we create a new instance. + def _load(self, jwt: str | bytes) -> tuple[bytes, bytes, dict, bytes]: + """Load a JWS.""" + return super()._load(jwt) + + +_jws = _PyJWSWithLoadCache() + + +@lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE) +def _decode_payload(json_payload: str) -> dict[str, Any]: + """Decode the payload from a JWS dictionary.""" + try: + payload = json_loads(json_payload) + except ValueError as err: + raise DecodeError(f"Invalid payload string: {err}") from err + if not isinstance(payload, dict): + raise DecodeError("Invalid payload string: must be a json object") + return payload + + +class _PyJWTWithVerify(PyJWT): + """PyJWT with a fast decode implementation.""" + + def decode_payload( + self, jwt: str, key: str, options: dict[str, Any], algorithms: list[str] + ) -> dict[str, Any]: + """Decode a JWT's payload.""" + if len(jwt) > MAX_TOKEN_SIZE: + # Avoid caching impossible tokens + raise DecodeError("Token too large") + return _decode_payload( + _jws.decode_complete( + jwt=jwt, + key=key, + algorithms=algorithms, + options=options, + )["payload"] + ) + + def verify_and_decode( + self, + jwt: str, + key: str, + algorithms: list[str], + issuer: str | None = None, + leeway: int | float | timedelta = 0, + options: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Verify a JWT's signature and claims.""" + merged_options = {**_VERIFY_OPTIONS, **(options or {})} + payload = self.decode_payload( + jwt=jwt, + key=key, + options=merged_options, + algorithms=algorithms, + ) + # These should never be missing since we verify them + # but this is an additional safeguard to make sure + # nothing slips through. + assert "exp" in payload, "exp claim is required" + assert "iat" in payload, "iat claim is required" + self._validate_claims( # type: ignore[no-untyped-call] + payload=payload, + options=merged_options, + issuer=issuer, + leeway=leeway, + ) + return payload + + +_jwt = _PyJWTWithVerify() # type: ignore[no-untyped-call] +verify_and_decode = _jwt.verify_and_decode +unverified_hs256_token_decode = lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)( + partial( + _jwt.decode_payload, key="", algorithms=["HS256"], options=_NO_VERIFY_OPTIONS + ) +) + +__all__ = [ + "unverified_hs256_token_decode", + "verify_and_decode", +] diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 9ba4e99a08..2077274be5 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -239,13 +239,14 @@ async def load_registries(hass: core.HomeAssistant) -> None: # Load the registries and cache the result of platform.uname().processor entity.async_setup(hass) + template.async_setup(hass) await asyncio.gather( area_registry.async_load(hass), device_registry.async_load(hass), entity_registry.async_load(hass), issue_registry.async_load(hass), hass.async_add_executor_job(_cache_uname_processor), - template.async_load_custom_jinja(hass), + template.async_load_custom_templates(hass), ) @@ -515,16 +516,15 @@ async def async_setup_multi_components( ) for domain in domains } - await asyncio.wait(futures.values()) - errors = [domain for domain in domains if futures[domain].exception()] - for domain in errors: - exception = futures[domain].exception() - assert exception is not None - _LOGGER.error( - "Error setting up integration %s - received exception", - domain, - exc_info=(type(exception), exception, exception.__traceback__), - ) + results = await asyncio.gather(*futures.values(), return_exceptions=True) + for idx, domain in enumerate(futures): + result = results[idx] + if isinstance(result, BaseException): + _LOGGER.error( + "Error setting up integration %s - received exception", + domain, + exc_info=(type(result), result, result.__traceback__), + ) async def _async_set_up_integrations( @@ -629,6 +629,9 @@ async def _async_set_up_integrations( - stage_1_domains ) + # Enables after dependencies when setting up stage 1 domains + async_set_domains_to_be_loaded(hass, stage_1_domains) + # Start setup if stage_1_domains: _LOGGER.info("Setting up stage 1: %s", stage_1_domains) @@ -640,7 +643,7 @@ async def _async_set_up_integrations( except asyncio.TimeoutError: _LOGGER.warning("Setup timed out for stage 1 - moving forward") - # Enables after dependencies + # Add after dependencies when setting up stage 2 domains async_set_domains_to_be_loaded(hass, stage_2_domains) if stage_2_domains: diff --git a/homeassistant/brands/homeseer.json b/homeassistant/brands/homeseer.json new file mode 100644 index 0000000000..cfc36968c1 --- /dev/null +++ b/homeassistant/brands/homeseer.json @@ -0,0 +1,5 @@ +{ + "domain": "homeseer", + "name": "HomeSeer", + "iot_standards": ["zwave"] +} diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json index d28932082a..9da24e76f1 100644 --- a/homeassistant/brands/microsoft.json +++ b/homeassistant/brands/microsoft.json @@ -10,7 +10,6 @@ "microsoft_face", "microsoft", "msteams", - "xbox", - "xbox_live" + "xbox" ] } diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index 89af284f87..c7943d15bd 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -10,14 +10,15 @@ from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout +from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN, MANUFACTURER @@ -49,6 +50,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Remove ozone sensors from registry if they exist + ent_reg = er.async_get(hass) + for day in range(0, 5): + unique_id = f"{coordinator.location_key}-ozone-{day}" + if entity_id := ent_reg.async_get_entity_id(SENSOR_PLATFORM, DOMAIN, unique_id): + _LOGGER.debug("Removing ozone sensor entity %s", entity_id) + ent_reg.async_remove(entity_id) + return True @@ -116,11 +125,7 @@ class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async with timeout(10): current = await self.accuweather.async_get_current_conditions() forecast = ( - await self.accuweather.async_get_forecast( - metric=self.hass.config.units is METRIC_SYSTEM - ) - if self.forecast - else {} + await self.accuweather.async_get_forecast() if self.forecast else {} ) except ( ApiError, diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 1336e31f41..87bc8eaef8 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -20,7 +20,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ) -API_IMPERIAL: Final = "Imperial" API_METRIC: Final = "Metric" ATTRIBUTION: Final = "Data provided by AccuWeather" ATTR_CATEGORY: Final = "Category" diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 5b0951dde9..ad07154ff6 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["accuweather"], "quality_scale": "platinum", - "requirements": ["accuweather==0.5.0"] + "requirements": ["accuweather==0.5.1"] } diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 6cb0b45418..5d0c70de4e 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -26,11 +26,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.unit_system import METRIC_SYSTEM from . import AccuWeatherDataUpdateCoordinator from .const import ( - API_IMPERIAL, API_METRIC, ATTR_CATEGORY, ATTR_DIRECTION, @@ -51,7 +49,7 @@ PARALLEL_UPDATES = 1 class AccuWeatherSensorDescriptionMixin: """Mixin for AccuWeather sensor.""" - value_fn: Callable[[dict[str, Any], str], StateType] + value_fn: Callable[[dict[str, Any]], StateType] @dataclass @@ -61,18 +59,25 @@ class AccuWeatherSensorDescription( """Class describing AccuWeather sensor entities.""" attr_fn: Callable[[dict[str, Any]], dict[str, StateType]] = lambda _: {} - metric_unit: str | None = None - us_customary_unit: str | None = None FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( + AccuWeatherSensorDescription( + key="AirQuality", + icon="mdi:air-filter", + name="Air quality", + value_fn=lambda data: cast(str, data[ATTR_CATEGORY]), + device_class=SensorDeviceClass.ENUM, + options=["good", "hazardous", "high", "low", "moderate", "unhealthy"], + translation_key="air_quality", + ), AccuWeatherSensorDescription( key="CloudCoverDay", icon="mdi:weather-cloudy", name="Cloud cover day", entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="CloudCoverNight", @@ -80,7 +85,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Cloud cover night", entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="Grass", @@ -88,15 +93,16 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Grass pollen", entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key="grass_pollen", ), AccuWeatherSensorDescription( key="HoursOfSun", icon="mdi:weather-partly-cloudy", name="Hours of sun", native_unit_of_measurement=UnitOfTime.HOURS, - value_fn=lambda data, _: cast(float, data), + value_fn=lambda data: cast(float, data), ), AccuWeatherSensorDescription( key="Mold", @@ -104,16 +110,9 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Mold pollen", entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, - ), - AccuWeatherSensorDescription( - key="Ozone", - icon="mdi:vector-triangle", - name="Ozone", - entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key="mold_pollen", ), AccuWeatherSensorDescription( key="Ragweed", @@ -121,56 +120,53 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Ragweed pollen", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key="ragweed_pollen", ), AccuWeatherSensorDescription( key="RealFeelTemperatureMax", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature max", - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureMin", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature min", - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShadeMax", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature shade max", entity_registry_enabled_default=False, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShadeMin", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature shade min", entity_registry_enabled_default=False, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="ThunderstormProbabilityDay", icon="mdi:weather-lightning", name="Thunderstorm probability day", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="ThunderstormProbabilityNight", icon="mdi:weather-lightning", name="Thunderstorm probability night", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="Tree", @@ -178,25 +174,26 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Tree pollen", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key="tree_pollen", ), AccuWeatherSensorDescription( key="UVIndex", icon="mdi:weather-sunny", name="UV index", native_unit_of_measurement=UV_INDEX, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key="uv_index", ), AccuWeatherSensorDescription( key="WindGustDay", device_class=SensorDeviceClass.WIND_SPEED, name="Wind gust day", entity_registry_enabled_default=False, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( @@ -204,27 +201,24 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( device_class=SensorDeviceClass.WIND_SPEED, name="Wind gust night", entity_registry_enabled_default=False, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( key="WindDay", device_class=SensorDeviceClass.WIND_SPEED, name="Wind day", - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( key="WindNight", device_class=SensorDeviceClass.WIND_SPEED, name="Wind night", - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), ) @@ -236,9 +230,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Apparent temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Ceiling", @@ -246,9 +239,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( icon="mdi:weather-fog", name="Cloud ceiling", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfLength.METERS, - us_customary_unit=UnitOfLength.FEET, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfLength.METERS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), suggested_display_precision=0, ), AccuWeatherSensorDescription( @@ -258,7 +250,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="DewPoint", @@ -266,18 +258,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Dew point", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperature", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShade", @@ -285,18 +275,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="RealFeel temperature shade", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Precipitation", device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, name="Precipitation", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - us_customary_unit=UnitOfVolumetricFlux.INCHES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), attr_fn=lambda data: {"type": data["PrecipitationType"]}, ), AccuWeatherSensorDescription( @@ -306,7 +294,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Pressure tendency", options=["falling", "rising", "steady"], translation_key="pressure_tendency", - value_fn=lambda data, _: cast(str, data["LocalizedText"]).lower(), + value_fn=lambda data: cast(str, data["LocalizedText"]).lower(), ), AccuWeatherSensorDescription( key="UVIndex", @@ -314,7 +302,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="UV index", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UV_INDEX, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), attr_fn=lambda data: {ATTR_LEVEL: data["UVIndexText"]}, ), AccuWeatherSensorDescription( @@ -323,9 +311,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Wet bulb temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="WindChillTemperature", @@ -333,18 +320,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Wind chill temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Wind", device_class=SensorDeviceClass.WIND_SPEED, name="Wind", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="WindGust", @@ -352,9 +337,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Wind gust", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]), ), ) @@ -374,7 +358,7 @@ async def async_setup_entry( # Some air quality/allergy sensors are only available for certain # locations. sensors.extend( - AccuWeatherForecastSensor(coordinator, description, forecast_day=day) + AccuWeatherSensor(coordinator, description, forecast_day=day) for day in range(MAX_FORECAST_DAYS + 1) for description in FORECAST_SENSOR_TYPES if description.key in coordinator.data[ATTR_FORECAST][0] @@ -413,34 +397,27 @@ class AccuWeatherSensor( self._attr_unique_id = ( f"{coordinator.location_key}-{description.key}".lower() ) - self._attr_native_unit_of_measurement = description.native_unit_of_measurement - if self.coordinator.hass.config.units is METRIC_SYSTEM: - self._unit_system = API_METRIC - if metric_unit := description.metric_unit: - self._attr_native_unit_of_measurement = metric_unit - else: - self._unit_system = API_IMPERIAL - if us_customary_unit := description.us_customary_unit: - self._attr_native_unit_of_measurement = us_customary_unit self._attr_device_info = coordinator.device_info - if forecast_day is not None: - self.forecast_day = forecast_day + self.forecast_day = forecast_day @property def native_value(self) -> StateType: """Return the state.""" - return self.entity_description.value_fn(self._sensor_data, self._unit_system) + return self.entity_description.value_fn(self._sensor_data) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" + if self.forecast_day is not None: + return self.entity_description.attr_fn(self._sensor_data) + return self.entity_description.attr_fn(self.coordinator.data) @callback def _handle_coordinator_update(self) -> None: """Handle data update.""" self._sensor_data = _get_sensor_data( - self.coordinator.data, self.entity_description.key + self.coordinator.data, self.entity_description.key, self.forecast_day ) self.async_write_ha_state() @@ -458,20 +435,3 @@ def _get_sensor_data( return sensors["PrecipitationSummary"]["PastHour"] return sensors[kind] - - -class AccuWeatherForecastSensor(AccuWeatherSensor): - """Define an AccuWeather forecast entity.""" - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - return self.entity_description.attr_fn(self._sensor_data) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle data update.""" - self._sensor_data = _get_sensor_data( - self.coordinator.data, self.entity_description.key, self.forecast_day - ) - self.async_write_ha_state() diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index d37b5a1077..e9c4ace9b9 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -30,6 +30,91 @@ "rising": "Rising", "falling": "Falling" } + }, + "air_quality": { + "state": { + "good": "Good", + "hazardous": "Hazardous", + "high": "High", + "low": "Low", + "moderate": "Moderate", + "unhealthy": "Unhealthy" + } + }, + "grass_pollen": { + "state_attributes": { + "level": { + "name": "Level", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + } + } + } + }, + "mold_pollen": { + "state_attributes": { + "level": { + "name": "Level", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + } + } + } + }, + "ragweed_pollen": { + "state_attributes": { + "level": { + "name": "Level", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + } + } + } + }, + "tree_pollen": { + "state_attributes": { + "level": { + "name": "Level", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + } + } + } + }, + "uv_index": { + "state_attributes": { + "level": { + "name": "Level", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + } + } + } } } }, diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 5c5ba303ad..0ef729b9b6 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -28,17 +28,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp -from homeassistant.util.unit_system import METRIC_SYSTEM from . import AccuWeatherDataUpdateCoordinator -from .const import ( - API_IMPERIAL, - API_METRIC, - ATTR_FORECAST, - ATTRIBUTION, - CONDITION_CLASSES, - DOMAIN, -) +from .const import API_METRIC, ATTR_FORECAST, ATTRIBUTION, CONDITION_CLASSES, DOMAIN PARALLEL_UPDATES = 1 @@ -66,20 +58,11 @@ class AccuWeatherEntity( # Coordinator data is used also for sensors which don't have units automatically # converted, hence the weather entity's native units follow the configured unit # system - if coordinator.hass.config.units is METRIC_SYSTEM: - self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS - self._attr_native_pressure_unit = UnitOfPressure.HPA - self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_native_visibility_unit = UnitOfLength.KILOMETERS - self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR - self._unit_system = API_METRIC - else: - self._unit_system = API_IMPERIAL - self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.INCHES - self._attr_native_pressure_unit = UnitOfPressure.INHG - self._attr_native_temperature_unit = UnitOfTemperature.FAHRENHEIT - self._attr_native_visibility_unit = UnitOfLength.MILES - self._attr_native_wind_speed_unit = UnitOfSpeed.MILES_PER_HOUR + self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS + self._attr_native_pressure_unit = UnitOfPressure.HPA + self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_native_visibility_unit = UnitOfLength.KILOMETERS + self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR self._attr_unique_id = coordinator.location_key self._attr_attribution = ATTRIBUTION self._attr_device_info = coordinator.device_info @@ -99,16 +82,12 @@ class AccuWeatherEntity( @property def native_temperature(self) -> float: """Return the temperature.""" - return cast( - float, self.coordinator.data["Temperature"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Temperature"][API_METRIC]["Value"]) @property def native_pressure(self) -> float: """Return the pressure.""" - return cast( - float, self.coordinator.data["Pressure"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Pressure"][API_METRIC]["Value"]) @property def humidity(self) -> int: @@ -118,9 +97,7 @@ class AccuWeatherEntity( @property def native_wind_speed(self) -> float: """Return the wind speed.""" - return cast( - float, self.coordinator.data["Wind"]["Speed"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Wind"]["Speed"][API_METRIC]["Value"]) @property def wind_bearing(self) -> int: @@ -130,19 +107,7 @@ class AccuWeatherEntity( @property def native_visibility(self) -> float: """Return the visibility.""" - return cast( - float, self.coordinator.data["Visibility"][self._unit_system]["Value"] - ) - - @property - def ozone(self) -> int | None: - """Return the ozone level.""" - # We only have ozone data for certain locations and only in the forecast data. - if self.coordinator.forecast and self.coordinator.data[ATTR_FORECAST][0].get( - "Ozone" - ): - return cast(int, self.coordinator.data[ATTR_FORECAST][0]["Ozone"]["Value"]) - return None + return cast(float, self.coordinator.data["Visibility"][API_METRIC]["Value"]) @property def forecast(self) -> list[Forecast] | None: diff --git a/homeassistant/components/advantage_air/__init__.py b/homeassistant/components/advantage_air/__init__.py index 739baefda5..4dbc2edad8 100644 --- a/homeassistant/components/advantage_air/__init__.py +++ b/homeassistant/components/advantage_air/__init__.py @@ -7,11 +7,11 @@ from advantage_air import ApiError, advantage_air from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ADVANTAGE_AIR_RETRY, DOMAIN +from .models import AdvantageAirData ADVANTAGE_AIR_SYNC_INTERVAL = 15 PLATFORMS = [ @@ -53,29 +53,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL), ) - def error_handle_factory(func): - """Return the provided API function wrapped. - - Adds an error handler and coordinator refresh. - """ - - async def error_handle(param): - try: - if await func(param): - await coordinator.async_refresh() - except ApiError as err: - raise HomeAssistantError(err) from err - - return error_handle - await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - "coordinator": coordinator, - "aircon": error_handle_factory(api.aircon.async_set), - "lights": error_handle_factory(api.lights.async_set), - } + hass.data[DOMAIN][entry.entry_id] = AdvantageAirData(coordinator, api) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/advantage_air/binary_sensor.py b/homeassistant/components/advantage_air/binary_sensor.py index 4cecffe2dd..74a276dc67 100644 --- a/homeassistant/components/advantage_air/binary_sensor.py +++ b/homeassistant/components/advantage_air/binary_sensor.py @@ -1,8 +1,6 @@ """Binary Sensor platform for Advantage Air integration.""" from __future__ import annotations -from typing import Any - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -14,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity +from .models import AdvantageAirData PARALLEL_UPDATES = 0 @@ -25,10 +24,10 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir Binary Sensor platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] entities: list[BinarySensorEntity] = [] - if aircons := instance["coordinator"].data.get("aircons"): + if aircons := instance.coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): entities.append(AdvantageAirFilter(instance, ac_key)) for zone_key, zone in ac_device["zones"].items(): @@ -48,7 +47,7 @@ class AdvantageAirFilter(AdvantageAirAcEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_name = "Filter" - def __init__(self, instance: dict[str, Any], ac_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize an Advantage Air Filter sensor.""" super().__init__(instance, ac_key) self._attr_unique_id += "-filter" @@ -64,7 +63,7 @@ class AdvantageAirZoneMotion(AdvantageAirZoneEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOTION - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Motion sensor.""" super().__init__(instance, ac_key, zone_key) self._attr_name = f'{self._zone["name"]} motion' @@ -82,7 +81,7 @@ class AdvantageAirZoneMyZone(AdvantageAirZoneEntity, BinarySensorEntity): _attr_entity_registry_enabled_default = False _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone MyZone sensor.""" super().__init__(instance, ac_key, zone_key) self._attr_name = f'{self._zone["name"]} myZone' diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 53a41994fc..a13fa95f6b 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -5,6 +5,8 @@ import logging from typing import Any from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -26,24 +28,17 @@ from .const import ( DOMAIN as ADVANTAGE_AIR_DOMAIN, ) from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity +from .models import AdvantageAirData ADVANTAGE_AIR_HVAC_MODES = { "heat": HVACMode.HEAT, "cool": HVACMode.COOL, "vent": HVACMode.FAN_ONLY, "dry": HVACMode.DRY, - "myauto": HVACMode.AUTO, + "myauto": HVACMode.HEAT_COOL, } HASS_HVAC_MODES = {v: k for k, v in ADVANTAGE_AIR_HVAC_MODES.items()} -AC_HVAC_MODES = [ - HVACMode.OFF, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.FAN_ONLY, - HVACMode.DRY, -] - ADVANTAGE_AIR_FAN_MODES = { "autoAA": FAN_AUTO, "low": FAN_LOW, @@ -53,7 +48,14 @@ ADVANTAGE_AIR_FAN_MODES = { HASS_FAN_MODES = {v: k for k, v in ADVANTAGE_AIR_FAN_MODES.items()} FAN_SPEEDS = {FAN_LOW: 30, FAN_MEDIUM: 60, FAN_HIGH: 100} -ZONE_HVAC_MODES = [HVACMode.OFF, HVACMode.HEAT_COOL] +ADVANTAGE_AIR_AUTOFAN = "aaAutoFanModeEnabled" +ADVANTAGE_AIR_MYZONE = "MyZone" +ADVANTAGE_AIR_MYAUTO = "MyAuto" +ADVANTAGE_AIR_MYAUTO_ENABLED = "myAutoModeEnabled" +ADVANTAGE_AIR_MYTEMP = "MyTemp" +ADVANTAGE_AIR_MYTEMP_ENABLED = "climateControlModeEnabled" +ADVANTAGE_AIR_HEAT_TARGET = "myAutoHeatTargetTemp" +ADVANTAGE_AIR_COOL_TARGET = "myAutoCoolTargetTemp" PARALLEL_UPDATES = 0 @@ -67,15 +69,15 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir climate platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] entities: list[ClimateEntity] = [] - if aircons := instance["coordinator"].data.get("aircons"): + if aircons := instance.coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): entities.append(AdvantageAirAC(instance, ac_key)) for zone_key, zone in ac_device["zones"].items(): # Only add zone climate control when zone is in temperature control - if zone["type"] != 0: + if zone["type"] > 0: entities.append(AdvantageAirZone(instance, ac_key, zone_key)) async_add_entities(entities) @@ -83,24 +85,56 @@ async def async_setup_entry( class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): """AdvantageAir AC unit.""" + _attr_fan_modes = [FAN_LOW, FAN_MEDIUM, FAN_HIGH] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = PRECISION_WHOLE _attr_max_temp = 32 _attr_min_temp = 16 - _attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] - _attr_hvac_modes = AC_HVAC_MODES - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - ) - def __init__(self, instance: dict[str, Any], ac_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize an AdvantageAir AC unit.""" super().__init__(instance, ac_key) - if self._ac.get("myAutoModeEnabled"): - self._attr_hvac_modes = AC_HVAC_MODES + [HVACMode.AUTO] + + # Set supported features and HVAC modes based on current operating mode + if self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED): + # MyAuto + self._attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + self._attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.HEAT_COOL, + ] + elif self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED): + # MyTemp + self._attr_supported_features = ClimateEntityFeature.FAN_MODE + self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT] + + else: + # MyZone + self._attr_supported_features = ( + ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + self._attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + ] + + # Add "ezfan" mode if supported + if self._ac.get(ADVANTAGE_AIR_AUTOFAN): + self._attr_fan_modes += [FAN_AUTO] @property - def target_temperature(self) -> float: + def target_temperature(self) -> float | None: """Return the current target temperature.""" return self._ac["setTemp"] @@ -116,77 +150,71 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): """Return the current fan modes.""" return ADVANTAGE_AIR_FAN_MODES.get(self._ac["fan"]) + @property + def target_temperature_high(self) -> float | None: + """Return the temperature cool mode is enabled.""" + return self._ac.get(ADVANTAGE_AIR_COOL_TARGET) + + @property + def target_temperature_low(self) -> float | None: + """Return the temperature heat mode is enabled.""" + return self._ac.get(ADVANTAGE_AIR_HEAT_TARGET) + async def async_turn_on(self) -> None: """Set the HVAC State to on.""" - await self.aircon( - { - self.ac_key: { - "info": { - "state": ADVANTAGE_AIR_STATE_ON, - } - } - } - ) + await self.async_update_ac({"state": ADVANTAGE_AIR_STATE_ON}) async def async_turn_off(self) -> None: """Set the HVAC State to off.""" - await self.aircon( + await self.async_update_ac( { - self.ac_key: { - "info": { - "state": ADVANTAGE_AIR_STATE_OFF, - } - } + "state": ADVANTAGE_AIR_STATE_OFF, } ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC Mode and State.""" if hvac_mode == HVACMode.OFF: - await self.aircon( - {self.ac_key: {"info": {"state": ADVANTAGE_AIR_STATE_OFF}}} - ) + await self.async_update_ac({"state": ADVANTAGE_AIR_STATE_OFF}) else: - await self.aircon( + await self.async_update_ac( { - self.ac_key: { - "info": { - "state": ADVANTAGE_AIR_STATE_ON, - "mode": HASS_HVAC_MODES.get(hvac_mode), - } - } + "state": ADVANTAGE_AIR_STATE_ON, + "mode": HASS_HVAC_MODES.get(hvac_mode), } ) async def async_set_fan_mode(self, fan_mode: str) -> None: """Set the Fan Mode.""" - await self.aircon( - {self.ac_key: {"info": {"fan": HASS_FAN_MODES.get(fan_mode)}}} - ) + await self.async_update_ac({"fan": HASS_FAN_MODES.get(fan_mode)}) async def async_set_temperature(self, **kwargs: Any) -> None: """Set the Temperature.""" - temp = kwargs.get(ATTR_TEMPERATURE) - await self.aircon({self.ac_key: {"info": {"setTemp": temp}}}) + if ATTR_TEMPERATURE in kwargs: + await self.async_update_ac({"setTemp": kwargs[ATTR_TEMPERATURE]}) + if ATTR_TARGET_TEMP_LOW in kwargs and ATTR_TARGET_TEMP_HIGH in kwargs: + await self.async_update_ac( + { + ADVANTAGE_AIR_COOL_TARGET: kwargs[ATTR_TARGET_TEMP_HIGH], + ADVANTAGE_AIR_HEAT_TARGET: kwargs[ATTR_TARGET_TEMP_LOW], + } + ) class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): - """AdvantageAir Zone control.""" + """AdvantageAir MyTemp Zone control.""" + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT_COOL] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = PRECISION_WHOLE _attr_max_temp = 32 _attr_min_temp = 16 - _attr_hvac_modes = ZONE_HVAC_MODES - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an AdvantageAir Zone control.""" super().__init__(instance, ac_key, zone_key) self._attr_name = self._zone["name"] - self._attr_unique_id = ( - f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}' - ) @property def hvac_mode(self) -> HVACMode: @@ -196,7 +224,7 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): return HVACMode.OFF @property - def current_temperature(self) -> float: + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._zone["measuredTemp"] @@ -207,23 +235,11 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): async def async_turn_on(self) -> None: """Set the HVAC State to on.""" - await self.aircon( - { - self.ac_key: { - "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_OPEN}} - } - } - ) + await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_OPEN}) async def async_turn_off(self) -> None: """Set the HVAC State to off.""" - await self.aircon( - { - self.ac_key: { - "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}} - } - } - ) + await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_CLOSE}) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC Mode and State.""" @@ -235,4 +251,4 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set the Temperature.""" temp = kwargs.get(ATTR_TEMPERATURE) - await self.aircon({self.ac_key: {"zones": {self.zone_key: {"setTemp": temp}}}}) + await self.async_update_zone({"setTemp": temp}) diff --git a/homeassistant/components/advantage_air/cover.py b/homeassistant/components/advantage_air/cover.py index 8d05f7e2e6..afb38dee93 100644 --- a/homeassistant/components/advantage_air/cover.py +++ b/homeassistant/components/advantage_air/cover.py @@ -16,7 +16,8 @@ from .const import ( ADVANTAGE_AIR_STATE_OPEN, DOMAIN as ADVANTAGE_AIR_DOMAIN, ) -from .entity import AdvantageAirZoneEntity +from .entity import AdvantageAirThingEntity, AdvantageAirZoneEntity +from .models import AdvantageAirData PARALLEL_UPDATES = 0 @@ -28,15 +29,25 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir cover platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] entities: list[CoverEntity] = [] - if aircons := instance["coordinator"].data.get("aircons"): + if aircons := instance.coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): for zone_key, zone in ac_device["zones"].items(): # Only add zone vent controls when zone in vent control mode. if zone["type"] == 0: entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key)) + if things := instance.coordinator.data.get("myThings"): + for thing in things["things"].values(): + if thing["channelDipState"] in [1, 2]: # 1 = "Blind", 2 = "Blind 2" + entities.append( + AdvantageAirThingCover(instance, thing, CoverDeviceClass.BLIND) + ) + elif thing["channelDipState"] == 3: # 3 = "Garage door" + entities.append( + AdvantageAirThingCover(instance, thing, CoverDeviceClass.GARAGE) + ) async_add_entities(entities) @@ -50,7 +61,7 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, CoverEntity): | CoverEntityFeature.SET_POSITION ) - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Vent.""" super().__init__(instance, ac_key, zone_key) self._attr_name = self._zone["name"] @@ -69,47 +80,52 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Fully open zone vent.""" - await self.aircon( - { - self.ac_key: { - "zones": { - self.zone_key: {"state": ADVANTAGE_AIR_STATE_OPEN, "value": 100} - } - } - } + await self.async_update_zone( + {"state": ADVANTAGE_AIR_STATE_OPEN, "value": 100}, ) async def async_close_cover(self, **kwargs: Any) -> None: """Fully close zone vent.""" - await self.aircon( - { - self.ac_key: { - "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}} - } - } - ) + await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_CLOSE}) async def async_set_cover_position(self, **kwargs: Any) -> None: """Change vent position.""" position = round(kwargs[ATTR_POSITION] / 5) * 5 if position == 0: - await self.aircon( - { - self.ac_key: { - "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}} - } - } - ) + await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_CLOSE}) else: - await self.aircon( + await self.async_update_zone( { - self.ac_key: { - "zones": { - self.zone_key: { - "state": ADVANTAGE_AIR_STATE_OPEN, - "value": position, - } - } - } + "state": ADVANTAGE_AIR_STATE_OPEN, + "value": position, } ) + + +class AdvantageAirThingCover(AdvantageAirThingEntity, CoverEntity): + """Representation of Advantage Air Cover controlled by MyPlace.""" + + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__( + self, + instance: AdvantageAirData, + thing: dict[str, Any], + device_class: CoverDeviceClass, + ) -> None: + """Initialize an Advantage Air Things Cover.""" + super().__init__(instance, thing) + self._attr_device_class = device_class + + @property + def is_closed(self) -> bool: + """Return if cover is fully closed.""" + return self._data["value"] == 0 + + async def async_open_cover(self, **kwargs: Any) -> None: + """Fully open zone vent.""" + return await self.async_turn_on() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Fully close zone vent.""" + return await self.async_turn_off() diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index aaaa4ff581..bbc8738c4a 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -1,11 +1,14 @@ """Advantage Air parent entity class.""" - from typing import Any +from advantage_air import ApiError + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .models import AdvantageAirData class AdvantageAirEntity(CoordinatorEntity): @@ -13,19 +16,34 @@ class AdvantageAirEntity(CoordinatorEntity): _attr_has_entity_name = True - def __init__(self, instance: dict[str, Any]) -> None: + def __init__(self, instance: AdvantageAirData) -> None: """Initialize common aspects of an Advantage Air entity.""" - super().__init__(instance["coordinator"]) + super().__init__(instance.coordinator) self._attr_unique_id: str = self.coordinator.data["system"]["rid"] + def update_handle_factory(self, func, *keys): + """Return the provided API function wrapped. + + Adds an error handler and coordinator refresh, and presets keys. + """ + + async def update_handle(*values): + try: + if await func(*keys, *values): + await self.coordinator.async_refresh() + except ApiError as err: + raise HomeAssistantError(err) from err + + return update_handle + class AdvantageAirAcEntity(AdvantageAirEntity): """Parent class for Advantage Air AC Entities.""" - def __init__(self, instance: dict[str, Any], ac_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize common aspects of an Advantage Air ac entity.""" super().__init__(instance) - self.aircon = instance["aircon"] + self.ac_key: str = ac_key self._attr_unique_id += f"-{ac_key}" @@ -36,6 +54,9 @@ class AdvantageAirAcEntity(AdvantageAirEntity): model=self.coordinator.data["system"]["sysType"], name=self.coordinator.data["aircons"][self.ac_key]["info"]["name"], ) + self.async_update_ac = self.update_handle_factory( + instance.api.aircon.async_update_ac, self.ac_key + ) @property def _ac(self) -> dict[str, Any]: @@ -45,12 +66,56 @@ class AdvantageAirAcEntity(AdvantageAirEntity): class AdvantageAirZoneEntity(AdvantageAirAcEntity): """Parent class for Advantage Air Zone Entities.""" - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize common aspects of an Advantage Air zone entity.""" super().__init__(instance, ac_key) + self.zone_key: str = zone_key self._attr_unique_id += f"-{zone_key}" + self.async_update_zone = self.update_handle_factory( + instance.api.aircon.async_update_zone, self.ac_key, self.zone_key + ) @property def _zone(self) -> dict[str, Any]: return self.coordinator.data["aircons"][self.ac_key]["zones"][self.zone_key] + + +class AdvantageAirThingEntity(AdvantageAirEntity): + """Parent class for Advantage Air Things Entities.""" + + def __init__(self, instance: AdvantageAirData, thing: dict[str, Any]) -> None: + """Initialize common aspects of an Advantage Air Things entity.""" + super().__init__(instance) + + self._id = thing["id"] + self._attr_unique_id += f"-{self._id}" + + self._attr_device_info = DeviceInfo( + via_device=(DOMAIN, self.coordinator.data["system"]["rid"]), + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer="Advantage Air", + model="MyPlace", + name=thing["name"], + ) + self.async_update_value = self.update_handle_factory( + instance.api.things.async_update_value, self._id + ) + + @property + def _data(self) -> dict: + """Return the thing data.""" + return self.coordinator.data["myThings"]["things"][self._id] + + @property + def is_on(self): + """Return if the thing is considered on.""" + return self._data["value"] > 0 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the thing on.""" + await self.async_update_value(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the thing off.""" + await self.async_update_value(False) diff --git a/homeassistant/components/advantage_air/light.py b/homeassistant/components/advantage_air/light.py index f0ae669acd..13a77d5cab 100644 --- a/homeassistant/components/advantage_air/light.py +++ b/homeassistant/components/advantage_air/light.py @@ -7,12 +7,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, - DOMAIN as ADVANTAGE_AIR_DOMAIN, -) -from .entity import AdvantageAirEntity +from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN +from .entity import AdvantageAirEntity, AdvantageAirThingEntity +from .models import AdvantageAirData async def async_setup_entry( @@ -22,15 +19,21 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir light platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] entities: list[LightEntity] = [] - if my_lights := instance["coordinator"].data.get("myLights"): + if my_lights := instance.coordinator.data.get("myLights"): for light in my_lights["lights"].values(): if light.get("relay"): entities.append(AdvantageAirLight(instance, light)) else: entities.append(AdvantageAirLightDimmable(instance, light)) + if things := instance.coordinator.data.get("myThings"): + for thing in things["things"].values(): + if thing["channelDipState"] == 4: # 4 = "Light (on/off)"" + entities.append(AdvantageAirThingLight(instance, thing)) + elif thing["channelDipState"] == 5: # 5 = "Light (Dimmable)"" + entities.append(AdvantageAirThingLightDimmable(instance, thing)) async_add_entities(entities) @@ -39,10 +42,10 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity): _attr_supported_color_modes = {ColorMode.ONOFF} - def __init__(self, instance: dict[str, Any], light: dict[str, Any]) -> None: + def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None: """Initialize an Advantage Air Light.""" super().__init__(instance) - self.lights = instance["lights"] + self._id: str = light["id"] self._attr_unique_id += f"-{self._id}" self._attr_device_info = DeviceInfo( @@ -52,24 +55,27 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity): model=light.get("moduleType"), name=light["name"], ) + self.async_update_state = self.update_handle_factory( + instance.api.lights.async_update_state, self._id + ) @property - def _light(self) -> dict[str, Any]: + def _data(self) -> dict[str, Any]: """Return the light object.""" return self.coordinator.data["myLights"]["lights"][self._id] @property def is_on(self) -> bool: """Return if the light is on.""" - return self._light["state"] == ADVANTAGE_AIR_STATE_ON + return self._data["state"] == ADVANTAGE_AIR_STATE_ON async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - await self.lights({"id": self._id, "state": ADVANTAGE_AIR_STATE_ON}) + await self.async_update_state(True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - await self.lights({"id": self._id, "state": ADVANTAGE_AIR_STATE_OFF}) + await self.async_update_state(False) class AdvantageAirLightDimmable(AdvantageAirLight): @@ -77,14 +83,41 @@ class AdvantageAirLightDimmable(AdvantageAirLight): _attr_supported_color_modes = {ColorMode.ONOFF, ColorMode.BRIGHTNESS} + def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None: + """Initialize an Advantage Air Dimmable Light.""" + super().__init__(instance, light) + self.async_update_value = self.update_handle_factory( + instance.api.lights.async_update_value, self._id + ) + @property def brightness(self) -> int: """Return the brightness of this light between 0..255.""" - return round(self._light["value"] * 255 / 100) + return round(self._data["value"] * 255 / 100) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on and optionally set the brightness.""" - data: dict[str, Any] = {"id": self._id, "state": ADVANTAGE_AIR_STATE_ON} if ATTR_BRIGHTNESS in kwargs: - data["value"] = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255) - await self.lights(data) + return await self.async_update_value(round(kwargs[ATTR_BRIGHTNESS] / 2.55)) + return await self.async_update_state(True) + + +class AdvantageAirThingLight(AdvantageAirThingEntity, LightEntity): + """Representation of Advantage Air Light controlled by myThings.""" + + _attr_supported_color_modes = {ColorMode.ONOFF} + + +class AdvantageAirThingLightDimmable(AdvantageAirThingEntity, LightEntity): + """Representation of Advantage Air Dimmable Light controlled by myThings.""" + + _attr_supported_color_modes = {ColorMode.ONOFF, ColorMode.BRIGHTNESS} + + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return round(self._data["value"] * 255 / 100) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on by setting the brightness.""" + await self.async_update_value(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)) diff --git a/homeassistant/components/advantage_air/manifest.json b/homeassistant/components/advantage_air/manifest.json index 85b093ea73..ed9d3bff98 100644 --- a/homeassistant/components/advantage_air/manifest.json +++ b/homeassistant/components/advantage_air/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["advantage_air"], "quality_scale": "platinum", - "requirements": ["advantage_air==0.4.1"] + "requirements": ["advantage_air==0.4.4"] } diff --git a/homeassistant/components/advantage_air/models.py b/homeassistant/components/advantage_air/models.py new file mode 100644 index 0000000000..f56b3f8823 --- /dev/null +++ b/homeassistant/components/advantage_air/models.py @@ -0,0 +1,16 @@ +"""The Advantage Air integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from advantage_air import advantage_air + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + + +@dataclass +class AdvantageAirData: + """Data for the Advantage Air integration.""" + + coordinator: DataUpdateCoordinator + api: advantage_air diff --git a/homeassistant/components/advantage_air/select.py b/homeassistant/components/advantage_air/select.py index 742ce81001..013f2cc214 100644 --- a/homeassistant/components/advantage_air/select.py +++ b/homeassistant/components/advantage_air/select.py @@ -1,5 +1,4 @@ """Select platform for Advantage Air integration.""" -from typing import Any from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry @@ -8,6 +7,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN from .entity import AdvantageAirAcEntity +from .models import AdvantageAirData ADVANTAGE_AIR_INACTIVE = "Inactive" @@ -19,10 +19,10 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir select platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] entities: list[SelectEntity] = [] - if aircons := instance["coordinator"].data.get("aircons"): + if aircons := instance.coordinator.data.get("aircons"): for ac_key in aircons: entities.append(AdvantageAirMyZone(instance, ac_key)) async_add_entities(entities) @@ -34,7 +34,7 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity): _attr_icon = "mdi:home-thermometer" _attr_name = "MyZone" - def __init__(self, instance: dict[str, Any], ac_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize an Advantage Air MyZone control.""" super().__init__(instance, ac_key) self._attr_unique_id += "-myzone" @@ -42,11 +42,12 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity): self._number_to_name = {0: ADVANTAGE_AIR_INACTIVE} self._name_to_number = {ADVANTAGE_AIR_INACTIVE: 0} - for zone in instance["coordinator"].data["aircons"][ac_key]["zones"].values(): - if zone["type"] > 0: - self._name_to_number[zone["name"]] = zone["number"] - self._number_to_name[zone["number"]] = zone["name"] - self._attr_options.append(zone["name"]) + if "aircons" in instance.coordinator.data: + for zone in instance.coordinator.data["aircons"][ac_key]["zones"].values(): + if zone["type"] > 0: + self._name_to_number[zone["name"]] = zone["number"] + self._number_to_name[zone["number"]] = zone["name"] + self._attr_options.append(zone["name"]) @property def current_option(self) -> str: @@ -55,6 +56,4 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Set the MyZone.""" - await self.aircon( - {self.ac_key: {"info": {"myZone": self._name_to_number[option]}}} - ) + await self.async_update_ac({"myZone": self._name_to_number[option]}) diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index 04b3802f64..4af028e6db 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ADVANTAGE_AIR_STATE_OPEN, DOMAIN as ADVANTAGE_AIR_DOMAIN from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity +from .models import AdvantageAirData ADVANTAGE_AIR_SET_COUNTDOWN_VALUE = "minutes" ADVANTAGE_AIR_SET_COUNTDOWN_UNIT = "min" @@ -34,10 +35,10 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir sensor platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] entities: list[SensorEntity] = [] - if aircons := instance["coordinator"].data.get("aircons"): + if aircons := instance.coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): entities.append(AdvantageAirTimeTo(instance, ac_key, "On")) entities.append(AdvantageAirTimeTo(instance, ac_key, "Off")) @@ -65,7 +66,7 @@ class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity): _attr_native_unit_of_measurement = ADVANTAGE_AIR_SET_COUNTDOWN_UNIT _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: dict[str, Any], ac_key: str, action: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, action: str) -> None: """Initialize the Advantage Air timer control.""" super().__init__(instance, ac_key) self.action = action @@ -88,7 +89,7 @@ class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity): async def set_time_to(self, **kwargs: Any) -> None: """Set the timer value.""" value = min(720, max(0, int(kwargs[ADVANTAGE_AIR_SET_COUNTDOWN_VALUE]))) - await self.aircon({self.ac_key: {"info": {self._time_key: value}}}) + await self.async_update_ac({self._time_key: value}) class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity): @@ -98,7 +99,7 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity): _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Vent Sensor.""" super().__init__(instance, ac_key, zone_key=zone_key) self._attr_name = f'{self._zone["name"]} vent' @@ -126,7 +127,7 @@ class AdvantageAirZoneSignal(AdvantageAirZoneEntity, SensorEntity): _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone wireless signal sensor.""" super().__init__(instance, ac_key, zone_key) self._attr_name = f'{self._zone["name"]} signal' @@ -160,7 +161,7 @@ class AdvantageAirZoneTemp(AdvantageAirZoneEntity, SensorEntity): _attr_entity_registry_enabled_default = False _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Temp Sensor.""" super().__init__(instance, ac_key, zone_key) self._attr_name = f'{self._zone["name"]} temperature' diff --git a/homeassistant/components/advantage_air/switch.py b/homeassistant/components/advantage_air/switch.py index e3504ab762..7234ca3630 100644 --- a/homeassistant/components/advantage_air/switch.py +++ b/homeassistant/components/advantage_air/switch.py @@ -1,7 +1,7 @@ """Switch platform for Advantage Air integration.""" from typing import Any -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -11,7 +11,8 @@ from .const import ( ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN, ) -from .entity import AdvantageAirAcEntity +from .entity import AdvantageAirAcEntity, AdvantageAirThingEntity +from .models import AdvantageAirData async def async_setup_entry( @@ -21,13 +22,17 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir switch platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] entities: list[SwitchEntity] = [] - if aircons := instance["coordinator"].data.get("aircons"): + if aircons := instance.coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): if ac_device["info"]["freshAirStatus"] != "none": entities.append(AdvantageAirFreshAir(instance, ac_key)) + if things := instance.coordinator.data.get("myThings"): + for thing in things["things"].values(): + if thing["channelDipState"] == 8: # 8 = Other relay + entities.append(AdvantageAirRelay(instance, thing)) async_add_entities(entities) @@ -36,8 +41,9 @@ class AdvantageAirFreshAir(AdvantageAirAcEntity, SwitchEntity): _attr_icon = "mdi:air-filter" _attr_name = "Fresh air" + _attr_device_class = SwitchDeviceClass.SWITCH - def __init__(self, instance: dict[str, Any], ac_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize an Advantage Air fresh air control.""" super().__init__(instance, ac_key) self._attr_unique_id += "-freshair" @@ -49,12 +55,14 @@ class AdvantageAirFreshAir(AdvantageAirAcEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn fresh air on.""" - await self.aircon( - {self.ac_key: {"info": {"freshAirStatus": ADVANTAGE_AIR_STATE_ON}}} - ) + await self.async_update_ac({"freshAirStatus": ADVANTAGE_AIR_STATE_ON}) async def async_turn_off(self, **kwargs: Any) -> None: """Turn fresh air off.""" - await self.aircon( - {self.ac_key: {"info": {"freshAirStatus": ADVANTAGE_AIR_STATE_OFF}}} - ) + await self.async_update_ac({"freshAirStatus": ADVANTAGE_AIR_STATE_OFF}) + + +class AdvantageAirRelay(AdvantageAirThingEntity, SwitchEntity): + """Representation of Advantage Air Thing.""" + + _attr_device_class = SwitchDeviceClass.SWITCH diff --git a/homeassistant/components/advantage_air/update.py b/homeassistant/components/advantage_air/update.py index 404fcad744..a646ba3b52 100644 --- a/homeassistant/components/advantage_air/update.py +++ b/homeassistant/components/advantage_air/update.py @@ -1,5 +1,4 @@ """Advantage Air Update platform.""" -from typing import Any from homeassistant.components.update import UpdateEntity from homeassistant.config_entries import ConfigEntry @@ -9,6 +8,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN from .entity import AdvantageAirEntity +from .models import AdvantageAirData async def async_setup_entry( @@ -18,7 +18,7 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir update platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] async_add_entities([AdvantageAirApp(instance)]) @@ -28,7 +28,7 @@ class AdvantageAirApp(AdvantageAirEntity, UpdateEntity): _attr_name = "App" - def __init__(self, instance: dict[str, Any]) -> None: + def __init__(self, instance: AdvantageAirData) -> None: """Initialize the Advantage Air App.""" super().__init__(instance) self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 754471c9d8..53e15c651a 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -68,7 +68,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_CAQI, icon="mdi:air-filter", - name=ATTR_API_CAQI, + translation_key="caqi", native_unit_of_measurement="CAQI", suggested_display_precision=0, attrs=lambda data: { @@ -80,7 +80,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM1, device_class=SensorDeviceClass.PM1, - name="PM1.0", + translation_key="pm1", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -88,7 +88,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM25, device_class=SensorDeviceClass.PM25, - name="PM2.5", + translation_key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -100,7 +100,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM10, device_class=SensorDeviceClass.PM10, - name=ATTR_API_PM10, + translation_key="pm10", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -112,7 +112,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, - name=ATTR_API_HUMIDITY.capitalize(), + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -120,7 +120,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PRESSURE, device_class=SensorDeviceClass.PRESSURE, - name=ATTR_API_PRESSURE.capitalize(), + translation_key="pressure", native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -128,14 +128,14 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, - name=ATTR_API_TEMPERATURE.capitalize(), + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), AirlySensorEntityDescription( key=ATTR_API_CO, - name="Carbon monoxide", + translation_key="co", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -147,7 +147,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_NO2, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, - name="Nitrogen dioxide", + translation_key="no2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -159,7 +159,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_SO2, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, - name="Sulphur dioxide", + translation_key="so2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -171,7 +171,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_O3, device_class=SensorDeviceClass.OZONE, - name="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 4f95f26afc..50ebdd6d4d 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -26,5 +26,42 @@ "requests_remaining": "Remaining allowed requests", "requests_per_day": "Allowed requests per day" } + }, + "entity": { + "sensor": { + "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/airq/__init__.py b/homeassistant/components/airq/__init__.py index 4bc64e1e82..06d7ba3074 100644 --- a/homeassistant/components/airq/__init__.py +++ b/homeassistant/components/airq/__init__.py @@ -1,58 +1,16 @@ """The air-Q integration.""" from __future__ import annotations -from datetime import timedelta -import logging - -from aioairq import AirQ - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .coordinator import AirQCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -class AirQCoordinator(DataUpdateCoordinator): - """Coordinator is responsible for querying the device at a specified route.""" - - def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - ) -> None: - """Initialise a custom coordinator.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=timedelta(seconds=UPDATE_INTERVAL), - ) - session = async_get_clientsession(hass) - self.airq = AirQ( - entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session - ) - self.device_id = entry.unique_id - assert self.device_id is not None - self.device_info = DeviceInfo( - manufacturer=MANUFACTURER, - identifiers={(DOMAIN, self.device_id)}, - ) - self.device_info.update(entry.data["device_info"]) - - async def _async_update_data(self) -> dict: - """Fetch the data from the device.""" - data = await self.airq.get(TARGET_ROUTE) - return self.airq.drop_uncertainties_from_data(data) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up air-Q from a config entry.""" diff --git a/homeassistant/components/airq/config_flow.py b/homeassistant/components/airq/config_flow.py index 90a6b9e055..41eda912e9 100644 --- a/homeassistant/components/airq/config_flow.py +++ b/homeassistant/components/airq/config_flow.py @@ -74,12 +74,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) device_info = await airq.fetch_device_info() - await self.async_set_unique_id(device_info.pop("id")) + await self.async_set_unique_id(device_info["id"]) self._abort_if_unique_id_configured() return self.async_create_entry( - title=device_info["name"], - data=user_input | {"device_info": device_info}, + title=device_info["name"], data=user_input ) return self.async_show_form( diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py new file mode 100644 index 0000000000..78e9580c63 --- /dev/null +++ b/homeassistant/components/airq/coordinator.py @@ -0,0 +1,61 @@ +"""The air-Q integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from aioairq import AirQ + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class AirQCoordinator(DataUpdateCoordinator): + """Coordinator is responsible for querying the device at a specified route.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialise a custom coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + session = async_get_clientsession(hass) + self.airq = AirQ( + entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session + ) + self.device_id = entry.unique_id + assert self.device_id is not None + self.device_info = DeviceInfo( + manufacturer=MANUFACTURER, + identifiers={(DOMAIN, self.device_id)}, + ) + + async def _async_update_data(self) -> dict: + """Fetch the data from the device.""" + if "name" not in self.device_info: + info = await self.airq.fetch_device_info() + self.device_info.update( + DeviceInfo( + name=info["name"], + model=info["model"], + sw_version=info["sw_version"], + hw_version=info["hw_version"], + ) + ) + + data = await self.airq.get(TARGET_ROUTE) + return self.airq.drop_uncertainties_from_data(data) diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index a47c308279..7f0d51fcaa 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -51,6 +51,13 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) # Keys must match those in the data dictionary SENSOR_TYPES: list[AirQEntityDescription] = [ + AirQEntityDescription( + key="c2h4o", + name="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", @@ -58,6 +65,27 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("nh3_MR100"), ), + AirQEntityDescription( + key="ash3", + name="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", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("br2"), + ), + AirQEntityDescription( + key="ch4s", + name="CH4S", + 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", @@ -65,6 +93,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("cl2_M20"), ), + AirQEntityDescription( + key="clo2", + name="ClO2", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("clo2"), + ), AirQEntityDescription( key="co", name="CO", @@ -80,6 +115,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("co2"), ), + AirQEntityDescription( + key="cs2", + name="CS2", + 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", @@ -95,6 +137,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ethanol"), ), + AirQEntityDescription( + key="c2h4", + name="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", @@ -102,6 +151,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ch2o_M10"), ), + AirQEntityDescription( + key="f2", + name="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", @@ -109,6 +165,27 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2s"), ), + AirQEntityDescription( + key="hcl", + name="HCl", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("hcl"), + ), + AirQEntityDescription( + key="hcn", + name="HCN", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("hcn"), + ), + AirQEntityDescription( + key="hf", + name="HF", + 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", @@ -140,6 +217,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2_M1000"), ), + AirQEntityDescription( + key="h2o2", + name="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", @@ -172,12 +256,11 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ value=lambda data: data.get("no2"), ), AirQEntityDescription( - key="o3", - name="Ozone", - device_class=SensorDeviceClass.OZONE, - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + key="acid_M100", + name="Organic acid", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, - value=lambda data: data.get("o3"), + value=lambda data: data.get("acid_M100"), ), AirQEntityDescription( key="oxygen", @@ -187,6 +270,14 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ value=lambda data: data.get("oxygen"), icon="mdi:leaf", ), + AirQEntityDescription( + key="o3", + name="Ozone", + device_class=SensorDeviceClass.OZONE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("o3"), + ), AirQEntityDescription( key="performance", name="Performance Index", @@ -195,6 +286,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ icon="mdi:head-check", value=lambda data: data.get("performance", 0.0) / 10.0, ), + AirQEntityDescription( + key="ph3", + name="PH3", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ph3"), + ), AirQEntityDescription( key="pm1", name="PM1", @@ -245,6 +343,20 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("c3h8_MIPEX"), ), + AirQEntityDescription( + key="refigerant", + name="Refrigerant", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("refigerant"), + ), + AirQEntityDescription( + key="sih4", + name="SiH4", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("sih4"), + ), AirQEntityDescription( key="so2", name="SO2", @@ -299,6 +411,14 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc_ionsc"), ), + AirQEntityDescription( + key="virus", + name="Virus Index", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:virus-off", + value=lambda data: data.get("virus", 0.0), + ), ] diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 793b787927..21be2e5d66 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -380,7 +380,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) else: entry.version = version - hass.config_entries.async_update_entry(entry) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 5837920560..2cf526e562 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from typing import Any -from AIOAladdinConnect import AladdinConnectClient +from AIOAladdinConnect import AladdinConnectClient, session_manager from homeassistant.components.cover import CoverDeviceClass, CoverEntity from homeassistant.config_entries import ConfigEntry @@ -46,7 +46,7 @@ class AladdinDevice(CoverEntity): ) -> 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"] @@ -85,7 +85,18 @@ class AladdinDevice(CoverEntity): async def async_update(self) -> None: """Update status of cover.""" - await self._acc.get_doors(self._serial) + try: + await self._acc.get_doors(self._serial) + self._attr_available = True + + except session_manager.ConnectionError: + self._attr_available = False + + except session_manager.InvalidPasswordError: + self._attr_available = False + await self.hass.async_create_task( + self.hass.config_entries.async_reload(self._entry_id) + ) @property def is_closed(self) -> bool | None: diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 120b24dcc4..d7d495b55b 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HassJob, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent @@ -237,7 +237,13 @@ class Alert(Entity): """Schedule a notification.""" delay = self._delay[self._next_delay] next_msg = now() + delay - self._cancel = async_track_point_in_time(self.hass, self._notify, next_msg) + self._cancel = async_track_point_in_time( + self.hass, + HassJob( + self._notify, name="Schedule notify alert", cancel_on_shutdown=True + ), + next_msg, + ) self._next_delay = min(self._next_delay + 1, len(self._delay) - 1) async def _notify(self, *args: Any) -> None: diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py index 7e21b9ac60..97e0af7f18 100644 --- a/homeassistant/components/amazon_polly/tts.py +++ b/homeassistant/components/amazon_polly/tts.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Final +from typing import Any, Final import boto3 import botocore @@ -166,8 +166,8 @@ class AmazonPollyProvider(Provider): def get_tts_audio( self, message: str, - language: str | None = None, - options: dict[str, str] | None = None, + language: str, + options: dict[str, Any] | None = None, ) -> TtsAudioType: """Request TTS file from Polly.""" if options is None or language is None: diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 5dd8f0fb2f..f68ae3df11 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -117,7 +117,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: en_reg.async_clear_config_entry(entry.entry_id) version = entry.version = 2 - hass.config_entries.async_update_entry(entry) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index bd07303df3..ebd0365106 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["aioambient"], - "requirements": ["aioambient==2021.11.0"] + "requirements": ["aioambient==2023.04.0"] } diff --git a/homeassistant/components/ampio/air_quality.py b/homeassistant/components/ampio/air_quality.py index f8119e9c1b..a423a62836 100644 --- a/homeassistant/components/ampio/air_quality.py +++ b/homeassistant/components/ampio/air_quality.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle -from .const import ATTRIBUTION, CONF_STATION_ID, SCAN_INTERVAL +from .const import CONF_STATION_ID, SCAN_INTERVAL _LOGGER: Final = logging.getLogger(__name__) @@ -54,6 +54,8 @@ async def async_setup_platform( class AmpioSmogQuality(AirQualityEntity): """Implementation of an Ampio Smog air quality entity.""" + _attr_attribution = "Data provided by Ampio" + def __init__( self, api: AmpioSmogMapData, station_id: str, name: str | None ) -> None: @@ -82,11 +84,6 @@ class AmpioSmogQuality(AirQualityEntity): """Return the particulate matter 10 level.""" return self._ampio.api.pm10 # type: ignore[no-any-return] - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - async def async_update(self) -> None: """Get the latest data from the AmpioMap API.""" await self._ampio.async_update() diff --git a/homeassistant/components/ampio/const.py b/homeassistant/components/ampio/const.py index 3162308ff4..b1a13ce941 100644 --- a/homeassistant/components/ampio/const.py +++ b/homeassistant/components/ampio/const.py @@ -2,6 +2,5 @@ from datetime import timedelta from typing import Final -ATTRIBUTION: Final = "Data provided by Ampio" CONF_STATION_ID: Final = "station_id" SCAN_INTERVAL: Final = timedelta(minutes=10) diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index ad53fb0311..c02c1a3a3b 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import Event, HassJob, HomeAssistant, callback from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType @@ -24,10 +24,24 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: def start_schedule(_event: Event) -> None: """Start the send schedule after the started event.""" # Wait 15 min after started - async_call_later(hass, 900, analytics.send_analytics) + async_call_later( + hass, + 900, + HassJob( + analytics.send_analytics, + name="analytics schedule", + cancel_on_shutdown=True, + ), + ) # Send every day - async_track_time_interval(hass, analytics.send_analytics, INTERVAL) + async_track_time_interval( + hass, + analytics.send_analytics, + INTERVAL, + name="analytics daily", + cancel_on_shutdown=True, + ) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule) diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index d10b1161da..4a1ad55e0b 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -1,4 +1,4 @@ -"""Support for functionality to interact with Android TV/Fire TV devices.""" +"""Support for functionality to interact with Android/Fire TV devices.""" from __future__ import annotations from collections.abc import Mapping @@ -135,11 +135,11 @@ async def async_connect_androidtv( if not aftv.available: # Determine the name that will be used for the device in the log if config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV: - device_name = "Android TV device" + device_name = "Android device" elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV: device_name = "Fire TV device" else: - device_name = "Android TV / Fire TV device" + device_name = "Android / Fire TV device" error_message = f"Could not connect to {device_name} at {address} {adb_log}" return None, error_message @@ -148,7 +148,7 @@ async def async_connect_androidtv( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Android TV platform.""" + """Set up Android Debug Bridge platform.""" state_det_rules = entry.options.get(CONF_STATE_DETECTION_RULES) if CONF_ADB_SERVER_IP not in entry.data: @@ -167,7 +167,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(error_message) async def async_close_connection(event): - """Close Android TV connection on HA Stop.""" + """Close Android Debug Bridge connection on HA Stop.""" await aftv.adb_close() entry.async_on_unload( diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index bac5a9aec6..7e2b1e85f3 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure the Android TV integration.""" +"""Config flow to configure the Android Debug Bridge integration.""" from __future__ import annotations import logging @@ -114,13 +114,14 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): async def _async_check_connection( self, user_input: dict[str, Any] ) -> tuple[str | None, str | None]: - """Attempt to connect the Android TV.""" + """Attempt to connect the Android device.""" try: aftv, error_message = await async_connect_androidtv(self.hass, user_input) except Exception: # pylint: disable=broad-except _LOGGER.exception( - "Unknown error connecting with Android TV at %s", user_input[CONF_HOST] + "Unknown error connecting with Android device at %s", + user_input[CONF_HOST], ) return RESULT_UNKNOWN, None @@ -130,7 +131,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): dev_prop = aftv.device_properties _LOGGER.info( - "Android TV at %s: %s = %r, %s = %r", + "Android device at %s: %s = %r, %s = %r", user_input[CONF_HOST], PROP_ETHMAC, dev_prop.get(PROP_ETHMAC), @@ -184,7 +185,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(OptionsFlowWithConfigEntry): - """Handle an option flow for Android TV.""" + """Handle an option flow for Android Debug Bridge.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" diff --git a/homeassistant/components/androidtv/const.py b/homeassistant/components/androidtv/const.py index 7f1e128851..1793642168 100644 --- a/homeassistant/components/androidtv/const.py +++ b/homeassistant/components/androidtv/const.py @@ -1,4 +1,4 @@ -"""Android TV component constants.""" +"""Android Debug Bridge component constants.""" DOMAIN = "androidtv" ANDROID_DEV = DOMAIN diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 2de47c65ad..f782db7987 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -1,6 +1,6 @@ { "domain": "androidtv", - "name": "Android TV", + "name": "Android Debug Bridge", "codeowners": ["@JeffLIrion", "@ollo69"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/androidtv", diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index fb01ffce77..563b8f07b2 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -1,4 +1,4 @@ -"""Support for functionality to interact with Android TV / Fire TV devices.""" +"""Support for functionality to interact with Android / Fire TV devices.""" from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine @@ -87,7 +87,7 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Android TV entity.""" + """Set up the Android Debug Bridge entity.""" aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV] device_class = aftv.DEVICE_CLASS device_type = ( @@ -201,7 +201,7 @@ def adb_decorator( class ADBDevice(MediaPlayerEntity): - """Representation of an Android TV or Fire TV device.""" + """Representation of an Android or Fire TV device.""" _attr_device_class = MediaPlayerDeviceClass.TV @@ -214,7 +214,7 @@ class ADBDevice(MediaPlayerEntity): entry_id, entry_data, ): - """Initialize the Android TV / Fire TV device.""" + """Initialize the Android / Fire TV device.""" self.aftv = aftv self._attr_name = name self._attr_unique_id = unique_id @@ -384,7 +384,7 @@ class ADBDevice(MediaPlayerEntity): @adb_decorator() async def adb_command(self, command): - """Send an ADB command to an Android TV / Fire TV device.""" + """Send an ADB command to an Android / Fire TV device.""" if key := KEYS.get(command): await self.aftv.adb_shell(f"input keyevent {key}") return @@ -422,13 +422,13 @@ class ADBDevice(MediaPlayerEntity): persistent_notification.async_create( self.hass, msg, - title="Android TV", + title="Android Debug Bridge", ) _LOGGER.info("%s", msg) @adb_decorator() async def service_download(self, device_path, local_path): - """Download a file from your Android TV / Fire TV device to your Home Assistant instance.""" + """Download a file from your Android / Fire TV device to your Home Assistant instance.""" if not self.hass.config.is_allowed_path(local_path): _LOGGER.warning("'%s' is not secure to load data from!", local_path) return @@ -437,7 +437,7 @@ class ADBDevice(MediaPlayerEntity): @adb_decorator() async def service_upload(self, device_path, local_path): - """Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" + """Upload a file from your Home Assistant instance to an Android / Fire TV device.""" if not self.hass.config.is_allowed_path(local_path): _LOGGER.warning("'%s' is not secure to load data from!", local_path) return @@ -446,7 +446,7 @@ class ADBDevice(MediaPlayerEntity): class AndroidTVDevice(ADBDevice): - """Representation of an Android TV device.""" + """Representation of an Android device.""" _attr_supported_features = ( MediaPlayerEntityFeature.PAUSE diff --git a/homeassistant/components/androidtv/services.yaml b/homeassistant/components/androidtv/services.yaml index fef06266e5..4482f50f3e 100644 --- a/homeassistant/components/androidtv/services.yaml +++ b/homeassistant/components/androidtv/services.yaml @@ -1,8 +1,8 @@ -# Describes the format for available Android TV and Fire TV services +# Describes the format for available Android and Fire TV services adb_command: name: ADB command - description: Send an ADB command to an Android TV / Fire TV device. + description: Send an ADB command to an Android / Fire TV device. target: entity: integration: androidtv @@ -17,7 +17,7 @@ adb_command: text: download: name: Download - description: Download a file from your Android TV / Fire TV device to your Home Assistant instance. + description: Download a file from your Android / Fire TV device to your Home Assistant instance. target: entity: integration: androidtv @@ -25,7 +25,7 @@ download: fields: device_path: name: Device path - description: The filepath on the Android TV / Fire TV device. + description: The filepath on the Android / Fire TV device. required: true example: "/storage/emulated/0/Download/example.txt" selector: @@ -39,7 +39,7 @@ download: text: upload: name: Upload - description: Upload a file from your Home Assistant instance to an Android TV / Fire TV device. + description: Upload a file from your Home Assistant instance to an Android / Fire TV device. target: entity: integration: androidtv @@ -47,7 +47,7 @@ upload: fields: device_path: name: Device path - description: The filepath on the Android TV / Fire TV device. + description: The filepath on the Android / Fire TV device. required: true example: "/storage/emulated/0/Download/example.txt" selector: diff --git a/homeassistant/components/androidtv/strings.json b/homeassistant/components/androidtv/strings.json index 7a46228bd4..e7d06a9f62 100644 --- a/homeassistant/components/androidtv/strings.json +++ b/homeassistant/components/androidtv/strings.json @@ -38,7 +38,7 @@ } }, "apps": { - "title": "Configure Android TV Apps", + "title": "Configure Android Apps", "description": "Configure application id {app_id}", "data": { "app_name": "Application Name", @@ -47,7 +47,7 @@ } }, "rules": { - "title": "Configure Android TV state detection rules", + "title": "Configure Android state detection rules", "description": "Configure detection rule for application id {rule_id}", "data": { "rule_id": "Application ID", diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py new file mode 100644 index 0000000000..fb275342cb --- /dev/null +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -0,0 +1,67 @@ +"""The Android TV Remote integration.""" +from __future__ import annotations + +from androidtvremote2 import ( + AndroidTVRemote, + CannotConnect, + ConnectionClosed, + InvalidAuth, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .const import DOMAIN +from .helpers import create_api + +PLATFORMS: list[Platform] = [Platform.REMOTE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Android TV Remote from a config entry.""" + + api = create_api(hass, entry.data[CONF_HOST]) + try: + await api.async_connect() + except InvalidAuth as exc: + # The Android TV is hard reset or the certificate and key files were deleted. + raise ConfigEntryAuthFailed from exc + except (CannotConnect, ConnectionClosed) as exc: + # The Android TV is network unreachable. Raise exception and let Home Assistant retry + # later. If device gets a new IP address the zeroconf flow will update the config. + raise ConfigEntryNotReady from exc + + def reauth_needed() -> None: + """Start a reauth flow if Android TV is hard reset while reconnecting.""" + entry.async_start_reauth(hass) + + # Start a task (canceled in disconnect) to keep reconnecting if device becomes + # network unreachable. If device gets a new IP address the zeroconf flow will + # update the config entry data and reload the config entry. + api.keep_reconnecting(reauth_needed) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + @callback + def on_hass_stop(event) -> None: + """Stop push updates when hass stops.""" + api.disconnect() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + api: AndroidTVRemote = hass.data[DOMAIN].pop(entry.entry_id) + api.disconnect() + + return unload_ok diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py new file mode 100644 index 0000000000..24b64c622a --- /dev/null +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -0,0 +1,187 @@ +"""Config flow for Android TV Remote integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from androidtvremote2 import ( + AndroidTVRemote, + CannotConnect, + ConnectionClosed, + InvalidAuth, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN +from .helpers import create_api + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required("host"): str, + } +) + +STEP_PAIR_DATA_SCHEMA = vol.Schema( + { + vol.Required("pin"): str, + } +) + + +class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Android TV Remote.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize a new AndroidTVRemoteConfigFlow.""" + self.api: AndroidTVRemote | None = None + self.reauth_entry: config_entries.ConfigEntry | None = None + self.host: str | None = None + self.name: str | None = None + self.mac: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self.host = user_input["host"] + assert self.host + api = create_api(self.hass, self.host) + try: + self.name, self.mac = await api.async_get_name_and_mac() + assert self.mac + await self.async_set_unique_id(format_mac(self.mac)) + self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Likely invalid IP address or device is network unreachable. Stay + # in the user step allowing the user to enter a different host. + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def _async_start_pair(self) -> FlowResult: + """Start pairing with the Android TV. Navigate to the pair flow to enter the PIN shown on screen.""" + assert self.host + self.api = create_api(self.hass, self.host) + await self.api.async_generate_cert_if_missing() + await self.api.async_start_pairing() + return await self.async_step_pair() + + async def async_step_pair( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the pair step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + pin = user_input["pin"] + assert self.api + await self.api.async_finish_pairing(pin) + if self.reauth_entry: + await self.hass.config_entries.async_reload( + self.reauth_entry.entry_id + ) + return self.async_abort(reason="reauth_successful") + assert self.name + return self.async_create_entry( + title=self.name, + data={ + CONF_HOST: self.host, + CONF_NAME: self.name, + CONF_MAC: self.mac, + }, + ) + except InvalidAuth: + # Invalid PIN. Stay in the pair step allowing the user to enter + # a different PIN. + errors["base"] = "invalid_auth" + except ConnectionClosed: + # Either user canceled pairing on the Android TV itself (most common) + # or device doesn't respond to the specified host (device was unplugged, + # network was unplugged, or device got a new IP address). + # Attempt to pair again. + try: + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Device doesn't respond to the specified host. Abort. + # If we are in the user flow we could go back to the user step to allow + # them to enter a new IP address but we cannot do that for the zeroconf + # flow. Simpler to abort for both flows. + return self.async_abort(reason="cannot_connect") + return self.async_show_form( + step_id="pair", + data_schema=STEP_PAIR_DATA_SCHEMA, + description_placeholders={CONF_NAME: self.name}, + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + self.host = discovery_info.host + self.name = discovery_info.name.removesuffix("._androidtvremote2._tcp.local.") + self.mac = discovery_info.properties.get("bt") + assert self.mac + await self.async_set_unique_id(format_mac(self.mac)) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self.host, CONF_NAME: self.name} + ) + self.context.update({"title_placeholders": {CONF_NAME: self.name}}) + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + if user_input is not None: + try: + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Device became network unreachable after discovery. + # Abort and let discovery find it again later. + return self.async_abort(reason="cannot_connect") + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={CONF_NAME: self.name}, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self.host = entry_data[CONF_HOST] + self.name = entry_data[CONF_NAME] + self.mac = entry_data[CONF_MAC] + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Device is network unreachable. Abort. + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: self.name}, + errors=errors, + ) diff --git a/homeassistant/components/androidtv_remote/const.py b/homeassistant/components/androidtv_remote/const.py new file mode 100644 index 0000000000..82f494b81a --- /dev/null +++ b/homeassistant/components/androidtv_remote/const.py @@ -0,0 +1,6 @@ +"""Constants for the Android TV Remote integration.""" +from __future__ import annotations + +from typing import Final + +DOMAIN: Final = "androidtv_remote" diff --git a/homeassistant/components/androidtv_remote/diagnostics.py b/homeassistant/components/androidtv_remote/diagnostics.py new file mode 100644 index 0000000000..28d16bf94f --- /dev/null +++ b/homeassistant/components/androidtv_remote/diagnostics.py @@ -0,0 +1,29 @@ +"""Diagnostics support for Android TV Remote.""" +from __future__ import annotations + +from typing import Any + +from androidtvremote2 import AndroidTVRemote + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_MAC +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {CONF_HOST, CONF_MAC} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + api: AndroidTVRemote = hass.data[DOMAIN].pop(entry.entry_id) + return async_redact_data( + { + "api_device_info": api.device_info, + "config_entry_data": entry.data, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/androidtv_remote/helpers.py b/homeassistant/components/androidtv_remote/helpers.py new file mode 100644 index 0000000000..0bc1f1b904 --- /dev/null +++ b/homeassistant/components/androidtv_remote/helpers.py @@ -0,0 +1,18 @@ +"""Helper functions for Android TV Remote integration.""" +from __future__ import annotations + +from androidtvremote2 import AndroidTVRemote + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import STORAGE_DIR + + +def create_api(hass: HomeAssistant, host: str) -> AndroidTVRemote: + """Create an AndroidTVRemote instance.""" + return AndroidTVRemote( + client_name="Home Assistant", + certfile=hass.config.path(STORAGE_DIR, "androidtv_remote_cert.pem"), + keyfile=hass.config.path(STORAGE_DIR, "androidtv_remote_key.pem"), + host=host, + loop=hass.loop, + ) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json new file mode 100644 index 0000000000..0e5d896a11 --- /dev/null +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "androidtv_remote", + "name": "Android TV Remote", + "codeowners": ["@tronikos"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/androidtv_remote", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["androidtvremote2"], + "quality_scale": "platinum", + "requirements": ["androidtvremote2==0.0.7"], + "zeroconf": ["_androidtvremote2._tcp.local."] +} diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py new file mode 100644 index 0000000000..1c68c92bc6 --- /dev/null +++ b/homeassistant/components/androidtv_remote/remote.py @@ -0,0 +1,154 @@ +"""Remote control support for Android TV Remote.""" +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +import logging +from typing import Any + +from androidtvremote2 import AndroidTVRemote, ConnectionClosed + +from homeassistant.components.remote import ( + ATTR_ACTIVITY, + ATTR_DELAY_SECS, + ATTR_HOLD_SECS, + ATTR_NUM_REPEATS, + DEFAULT_DELAY_SECS, + DEFAULT_HOLD_SECS, + DEFAULT_NUM_REPEATS, + RemoteEntity, + RemoteEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + +PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Android TV remote entity based on a config entry.""" + api: AndroidTVRemote = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([AndroidTVRemoteEntity(api, config_entry)]) + + +class AndroidTVRemoteEntity(RemoteEntity): + """Representation of an Android TV Remote.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None: + """Initialize device.""" + self._api = api + self._host = config_entry.data[CONF_HOST] + self._name = config_entry.data[CONF_NAME] + self._attr_unique_id = config_entry.unique_id + self._attr_supported_features = RemoteEntityFeature.ACTIVITY + self._attr_is_on = api.is_on + self._attr_current_activity = api.current_app + device_info = api.device_info + assert config_entry.unique_id + assert device_info + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, config_entry.data[CONF_MAC])}, + identifiers={(DOMAIN, config_entry.unique_id)}, + name=self._name, + manufacturer=device_info["manufacturer"], + model=device_info["model"], + ) + + @callback + def is_on_updated(is_on: bool) -> None: + self._attr_is_on = is_on + self.async_write_ha_state() + + @callback + def current_app_updated(current_app: str) -> None: + self._attr_current_activity = current_app + self.async_write_ha_state() + + @callback + def is_available_updated(is_available: bool) -> None: + if is_available: + _LOGGER.info( + "Reconnected to %s at %s", + self._name, + self._host, + ) + else: + _LOGGER.warning( + "Disconnected from %s at %s", + self._name, + self._host, + ) + self._attr_available = is_available + self.async_write_ha_state() + + api.add_is_on_updated_callback(is_on_updated) + api.add_current_app_updated_callback(current_app_updated) + api.add_is_available_updated_callback(is_available_updated) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the Android TV on.""" + if not self.is_on: + self._send_key_command("POWER") + activity = kwargs.get(ATTR_ACTIVITY, "") + if activity: + self._send_launch_app_command(activity) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the Android TV off.""" + if self.is_on: + self._send_key_command("POWER") + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send commands to one device.""" + num_repeats = kwargs.get(ATTR_NUM_REPEATS, DEFAULT_NUM_REPEATS) + delay_secs = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + hold_secs = kwargs.get(ATTR_HOLD_SECS, DEFAULT_HOLD_SECS) + + for _ in range(num_repeats): + for single_command in command: + if hold_secs: + self._send_key_command(single_command, "START_LONG") + await asyncio.sleep(hold_secs) + self._send_key_command(single_command, "END_LONG") + else: + self._send_key_command(single_command, "SHORT") + await asyncio.sleep(delay_secs) + + def _send_key_command(self, key_code: str, direction: str = "SHORT") -> None: + """Send a key press to Android TV. + + This does not block; it buffers the data and arranges for it to be sent out asynchronously. + """ + try: + self._api.send_key_command(key_code, direction) + except ConnectionClosed as exc: + raise HomeAssistantError( + "Connection to Android TV device is closed" + ) from exc + + def _send_launch_app_command(self, app_link: str) -> None: + """Launch an app on Android TV. + + This does not block; it buffers the data and arranges for it to be sent out asynchronously. + """ + try: + self._api.send_launch_app_command(app_link) + except ConnectionClosed as exc: + raise HomeAssistantError( + "Connection to Android TV device is closed" + ) from exc diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json new file mode 100644 index 0000000000..983c604370 --- /dev/null +++ b/homeassistant/components/androidtv_remote/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Enter the IP address of the Android TV you want to add to Home Assistant. It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "zeroconf_confirm": { + "title": "Discovered Android TV", + "description": "Do you want to add the Android TV ({name}) to Home Assistant? It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen." + }, + "pair": { + "description": "Enter the pairing code displayed on the Android TV ({name}).", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "You need to pair again with the Android TV ({name})." + } + }, + "error": { + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/anova/__init__.py b/homeassistant/components/anova/__init__.py new file mode 100644 index 0000000000..7810e00ded --- /dev/null +++ b/homeassistant/components/anova/__init__.py @@ -0,0 +1,86 @@ +"""The Anova integration.""" +from __future__ import annotations + +import logging + +from anova_wifi import ( + AnovaApi, + AnovaPrecisionCooker, + AnovaPrecisionCookerSensor, + InvalidLogin, + NoDevicesFound, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN +from .coordinator import AnovaCoordinator +from .models import AnovaData +from .util import serialize_device_list + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Anova from a config entry.""" + api = AnovaApi( + aiohttp_client.async_get_clientsession(hass), + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) + try: + await api.authenticate() + except InvalidLogin as err: + _LOGGER.error( + "Login was incorrect - please log back in through the config flow. %s", err + ) + return False + assert api.jwt + api.existing_devices = [ + AnovaPrecisionCooker( + aiohttp_client.async_get_clientsession(hass), + device[0], + device[1], + api.jwt, + ) + for device in entry.data["devices"] + ] + try: + new_devices = await api.get_devices() + except NoDevicesFound: + # get_devices raises an exception if no devices are online + new_devices = [] + devices = api.existing_devices + if new_devices: + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + **{"devices": serialize_device_list(devices)}, + }, + ) + coordinators = [AnovaCoordinator(hass, device) for device in devices] + for coordinator in coordinators: + await coordinator.async_config_entry_first_refresh() + firmware_version = coordinator.data["sensors"][ + AnovaPrecisionCookerSensor.FIRMWARE_VERSION + ] + coordinator.async_setup(str(firmware_version)) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AnovaData( + api_jwt=api.jwt, precision_cookers=devices, coordinators=coordinators + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/anova/config_flow.py b/homeassistant/components/anova/config_flow.py new file mode 100644 index 0000000000..5d0d2dbf62 --- /dev/null +++ b/homeassistant/components/anova/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for Anova.""" +from __future__ import annotations + +from anova_wifi import AnovaApi, InvalidLogin, NoDevicesFound +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN +from .util import serialize_device_list + + +class AnovaConfligFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Sets up a config flow for Anova.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + api = AnovaApi( + aiohttp_client.async_get_clientsession(self.hass), + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + try: + await api.authenticate() + devices = await api.get_devices() + except InvalidLogin: + errors["base"] = "invalid_auth" + except NoDevicesFound: + errors["base"] = "no_devices_found" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" + else: + # We store device list in config flow in order to persist found devices on restart, as the Anova api get_devices does not return any devices that are offline. + device_list = serialize_device_list(devices) + return self.async_create_entry( + title="Anova", + data={ + CONF_USERNAME: api.username, + CONF_PASSWORD: api.password, + "devices": device_list, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) diff --git a/homeassistant/components/anova/const.py b/homeassistant/components/anova/const.py new file mode 100644 index 0000000000..0e3de12aca --- /dev/null +++ b/homeassistant/components/anova/const.py @@ -0,0 +1,6 @@ +"""Constants for the Anova integration.""" + +DOMAIN = "anova" + +ANOVA_CLIENT = "anova_api_client" +ANOVA_FIRMWARE_VERSION = "anova_firmware_version" diff --git a/homeassistant/components/anova/coordinator.py b/homeassistant/components/anova/coordinator.py new file mode 100644 index 0000000000..cd4eab9c2e --- /dev/null +++ b/homeassistant/components/anova/coordinator.py @@ -0,0 +1,55 @@ +"""Support for Anova Coordinators.""" +from datetime import timedelta +import logging + +from anova_wifi import AnovaOffline, AnovaPrecisionCooker +import async_timeout + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AnovaCoordinator(DataUpdateCoordinator): + """Anova custom coordinator.""" + + data: dict[str, dict[str, str | int | float]] + + def __init__( + self, + hass: HomeAssistant, + anova_device: AnovaPrecisionCooker, + ) -> None: + """Set up Anova Coordinator.""" + super().__init__( + hass, + name="Anova Precision Cooker", + logger=_LOGGER, + update_interval=timedelta(seconds=30), + ) + assert self.config_entry is not None + self._device_unique_id = anova_device.device_key + self.anova_device = anova_device + self.device_info: DeviceInfo | None = None + + @callback + def async_setup(self, firmware_version: str) -> None: + """Set the firmware version info.""" + self.device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_unique_id)}, + name="Anova Precision Cooker", + manufacturer="Anova", + model="Precision Cooker", + sw_version=firmware_version, + ) + + async def _async_update_data(self) -> dict[str, dict[str, str | int | float]]: + try: + async with async_timeout.timeout(5): + return await self.anova_device.update() + except AnovaOffline as err: + raise UpdateFailed(err) from err diff --git a/homeassistant/components/anova/entity.py b/homeassistant/components/anova/entity.py new file mode 100644 index 0000000000..fd104e194f --- /dev/null +++ b/homeassistant/components/anova/entity.py @@ -0,0 +1,30 @@ +"""Base entity for the Anova integration.""" +from __future__ import annotations + +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import AnovaCoordinator + + +class AnovaEntity(CoordinatorEntity[AnovaCoordinator], Entity): + """Defines a Anova entity.""" + + def __init__(self, coordinator: AnovaCoordinator) -> None: + """Initialize the Anova entity.""" + super().__init__(coordinator) + self.device = coordinator.anova_device + self._attr_device_info = coordinator.device_info + self._attr_has_entity_name = True + + +class AnovaDescriptionEntity(AnovaEntity, Entity): + """Defines a Anova entity that uses a description.""" + + def __init__( + self, coordinator: AnovaCoordinator, description: EntityDescription + ) -> None: + """Initialize the entity and declare unique id based on description key.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator._device_unique_id}_{description.key}" diff --git a/homeassistant/components/anova/manifest.json b/homeassistant/components/anova/manifest.json new file mode 100644 index 0000000000..d307a9314f --- /dev/null +++ b/homeassistant/components/anova/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "anova", + "name": "Anova", + "codeowners": ["@Lash-L"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/anova", + "iot_class": "cloud_polling", + "loggers": ["anova_wifi"], + "requirements": ["anova-wifi==0.8.0"] +} diff --git a/homeassistant/components/anova/models.py b/homeassistant/components/anova/models.py new file mode 100644 index 0000000000..a63355b2bb --- /dev/null +++ b/homeassistant/components/anova/models.py @@ -0,0 +1,15 @@ +"""Dataclass models for the Anova integration.""" +from dataclasses import dataclass + +from anova_wifi import AnovaPrecisionCooker + +from .coordinator import AnovaCoordinator + + +@dataclass +class AnovaData: + """Data for the Anova integration.""" + + api_jwt: str + precision_cookers: list[AnovaPrecisionCooker] + coordinators: list[AnovaCoordinator] diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py new file mode 100644 index 0000000000..a5ea3ee2fd --- /dev/null +++ b/homeassistant/components/anova/sensor.py @@ -0,0 +1,97 @@ +"""Support for Anova Sensors.""" +from __future__ import annotations + +from anova_wifi import AnovaPrecisionCookerSensor + +from homeassistant import config_entries +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfTemperature, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .entity import AnovaDescriptionEntity +from .models import AnovaData + +SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.COOK_TIME, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfTime.SECONDS, + icon="mdi:clock-outline", + translation_key="cook_time", + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.STATE, translation_key="state" + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.MODE, translation_key="mode" + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.TARGET_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="target_temperature", + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.COOK_TIME_REMAINING, + native_unit_of_measurement=UnitOfTime.SECONDS, + icon="mdi:clock-outline", + translation_key="cook_time_remaining", + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.HEATER_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="heater_temperature", + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.TRIAC_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="triac_temperature", + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.WATER_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="water_temperature", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Anova device.""" + anova_data: AnovaData = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + AnovaSensor(coordinator, description) + for coordinator in anova_data.coordinators + for description in SENSOR_DESCRIPTIONS + ) + + +class AnovaSensor(AnovaDescriptionEntity, SensorEntity): + """A sensor using Anova coordinator.""" + + @property + def native_value(self) -> StateType: + """Return the state.""" + return self.coordinator.data["sensors"][self.entity_description.key] diff --git a/homeassistant/components/anova/strings.json b/homeassistant/components/anova/strings.json new file mode 100644 index 0000000000..19d0e52b7d --- /dev/null +++ b/homeassistant/components/anova/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "no_devices_found": "No devices were found. Make sure you have at least one Anova device online" + } + }, + "entity": { + "sensor": { + "cook_time": { + "name": "Cook time" + }, + "state": { + "name": "State" + }, + "mode": { + "name": "Mode" + }, + "target_temperature": { + "name": "Target temperature" + }, + "cook_time_remaining": { + "name": "Cook time remaining" + }, + "heater_temperature": { + "name": "Heater temperature" + }, + "triac_temperature": { + "name": "Triac temperature" + }, + "water_temperature": { + "name": "Water temperature" + } + } + } +} diff --git a/homeassistant/components/anova/util.py b/homeassistant/components/anova/util.py new file mode 100644 index 0000000000..10e8fa0fef --- /dev/null +++ b/homeassistant/components/anova/util.py @@ -0,0 +1,8 @@ +"""Anova utilities.""" + +from anova_wifi import AnovaPrecisionCooker + + +def serialize_device_list(devices: list[AnovaPrecisionCooker]) -> list[tuple[str, str]]: + """Turn the device list into a serializable list that can be reconstructed.""" + return [(device.device_key, device.type) for device in devices] diff --git a/homeassistant/components/apcupsd/strings.json b/homeassistant/components/apcupsd/strings.json index 1ca53c0e85..aef33a6f8b 100644 --- a/homeassistant/components/apcupsd/strings.json +++ b/homeassistant/components/apcupsd/strings.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_status": "No status is reported from [%key:common::config_flow::data::host%]" + "no_status": "No status is reported from host" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 33521b3d06..f1471f2966 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -75,7 +75,7 @@ class AuthorizationServer: token_url: str -class ApplicationCredentialsStorageCollection(collection.StorageCollection): +class ApplicationCredentialsStorageCollection(collection.DictStorageCollection): """Application credential collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -94,7 +94,7 @@ class ApplicationCredentialsStorageCollection(collection.StorageCollection): return f"{info[CONF_DOMAIN]}.{info[CONF_CLIENT_ID]}" async def _update_data( - self, data: dict[str, str], update_data: dict[str, str] + self, item: dict[str, str], update_data: dict[str, str] ) -> dict[str, str]: """Return a new updated data object.""" raise ValueError("Updates not supported") @@ -144,13 +144,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: id_manager = collection.IDManager() storage_collection = ApplicationCredentialsStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) await storage_collection.async_load() hass.data[DOMAIN][DATA_STORAGE] = storage_collection - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index 132e6cedda..9a76d4843f 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", "iot_class": "local_polling", "loggers": ["arcam"], - "requirements": ["arcam-fmj==1.2.1"], + "requirements": ["arcam-fmj==1.3.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py new file mode 100644 index 0000000000..7af379804e --- /dev/null +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -0,0 +1,80 @@ +"""The Assist pipeline integration.""" +from __future__ import annotations + +from collections.abc import AsyncIterable + +from homeassistant.components import stt +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN +from .error import PipelineNotFound +from .pipeline import ( + Pipeline, + PipelineEvent, + PipelineEventCallback, + PipelineEventType, + PipelineInput, + PipelineRun, + PipelineStage, + async_create_default_pipeline, + async_get_pipeline, + async_get_pipelines, + async_setup_pipeline_store, +) +from .websocket_api import async_register_websocket_api + +__all__ = ( + "DOMAIN", + "async_create_default_pipeline", + "async_get_pipelines", + "async_setup", + "async_pipeline_from_audio_stream", + "Pipeline", + "PipelineEvent", + "PipelineEventType", +) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Assist pipeline integration.""" + await async_setup_pipeline_store(hass) + async_register_websocket_api(hass) + + return True + + +async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context: Context, + event_callback: PipelineEventCallback, + stt_metadata: stt.SpeechMetadata, + stt_stream: AsyncIterable[bytes], + pipeline_id: str | None = None, + conversation_id: str | None = None, + tts_audio_output: str | None = None, +) -> None: + """Create an audio pipeline from an audio stream.""" + pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id) + if pipeline is None: + raise PipelineNotFound( + "pipeline_not_found", f"Pipeline {pipeline_id} not found" + ) + + pipeline_input = PipelineInput( + conversation_id=conversation_id, + stt_metadata=stt_metadata, + stt_stream=stt_stream, + run=PipelineRun( + hass, + context=context, + pipeline=pipeline, + start_stage=PipelineStage.STT, + end_stage=PipelineStage.TTS, + event_callback=event_callback, + tts_audio_output=tts_audio_output, + ), + ) + + await pipeline_input.validate() + await pipeline_input.execute() diff --git a/homeassistant/components/assist_pipeline/const.py b/homeassistant/components/assist_pipeline/const.py new file mode 100644 index 0000000000..5cbdd5d635 --- /dev/null +++ b/homeassistant/components/assist_pipeline/const.py @@ -0,0 +1,2 @@ +"""Constants for the Assist pipeline integration.""" +DOMAIN = "assist_pipeline" diff --git a/homeassistant/components/assist_pipeline/error.py b/homeassistant/components/assist_pipeline/error.py new file mode 100644 index 0000000000..fa26d916ee --- /dev/null +++ b/homeassistant/components/assist_pipeline/error.py @@ -0,0 +1,30 @@ +"""Assist pipeline errors.""" + +from homeassistant.exceptions import HomeAssistantError + + +class PipelineError(HomeAssistantError): + """Base class for pipeline errors.""" + + def __init__(self, code: str, message: str) -> None: + """Set error message.""" + self.code = code + self.message = message + + super().__init__(f"Pipeline error code={code}, message={message}") + + +class PipelineNotFound(PipelineError): + """Unspecified pipeline picked.""" + + +class SpeechToTextError(PipelineError): + """Error in speech to text portion of pipeline.""" + + +class IntentRecognitionError(PipelineError): + """Error in intent recognition portion of pipeline.""" + + +class TextToSpeechError(PipelineError): + """Error in text to speech portion of pipeline.""" diff --git a/homeassistant/components/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json new file mode 100644 index 0000000000..e97ceae5de --- /dev/null +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "assist_pipeline", + "name": "Assist pipeline", + "codeowners": ["@balloob", "@synesthesiam"], + "dependencies": ["conversation", "stt", "tts"], + "documentation": "https://www.home-assistant.io/integrations/assist_pipeline", + "iot_class": "local_push", + "quality_scale": "internal", + "requirements": ["webrtcvad==2.0.10"] +} diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py new file mode 100644 index 0000000000..87fd9be0c4 --- /dev/null +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -0,0 +1,974 @@ +"""Classes for voice assistant pipelines.""" +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterable, Callable, Iterable +from dataclasses import asdict, dataclass, field +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.backports.enum import StrEnum +from homeassistant.components import conversation, media_source, stt, tts, websocket_api +from homeassistant.components.tts.media_source import ( + generate_media_source_id as tts_generate_media_source_id, +) +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.collection import ( + CollectionError, + ItemNotFound, + SerializedStorageCollection, + StorageCollection, + StorageCollectionWebsocket, +) +from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store +from homeassistant.util import ( + dt as dt_util, + language as language_util, + ulid as ulid_util, +) +from homeassistant.util.limited_size_dict import LimitedSizeDict + +from .const import DOMAIN +from .error import ( + IntentRecognitionError, + PipelineError, + SpeechToTextError, + TextToSpeechError, +) + +_LOGGER = logging.getLogger(__name__) + +STORAGE_KEY = f"{DOMAIN}.pipelines" +STORAGE_VERSION = 1 + +ENGINE_LANGUAGE_PAIRS = ( + ("stt_engine", "stt_language"), + ("tts_engine", "tts_language"), +) + + +def validate_language(data: dict[str, Any]) -> Any: + """Validate language settings.""" + for engine, language in ENGINE_LANGUAGE_PAIRS: + if data[engine] is not None and data[language] is None: + raise vol.Invalid(f"Need language {language} for {engine} {data[engine]}") + return data + + +PIPELINE_FIELDS = { + vol.Required("conversation_engine"): str, + vol.Required("conversation_language"): str, + vol.Required("language"): str, + vol.Required("name"): str, + vol.Required("stt_engine"): vol.Any(str, None), + vol.Required("stt_language"): vol.Any(str, None), + vol.Required("tts_engine"): vol.Any(str, None), + vol.Required("tts_language"): vol.Any(str, None), + vol.Required("tts_voice"): vol.Any(str, None), +} + +STORED_PIPELINE_RUNS = 10 + +SAVE_DELAY = 10 + + +async def _async_resolve_default_pipeline_settings( + hass: HomeAssistant, + stt_engine_id: str | None, + tts_engine_id: str | None, +) -> dict[str, str | None]: + """Resolve settings for a default pipeline. + + The default pipeline will use the homeassistant conversation agent and the + default stt / tts engines if none are specified. + """ + conversation_language = "en" + pipeline_language = "en" + pipeline_name = "Home Assistant" + stt_engine = None + stt_language = None + tts_engine = None + tts_language = None + tts_voice = None + + # Find a matching language supported by the Home Assistant conversation agent + conversation_languages = language_util.matches( + hass.config.language, + await conversation.async_get_conversation_languages( + hass, conversation.HOME_ASSISTANT_AGENT + ), + country=hass.config.country, + ) + if conversation_languages: + pipeline_language = hass.config.language + conversation_language = conversation_languages[0] + + if stt_engine_id is None: + stt_engine_id = stt.async_default_engine(hass) + + if stt_engine_id is not None: + stt_engine = stt.async_get_speech_to_text_engine(hass, stt_engine_id) + if stt_engine is None: + stt_engine_id = None + + if stt_engine: + stt_languages = language_util.matches( + pipeline_language, + stt_engine.supported_languages, + country=hass.config.country, + ) + if stt_languages: + stt_language = stt_languages[0] + else: + _LOGGER.debug( + "Speech to text engine '%s' does not support language '%s'", + stt_engine_id, + pipeline_language, + ) + stt_engine_id = None + + if tts_engine_id is None: + tts_engine_id = tts.async_default_engine(hass) + + if tts_engine_id is not None: + tts_engine = tts.get_engine_instance(hass, tts_engine_id) + if tts_engine is None: + tts_engine_id = None + + if tts_engine: + tts_languages = language_util.matches( + pipeline_language, + tts_engine.supported_languages, + country=hass.config.country, + ) + if tts_languages: + tts_language = tts_languages[0] + tts_voices = tts_engine.async_get_supported_voices(tts_language) + if tts_voices: + tts_voice = tts_voices[0].voice_id + else: + _LOGGER.debug( + "Text to speech engine '%s' does not support language '%s'", + tts_engine_id, + pipeline_language, + ) + tts_engine_id = None + + if stt_engine_id == "cloud" and tts_engine_id == "cloud": + pipeline_name = "Home Assistant Cloud" + + return { + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, + "conversation_language": conversation_language, + "language": hass.config.language, + "name": pipeline_name, + "stt_engine": stt_engine_id, + "stt_language": stt_language, + "tts_engine": tts_engine_id, + "tts_language": tts_language, + "tts_voice": tts_voice, + } + + +async def _async_create_default_pipeline( + hass: HomeAssistant, pipeline_store: PipelineStorageCollection +) -> Pipeline: + """Create a default pipeline. + + The default pipeline will use the homeassistant conversation agent and the + default stt / tts engines. + """ + pipeline_settings = await _async_resolve_default_pipeline_settings(hass, None, None) + return await pipeline_store.async_create_item(pipeline_settings) + + +async def async_create_default_pipeline( + hass: HomeAssistant, stt_engine_id: str, tts_engine_id: str +) -> Pipeline | None: + """Create a pipeline with default settings. + + The default pipeline will use the homeassistant conversation agent and the + specified stt / tts engines. + """ + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_store = pipeline_data.pipeline_store + pipeline_settings = await _async_resolve_default_pipeline_settings( + hass, stt_engine_id, tts_engine_id + ) + if ( + pipeline_settings["stt_engine"] != stt_engine_id + or pipeline_settings["tts_engine"] != tts_engine_id + ): + return None + return await pipeline_store.async_create_item(pipeline_settings) + + +@callback +def async_get_pipeline( + hass: HomeAssistant, pipeline_id: str | None = None +) -> Pipeline | None: + """Get a pipeline by id or the preferred pipeline.""" + pipeline_data: PipelineData = hass.data[DOMAIN] + + if pipeline_id is None: + # A pipeline was not specified, use the preferred one + pipeline_id = pipeline_data.pipeline_store.async_get_preferred_item() + + return pipeline_data.pipeline_store.data.get(pipeline_id) + + +@callback +def async_get_pipelines(hass: HomeAssistant) -> Iterable[Pipeline]: + """Get all pipelines.""" + pipeline_data: PipelineData = hass.data[DOMAIN] + + return pipeline_data.pipeline_store.data.values() + + +class PipelineEventType(StrEnum): + """Event types emitted during a pipeline run.""" + + RUN_START = "run-start" + RUN_END = "run-end" + STT_START = "stt-start" + STT_END = "stt-end" + INTENT_START = "intent-start" + INTENT_END = "intent-end" + TTS_START = "tts-start" + TTS_END = "tts-end" + ERROR = "error" + + +@dataclass(frozen=True) +class PipelineEvent: + """Events emitted during a pipeline run.""" + + type: PipelineEventType + data: dict[str, Any] | None = None + timestamp: str = field(default_factory=lambda: dt_util.utcnow().isoformat()) + + +PipelineEventCallback = Callable[[PipelineEvent], None] + + +@dataclass(frozen=True) +class Pipeline: + """A voice assistant pipeline.""" + + conversation_engine: str + conversation_language: str + language: str + name: str + stt_engine: str | None + stt_language: str | None + tts_engine: str | None + tts_language: str | None + tts_voice: str | None + + id: str = field(default_factory=ulid_util.ulid) + + def to_json(self) -> dict[str, Any]: + """Return a JSON serializable representation for storage.""" + return { + "conversation_engine": self.conversation_engine, + "conversation_language": self.conversation_language, + "id": self.id, + "language": self.language, + "name": self.name, + "stt_engine": self.stt_engine, + "stt_language": self.stt_language, + "tts_engine": self.tts_engine, + "tts_language": self.tts_language, + "tts_voice": self.tts_voice, + } + + +class PipelineStage(StrEnum): + """Stages of a pipeline.""" + + STT = "stt" + INTENT = "intent" + TTS = "tts" + + +PIPELINE_STAGE_ORDER = [ + PipelineStage.STT, + PipelineStage.INTENT, + PipelineStage.TTS, +] + + +class PipelineRunValidationError(Exception): + """Error when a pipeline run is not valid.""" + + +class InvalidPipelineStagesError(PipelineRunValidationError): + """Error when given an invalid combination of start/end stages.""" + + def __init__( + self, + start_stage: PipelineStage, + end_stage: PipelineStage, + ) -> None: + """Set error message.""" + super().__init__( + f"Invalid stage combination: start={start_stage}, end={end_stage}" + ) + + +@dataclass +class PipelineRun: + """Running context for a pipeline.""" + + hass: HomeAssistant + context: Context + pipeline: Pipeline + start_stage: PipelineStage + end_stage: PipelineStage + event_callback: PipelineEventCallback + language: str = None # type: ignore[assignment] + runner_data: Any | None = None + stt_provider: stt.SpeechToTextEntity | stt.Provider | None = None + intent_agent: str | None = None + tts_engine: str | None = None + tts_audio_output: str | None = None + + id: str = field(default_factory=ulid_util.ulid) + tts_options: dict | None = field(init=False, default=None) + + def __post_init__(self) -> None: + """Set language for pipeline.""" + self.language = self.pipeline.language or self.hass.config.language + + # stt -> intent -> tts + if PIPELINE_STAGE_ORDER.index(self.end_stage) < PIPELINE_STAGE_ORDER.index( + self.start_stage + ): + raise InvalidPipelineStagesError(self.start_stage, self.end_stage) + + pipeline_data: PipelineData = self.hass.data[DOMAIN] + if self.pipeline.id not in pipeline_data.pipeline_runs: + pipeline_data.pipeline_runs[self.pipeline.id] = LimitedSizeDict( + size_limit=STORED_PIPELINE_RUNS + ) + pipeline_data.pipeline_runs[self.pipeline.id][self.id] = PipelineRunDebug() + + @callback + def process_event(self, event: PipelineEvent) -> None: + """Log an event and call listener.""" + self.event_callback(event) + pipeline_data: PipelineData = self.hass.data[DOMAIN] + if self.id not in pipeline_data.pipeline_runs[self.pipeline.id]: + # This run has been evicted from the logged pipeline runs already + return + pipeline_data.pipeline_runs[self.pipeline.id][self.id].events.append(event) + + def start(self) -> None: + """Emit run start event.""" + data = { + "pipeline": self.pipeline.name, + "language": self.language, + } + if self.runner_data is not None: + data["runner_data"] = self.runner_data + + self.process_event(PipelineEvent(PipelineEventType.RUN_START, data)) + + def end(self) -> None: + """Emit run end event.""" + self.process_event( + PipelineEvent( + PipelineEventType.RUN_END, + ) + ) + + async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: + """Prepare speech to text.""" + stt_provider: stt.SpeechToTextEntity | stt.Provider | None = None + + # pipeline.stt_engine can't be None or this function is not called + stt_provider = stt.async_get_speech_to_text_engine( + self.hass, + self.pipeline.stt_engine, # type: ignore[arg-type] + ) + + if stt_provider is None: + engine = self.pipeline.stt_engine + raise SpeechToTextError( + code="stt-provider-missing", + message=f"No speech to text provider for: {engine}", + ) + + metadata.language = self.pipeline.stt_language or self.language + + if not stt_provider.check_metadata(metadata): + raise SpeechToTextError( + code="stt-provider-unsupported-metadata", + message=( + f"Provider {stt_provider.name} does not support input speech " + f"to text metadata {metadata}" + ), + ) + + self.stt_provider = stt_provider + + async def speech_to_text( + self, + metadata: stt.SpeechMetadata, + stream: AsyncIterable[bytes], + ) -> str: + """Run speech to text portion of pipeline. Returns the spoken text.""" + if self.stt_provider is None: + raise RuntimeError("Speech to text was not prepared") + + if isinstance(self.stt_provider, stt.Provider): + engine = self.stt_provider.name + else: + engine = self.stt_provider.entity_id + + self.process_event( + PipelineEvent( + PipelineEventType.STT_START, + { + "engine": engine, + "metadata": asdict(metadata), + }, + ) + ) + + try: + # Transcribe audio stream + result = await self.stt_provider.async_process_audio_stream( + metadata, stream + ) + except Exception as src_error: + _LOGGER.exception("Unexpected error during speech to text") + raise SpeechToTextError( + code="stt-stream-failed", + message="Unexpected error during speech to text", + ) from src_error + + _LOGGER.debug("speech-to-text result %s", result) + + if result.result != stt.SpeechResultState.SUCCESS: + raise SpeechToTextError( + code="stt-stream-failed", + message="Speech to text failed", + ) + + if not result.text: + raise SpeechToTextError( + code="stt-no-text-recognized", message="No text recognized" + ) + + self.process_event( + PipelineEvent( + PipelineEventType.STT_END, + { + "stt_output": { + "text": result.text, + } + }, + ) + ) + + return result.text + + async def prepare_recognize_intent(self) -> None: + """Prepare recognizing an intent.""" + agent_info = conversation.async_get_agent_info( + self.hass, + # If no conversation engine is set, use the Home Assistant agent + # (the conversation integration default is currently the last one set) + self.pipeline.conversation_engine or conversation.HOME_ASSISTANT_AGENT, + ) + + if agent_info is None: + engine = self.pipeline.conversation_engine or "default" + raise IntentRecognitionError( + code="intent-not-supported", + message=f"Intent recognition engine {engine} is not found", + ) + + self.intent_agent = agent_info.id + + async def recognize_intent( + self, intent_input: str, conversation_id: str | None + ) -> str: + """Run intent recognition portion of pipeline. Returns text to speak.""" + if self.intent_agent is None: + raise RuntimeError("Recognize intent was not prepared") + + self.process_event( + PipelineEvent( + PipelineEventType.INTENT_START, + { + "engine": self.intent_agent, + "language": self.pipeline.conversation_language, + "intent_input": intent_input, + }, + ) + ) + + try: + conversation_result = await conversation.async_converse( + hass=self.hass, + text=intent_input, + conversation_id=conversation_id, + context=self.context, + language=self.pipeline.conversation_language, + agent_id=self.intent_agent, + ) + except Exception as src_error: + _LOGGER.exception("Unexpected error during intent recognition") + raise IntentRecognitionError( + code="intent-failed", + message="Unexpected error during intent recognition", + ) from src_error + + _LOGGER.debug("conversation result %s", conversation_result) + + self.process_event( + PipelineEvent( + PipelineEventType.INTENT_END, + {"intent_output": conversation_result.as_dict()}, + ) + ) + + speech: str = conversation_result.response.speech.get("plain", {}).get( + "speech", "" + ) + + return speech + + async def prepare_text_to_speech(self) -> None: + """Prepare text to speech.""" + engine = self.pipeline.tts_engine + + tts_options = {} + if self.pipeline.tts_voice is not None: + tts_options[tts.ATTR_VOICE] = self.pipeline.tts_voice + + if self.tts_audio_output is not None: + tts_options[tts.ATTR_AUDIO_OUTPUT] = self.tts_audio_output + + try: + # pipeline.tts_engine can't be None or this function is not called + if not await tts.async_support_options( + self.hass, + engine, # type: ignore[arg-type] + self.pipeline.tts_language, + tts_options, + ): + raise TextToSpeechError( + code="tts-not-supported", + message=( + f"Text to speech engine {engine} " + f"does not support language {self.pipeline.tts_language} or options {tts_options}" + ), + ) + except HomeAssistantError as err: + raise TextToSpeechError( + code="tts-not-supported", + message=f"Text to speech engine '{engine}' not found", + ) from err + + self.tts_engine = engine + self.tts_options = tts_options + + async def text_to_speech(self, tts_input: str) -> str: + """Run text to speech portion of pipeline. Returns URL of TTS audio.""" + if self.tts_engine is None: + raise RuntimeError("Text to speech was not prepared") + + self.process_event( + PipelineEvent( + PipelineEventType.TTS_START, + { + "engine": self.tts_engine, + "language": self.pipeline.tts_language, + "voice": self.pipeline.tts_voice, + "tts_input": tts_input, + }, + ) + ) + + try: + # Synthesize audio and get URL + tts_media_id = tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.tts_engine, + language=self.pipeline.tts_language, + options=self.tts_options, + ) + tts_media = await media_source.async_resolve_media( + self.hass, + tts_media_id, + None, + ) + except Exception as src_error: + _LOGGER.exception("Unexpected error during text to speech") + raise TextToSpeechError( + code="tts-failed", + message="Unexpected error during text to speech", + ) from src_error + + _LOGGER.debug("TTS result %s", tts_media) + + self.process_event( + PipelineEvent( + PipelineEventType.TTS_END, + { + "tts_output": { + "media_id": tts_media_id, + **asdict(tts_media), + } + }, + ) + ) + + return tts_media.url + + +@dataclass +class PipelineInput: + """Input to a pipeline run.""" + + run: PipelineRun + + stt_metadata: stt.SpeechMetadata | None = None + """Metadata of stt input audio. Required when start_stage = stt.""" + + stt_stream: AsyncIterable[bytes] | None = None + """Input audio for stt. Required when start_stage = stt.""" + + intent_input: str | None = None + """Input for conversation agent. Required when start_stage = intent.""" + + tts_input: str | None = None + """Input for text to speech. Required when start_stage = tts.""" + + conversation_id: str | None = None + + async def execute(self) -> None: + """Run pipeline.""" + self.run.start() + current_stage = self.run.start_stage + + try: + # Speech to text + intent_input = self.intent_input + if current_stage == PipelineStage.STT: + assert self.stt_metadata is not None + assert self.stt_stream is not None + intent_input = await self.run.speech_to_text( + self.stt_metadata, + self.stt_stream, + ) + current_stage = PipelineStage.INTENT + + if self.run.end_stage != PipelineStage.STT: + tts_input = self.tts_input + + if current_stage == PipelineStage.INTENT: + assert intent_input is not None + tts_input = await self.run.recognize_intent( + intent_input, self.conversation_id + ) + current_stage = PipelineStage.TTS + + if self.run.end_stage != PipelineStage.INTENT: + if current_stage == PipelineStage.TTS: + assert tts_input is not None + await self.run.text_to_speech(tts_input) + + except PipelineError as err: + self.run.process_event( + PipelineEvent( + PipelineEventType.ERROR, + {"code": err.code, "message": err.message}, + ) + ) + return + + self.run.end() + + async def validate(self) -> None: + """Validate pipeline input against start stage.""" + if self.run.start_stage == PipelineStage.STT: + if self.run.pipeline.stt_engine is None: + raise PipelineRunValidationError( + "the pipeline does not support speech to text" + ) + if self.stt_metadata is None: + raise PipelineRunValidationError( + "stt_metadata is required for speech to text" + ) + if self.stt_stream is None: + raise PipelineRunValidationError( + "stt_stream is required for speech to text" + ) + elif self.run.start_stage == PipelineStage.INTENT: + if self.intent_input is None: + raise PipelineRunValidationError( + "intent_input is required for intent recognition" + ) + elif self.run.start_stage == PipelineStage.TTS: + if self.tts_input is None: + raise PipelineRunValidationError( + "tts_input is required for text to speech" + ) + if self.run.end_stage == PipelineStage.TTS: + if self.run.pipeline.tts_engine is None: + raise PipelineRunValidationError( + "the pipeline does not support text to speech" + ) + + start_stage_index = PIPELINE_STAGE_ORDER.index(self.run.start_stage) + + prepare_tasks = [] + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.STT): + # self.stt_metadata can't be None or we'd raise above + prepare_tasks.append(self.run.prepare_speech_to_text(self.stt_metadata)) # type: ignore[arg-type] + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT): + prepare_tasks.append(self.run.prepare_recognize_intent()) + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS): + prepare_tasks.append(self.run.prepare_text_to_speech()) + + if prepare_tasks: + await asyncio.gather(*prepare_tasks) + + +class PipelinePreferred(CollectionError): + """Raised when attempting to delete the preferred pipelen.""" + + def __init__(self, item_id: str) -> None: + """Initialize pipeline preferred error.""" + super().__init__(f"Item {item_id} preferred.") + self.item_id = item_id + + +class SerializedPipelineStorageCollection(SerializedStorageCollection): + """Serialized pipeline storage collection.""" + + preferred_item: str + + +class PipelineStorageCollection( + StorageCollection[Pipeline, SerializedPipelineStorageCollection] +): + """Pipeline storage collection.""" + + _preferred_item: str + + async def _async_load_data(self) -> SerializedPipelineStorageCollection | None: + """Load the data.""" + if not (data := await super()._async_load_data()): + pipeline = await _async_create_default_pipeline(self.hass, self) + self._preferred_item = pipeline.id + return data + + self._preferred_item = data["preferred_item"] + + return data + + async def _process_create_data(self, data: dict) -> dict: + """Validate the config is valid.""" + validated_data: dict = validate_language(data) + return validated_data + + @callback + def _get_suggested_id(self, info: dict) -> str: + """Suggest an ID based on the config.""" + return ulid_util.ulid() + + async def _update_data(self, item: Pipeline, update_data: dict) -> Pipeline: + """Return a new updated item.""" + update_data = validate_language(update_data) + return Pipeline(id=item.id, **update_data) + + def _create_item(self, item_id: str, data: dict) -> Pipeline: + """Create an item from validated config.""" + return Pipeline(id=item_id, **data) + + def _deserialize_item(self, data: dict) -> Pipeline: + """Create an item from its serialized representation.""" + return Pipeline(**data) + + def _serialize_item(self, item_id: str, item: Pipeline) -> dict: + """Return the serialized representation of an item for storing.""" + return item.to_json() + + async def async_delete_item(self, item_id: str) -> None: + """Delete item.""" + if self._preferred_item == item_id: + raise PipelinePreferred(item_id) + await super().async_delete_item(item_id) + + @callback + def async_get_preferred_item(self) -> str: + """Get the id of the preferred item.""" + return self._preferred_item + + @callback + def async_set_preferred_item(self, item_id: str) -> None: + """Set the preferred pipeline.""" + if item_id not in self.data: + raise ItemNotFound(item_id) + self._preferred_item = item_id + self._async_schedule_save() + + @callback + def _data_to_save(self) -> SerializedPipelineStorageCollection: + """Return JSON-compatible date for storing to file.""" + base_data = super()._base_data_to_save() + return { + "items": base_data["items"], + "preferred_item": self._preferred_item, + } + + +class PipelineStorageCollectionWebsocket( + StorageCollectionWebsocket[PipelineStorageCollection] +): + """Class to expose storage collection management over websocket.""" + + @callback + def async_setup( + self, + hass: HomeAssistant, + *, + create_list: bool = True, + create_create: bool = True, + ) -> None: + """Set up the websocket commands.""" + super().async_setup(hass, create_list=create_list, create_create=create_create) + + websocket_api.async_register_command( + hass, + f"{self.api_prefix}/get", + self.ws_get_item, + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): f"{self.api_prefix}/get", + vol.Optional(self.item_id_key): str, + } + ), + ) + + websocket_api.async_register_command( + hass, + f"{self.api_prefix}/set_preferred", + websocket_api.require_admin( + websocket_api.async_response(self.ws_set_preferred_item) + ), + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): f"{self.api_prefix}/set_preferred", + vol.Required(self.item_id_key): str, + } + ), + ) + + async def ws_delete_item( + self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + ) -> None: + """Delete an item.""" + try: + await super().ws_delete_item(hass, connection, msg) + except PipelinePreferred as exc: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_ALLOWED, str(exc) + ) + + @callback + def ws_get_item( + self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + ) -> None: + """Get an item.""" + item_id = msg.get(self.item_id_key) + if item_id is None: + item_id = self.storage_collection.async_get_preferred_item() + + if item_id not in self.storage_collection.data: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"Unable to find {self.item_id_key} {item_id}", + ) + return + + connection.send_result(msg["id"], self.storage_collection.data[item_id]) + + @callback + def ws_list_item( + self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + ) -> None: + """List items.""" + connection.send_result( + msg["id"], + { + "pipelines": self.storage_collection.async_items(), + "preferred_pipeline": self.storage_collection.async_get_preferred_item(), + }, + ) + + async def ws_set_preferred_item( + self, + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: + """Set the preferred item.""" + try: + self.storage_collection.async_set_preferred_item(msg[self.item_id_key]) + except ItemNotFound: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "unknown item" + ) + return + connection.send_result(msg["id"]) + + +@dataclass +class PipelineData: + """Store and debug data stored in hass.data.""" + + pipeline_runs: dict[str, LimitedSizeDict[str, PipelineRunDebug]] + pipeline_store: PipelineStorageCollection + + +@dataclass +class PipelineRunDebug: + """Debug data for a pipelinerun.""" + + events: list[PipelineEvent] = field(default_factory=list, init=False) + timestamp: str = field( + default_factory=lambda: dt_util.utcnow().isoformat(), + init=False, + ) + + +@singleton(DOMAIN) +async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData: + """Set up the pipeline storage collection.""" + pipeline_store = PipelineStorageCollection( + Store(hass, STORAGE_VERSION, STORAGE_KEY) + ) + await pipeline_store.async_load() + PipelineStorageCollectionWebsocket( + pipeline_store, + f"{DOMAIN}/pipeline", + "pipeline", + PIPELINE_FIELDS, + PIPELINE_FIELDS, + ).async_setup(hass) + return PipelineData({}, pipeline_store) diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py new file mode 100644 index 0000000000..9ac1d6b588 --- /dev/null +++ b/homeassistant/components/assist_pipeline/select.py @@ -0,0 +1,95 @@ +"""Select entities for a pipeline.""" + +from __future__ import annotations + +from collections.abc import Iterable + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import collection, entity_registry as er, restore_state + +from .const import DOMAIN +from .pipeline import PipelineStorageCollection + +OPTION_PREFERRED = "preferred" + + +@callback +def get_chosen_pipeline( + hass: HomeAssistant, domain: str, unique_id_prefix: str +) -> str | None: + """Get the chosen pipeline for a domain.""" + ent_reg = er.async_get(hass) + pipeline_entity_id = ent_reg.async_get_entity_id( + Platform.SELECT, domain, f"{unique_id_prefix}-pipeline" + ) + if pipeline_entity_id is None: + return None + + state = hass.states.get(pipeline_entity_id) + if state is None or state.state == OPTION_PREFERRED: + return None + + pipeline_store: PipelineStorageCollection = hass.data[DOMAIN].pipeline_store + return next( + (item.id for item in pipeline_store.async_items() if item.name == state.state), + None, + ) + + +class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): + """Entity to represent a pipeline selector.""" + + entity_description = SelectEntityDescription( + key="pipeline", + translation_key="pipeline", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_current_option = OPTION_PREFERRED + _attr_options = [OPTION_PREFERRED] + + def __init__(self, hass: HomeAssistant, unique_id_prefix: str) -> None: + """Initialize a pipeline selector.""" + self._attr_unique_id = f"{unique_id_prefix}-pipeline" + self.hass = hass + self._update_options() + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + pipeline_store: PipelineStorageCollection = self.hass.data[ + DOMAIN + ].pipeline_store + pipeline_store.async_add_change_set_listener(self._pipelines_updated) + + state = await self.async_get_last_state() + if state is not None and state.state in self.options: + self._attr_current_option = state.state + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + self._attr_current_option = option + self.async_write_ha_state() + + async def _pipelines_updated( + self, change_sets: Iterable[collection.CollectionChangeSet] + ) -> None: + """Handle pipeline update.""" + self._update_options() + self.async_write_ha_state() + + @callback + def _update_options(self) -> None: + """Handle pipeline update.""" + pipeline_store: PipelineStorageCollection = self.hass.data[ + DOMAIN + ].pipeline_store + options = [OPTION_PREFERRED] + options.extend(sorted(item.name for item in pipeline_store.async_items())) + self._attr_options = options + + if self._attr_current_option not in options: + self._attr_current_option = OPTION_PREFERRED diff --git a/homeassistant/components/assist_pipeline/strings.json b/homeassistant/components/assist_pipeline/strings.json new file mode 100644 index 0000000000..8ee0ad286b --- /dev/null +++ b/homeassistant/components/assist_pipeline/strings.json @@ -0,0 +1,12 @@ +{ + "entity": { + "select": { + "pipeline": { + "name": "Assist Pipeline", + "state": { + "preferred": "Preferred" + } + } + } + } +} diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py new file mode 100644 index 0000000000..c5f87f1336 --- /dev/null +++ b/homeassistant/components/assist_pipeline/vad.py @@ -0,0 +1,128 @@ +"""Voice activity detection.""" +from dataclasses import dataclass, field + +import webrtcvad + +_SAMPLE_RATE = 16000 + + +@dataclass +class VoiceCommandSegmenter: + """Segments an audio stream into voice commands using webrtcvad.""" + + vad_mode: int = 3 + """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" + + vad_frames: int = 480 # 30 ms + """Must be 10, 20, or 30 ms at 16Khz.""" + + speech_seconds: float = 0.3 + """Seconds of speech before voice command has started.""" + + silence_seconds: float = 0.5 + """Seconds of silence after voice command has ended.""" + + timeout_seconds: float = 15.0 + """Maximum number of seconds before stopping with timeout=True.""" + + reset_seconds: float = 1.0 + """Seconds before reset start/stop time counters.""" + + in_command: bool = False + """True if inside voice command.""" + + _speech_seconds_left: float = 0.0 + """Seconds left before considering voice command as started.""" + + _silence_seconds_left: float = 0.0 + """Seconds left before considering voice command as stopped.""" + + _timeout_seconds_left: float = 0.0 + """Seconds left before considering voice command timed out.""" + + _reset_seconds_left: float = 0.0 + """Seconds left before resetting start/stop time counters.""" + + _vad: webrtcvad.Vad = None + _audio_buffer: bytes = field(default_factory=bytes) + _bytes_per_chunk: int = 480 * 2 # 16-bit samples + _seconds_per_chunk: float = 0.03 # 30 ms + + def __post_init__(self) -> None: + """Initialize VAD.""" + self._vad = webrtcvad.Vad(self.vad_mode) + self._bytes_per_chunk = self.vad_frames * 2 + self._seconds_per_chunk = self.vad_frames / _SAMPLE_RATE + self.reset() + + def reset(self) -> None: + """Reset all counters and state.""" + self._audio_buffer = b"" + self._speech_seconds_left = self.speech_seconds + self._silence_seconds_left = self.silence_seconds + self._timeout_seconds_left = self.timeout_seconds + self._reset_seconds_left = self.reset_seconds + self.in_command = False + + def process(self, samples: bytes) -> bool: + """Process a 16-bit 16Khz mono audio samples. + + Returns False when command is done. + """ + self._audio_buffer += samples + + # Process in 10, 20, or 30 ms chunks. + num_chunks = len(self._audio_buffer) // self._bytes_per_chunk + for chunk_idx in range(num_chunks): + chunk_offset = chunk_idx * self._bytes_per_chunk + chunk = self._audio_buffer[ + chunk_offset : chunk_offset + self._bytes_per_chunk + ] + if not self._process_chunk(chunk): + self.reset() + return False + + if num_chunks > 0: + # Remove from buffer + self._audio_buffer = self._audio_buffer[ + num_chunks * self._bytes_per_chunk : + ] + + return True + + def _process_chunk(self, chunk: bytes) -> bool: + """Process a single chunk of 16-bit 16Khz mono audio. + + Returns False when command is done. + """ + is_speech = self._vad.is_speech(chunk, _SAMPLE_RATE) + + self._timeout_seconds_left -= self._seconds_per_chunk + if self._timeout_seconds_left <= 0: + return False + + if not self.in_command: + if is_speech: + self._reset_seconds_left = self.reset_seconds + self._speech_seconds_left -= self._seconds_per_chunk + if self._speech_seconds_left <= 0: + # Inside voice command + self.in_command = True + else: + # Reset if enough silence + self._reset_seconds_left -= self._seconds_per_chunk + if self._reset_seconds_left <= 0: + self._speech_seconds_left = self.speech_seconds + else: + if not is_speech: + self._reset_seconds_left = self.reset_seconds + self._silence_seconds_left -= self._seconds_per_chunk + if self._silence_seconds_left <= 0: + return False + else: + # Reset if enough speech + self._reset_seconds_left -= self._seconds_per_chunk + if self._reset_seconds_left <= 0: + self._silence_seconds_left = self.silence_seconds + + return True diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py new file mode 100644 index 0000000000..6c1dbe3dbc --- /dev/null +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -0,0 +1,333 @@ +"""Assist pipeline Websocket API.""" +import asyncio + +# Suppressing disable=deprecated-module is needed for Python 3.11 +import audioop # pylint: disable=deprecated-module +from collections.abc import AsyncGenerator, Callable +import logging +from typing import Any + +import async_timeout +import voluptuous as vol + +from homeassistant.components import conversation, stt, tts, websocket_api +from homeassistant.const import MATCH_ALL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.util import language as language_util + +from .const import DOMAIN +from .pipeline import ( + PipelineData, + PipelineError, + PipelineEvent, + PipelineEventType, + PipelineInput, + PipelineRun, + PipelineStage, + async_get_pipeline, +) +from .vad import VoiceCommandSegmenter + +DEFAULT_TIMEOUT = 30 + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_register_websocket_api(hass: HomeAssistant) -> None: + """Register the websocket API.""" + websocket_api.async_register_command(hass, websocket_run) + websocket_api.async_register_command(hass, websocket_list_languages) + websocket_api.async_register_command(hass, websocket_list_runs) + websocket_api.async_register_command(hass, websocket_get_run) + + +@websocket_api.websocket_command( + vol.All( + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): "assist_pipeline/run", + # pylint: disable-next=unnecessary-lambda + vol.Required("start_stage"): lambda val: PipelineStage(val), + # pylint: disable-next=unnecessary-lambda + vol.Required("end_stage"): lambda val: PipelineStage(val), + vol.Optional("input"): dict, + vol.Optional("pipeline"): str, + vol.Optional("conversation_id"): vol.Any(str, None), + vol.Optional("timeout"): vol.Any(float, int), + }, + ), + cv.key_value_schemas( + "start_stage", + { + PipelineStage.STT: vol.Schema( + {vol.Required("input"): {vol.Required("sample_rate"): int}}, + extra=vol.ALLOW_EXTRA, + ), + PipelineStage.INTENT: vol.Schema( + {vol.Required("input"): {"text": str}}, + extra=vol.ALLOW_EXTRA, + ), + PipelineStage.TTS: vol.Schema( + {vol.Required("input"): {"text": str}}, + extra=vol.ALLOW_EXTRA, + ), + }, + ), + ), +) +@websocket_api.async_response +async def websocket_run( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Run a pipeline.""" + pipeline_id = msg.get("pipeline") + pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id) + if pipeline is None: + connection.send_error( + msg["id"], + "pipeline-not-found", + f"Pipeline not found: id={pipeline_id}", + ) + return + + timeout = msg.get("timeout", DEFAULT_TIMEOUT) + start_stage = PipelineStage(msg["start_stage"]) + end_stage = PipelineStage(msg["end_stage"]) + handler_id: int | None = None + unregister_handler: Callable[[], None] | None = None + + # Arguments to PipelineInput + input_args: dict[str, Any] = { + "conversation_id": msg.get("conversation_id"), + } + + if start_stage == PipelineStage.STT: + # Audio pipeline that will receive audio as binary websocket messages + audio_queue: "asyncio.Queue[bytes]" = asyncio.Queue() + incoming_sample_rate = msg["input"]["sample_rate"] + + async def stt_stream() -> AsyncGenerator[bytes, None]: + state = None + segmenter = VoiceCommandSegmenter() + + # Yield until we receive an empty chunk + while chunk := await audio_queue.get(): + chunk, state = audioop.ratecv( + chunk, 2, 1, incoming_sample_rate, 16000, state + ) + if not segmenter.process(chunk): + # Voice command is finished + break + + yield chunk + + def handle_binary( + _hass: HomeAssistant, + _connection: websocket_api.ActiveConnection, + data: bytes, + ) -> None: + # Forward to STT audio stream + audio_queue.put_nowait(data) + + handler_id, unregister_handler = connection.async_register_binary_handler( + handle_binary + ) + + # Audio input must be raw PCM at 16Khz with 16-bit mono samples + input_args["stt_metadata"] = stt.SpeechMetadata( + language=pipeline.stt_language or pipeline.language, + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + input_args["stt_stream"] = stt_stream() + elif start_stage == PipelineStage.INTENT: + # Input to conversation agent + input_args["intent_input"] = msg["input"]["text"] + elif start_stage == PipelineStage.TTS: + # Input to text to speech system + input_args["tts_input"] = msg["input"]["text"] + + input_args["run"] = PipelineRun( + hass, + context=connection.context(msg), + pipeline=pipeline, + start_stage=start_stage, + end_stage=end_stage, + event_callback=lambda event: connection.send_event(msg["id"], event), + runner_data={ + "stt_binary_handler_id": handler_id, + "timeout": timeout, + }, + ) + + pipeline_input = PipelineInput(**input_args) + + try: + await pipeline_input.validate() + except PipelineError as error: + # Report more specific error when possible + connection.send_error(msg["id"], error.code, error.message) + return + + # Confirm subscription + connection.send_result(msg["id"]) + + run_task = hass.async_create_task(pipeline_input.execute()) + + # Cancel pipeline if user unsubscribes + connection.subscriptions[msg["id"]] = run_task.cancel + + try: + # Task contains a timeout + async with async_timeout.timeout(timeout): + await run_task + except asyncio.TimeoutError: + pipeline_input.run.process_event( + PipelineEvent( + PipelineEventType.ERROR, + {"code": "timeout", "message": "Timeout running pipeline"}, + ) + ) + finally: + if unregister_handler is not None: + # Unregister binary handler + unregister_handler() + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "assist_pipeline/pipeline_debug/list", + vol.Required("pipeline_id"): str, + } +) +def websocket_list_runs( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List pipeline runs for which debug data is available.""" + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = msg["pipeline_id"] + + if pipeline_id not in pipeline_data.pipeline_runs: + connection.send_result(msg["id"], {"pipeline_runs": []}) + return + + pipeline_runs = pipeline_data.pipeline_runs[pipeline_id] + + connection.send_result( + msg["id"], + { + "pipeline_runs": [ + {"pipeline_run_id": id, "timestamp": pipeline_run.timestamp} + for id, pipeline_run in pipeline_runs.items() + ] + }, + ) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "assist_pipeline/pipeline_debug/get", + vol.Required("pipeline_id"): str, + vol.Required("pipeline_run_id"): str, + } +) +def websocket_get_run( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get debug data for a pipeline run.""" + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = msg["pipeline_id"] + pipeline_run_id = msg["pipeline_run_id"] + + if pipeline_id not in pipeline_data.pipeline_runs: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"pipeline_id {pipeline_id} not found", + ) + return + + pipeline_runs = pipeline_data.pipeline_runs[pipeline_id] + + if pipeline_run_id not in pipeline_runs: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"pipeline_run_id {pipeline_run_id} not found", + ) + return + + connection.send_result( + msg["id"], + {"events": pipeline_runs[pipeline_run_id].events}, + ) + + +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "assist_pipeline/language/list", + } +) +@websocket_api.async_response +async def websocket_list_languages( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List languages which are supported by a complete pipeline. + + This will return a list of languages which are supported by at least one stt, tts + and conversation engine respectively. + """ + conv_language_tags = await conversation.async_get_conversation_languages(hass) + stt_language_tags = stt.async_get_speech_to_text_languages(hass) + tts_language_tags = tts.async_get_text_to_speech_languages(hass) + pipeline_languages: set[str] | None = None + + if conv_language_tags and conv_language_tags != MATCH_ALL: + languages = set() + for language_tag in conv_language_tags: + dialect = language_util.Dialect.parse(language_tag) + languages.add(dialect.language) + pipeline_languages = languages + + if stt_language_tags: + languages = set() + for language_tag in stt_language_tags: + dialect = language_util.Dialect.parse(language_tag) + languages.add(dialect.language) + if pipeline_languages is not None: + pipeline_languages &= languages + else: + pipeline_languages = languages + + if tts_language_tags: + languages = set() + for language_tag in tts_language_tags: + dialect = language_util.Dialect.parse(language_tag) + languages.add(dialect.language) + if pipeline_languages is not None: + pipeline_languages &= languages + else: + pipeline_languages = languages + + connection.send_result( + msg["id"], + {"languages": pipeline_languages}, + ) diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index bd0c706e74..f6ccb5a7c9 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -11,7 +11,7 @@ "password": "[%key:common::config_flow::data::password%]", "ssh_key": "Path to your SSH key file (instead of password)", "protocol": "Communication protocol to use", - "port": "[%key:common::config_flow::data::port%] (leave empty for protocol default)", + "port": "Port (leave empty for protocol default)", "mode": "[%key:common::config_flow::data::mode%]" } } diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 213f0237e1..1233cf07c2 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.1"] + "requirements": ["yalexs==1.3.2", "yalexs-ble==2.1.16"] } diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py index 5223b8b4a3..62aef44a9e 100644 --- a/homeassistant/components/august/subscriber.py +++ b/homeassistant/components/august/subscriber.py @@ -38,7 +38,10 @@ class AugustSubscriberMixin: def _async_setup_listeners(self): """Create interval and stop listeners.""" self._unsub_interval = async_track_time_interval( - self._hass, self._async_refresh, self._update_interval + self._hass, + self._async_refresh, + self._update_interval, + name="august refresh", ) @callback diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 3cfae999b9..4712592edc 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -659,7 +659,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): ) -@dataclass +@dataclass(slots=True) class AutomationEntityConfig: """Container for prepared automation entity configuration.""" diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index c4c05f1c51..65a425fa5c 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -51,7 +51,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if config_entry.version != 3: # Home Assistant 2023.2 config_entry.version = 3 - hass.config_entries.async_update_entry(config_entry) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/azure_service_bus/notify.py b/homeassistant/components/azure_service_bus/notify.py index 4005460eca..b318c5224d 100644 --- a/homeassistant/components/azure_service_bus/notify.py +++ b/homeassistant/components/azure_service_bus/notify.py @@ -4,8 +4,13 @@ from __future__ import annotations import json import logging +# pylint: disable-next=import-error, no-name-in-module from azure.servicebus import ServiceBusMessage + +# pylint: disable-next=import-error, no-name-in-module from azure.servicebus.aio import ServiceBusClient, ServiceBusSender + +# pylint: disable-next=import-error, no-name-in-module from azure.servicebus.exceptions import ( MessagingEntityNotFoundError, ServiceBusConnectionError, diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 69df310bd5..1f8b70f4d3 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -23,8 +23,10 @@ from homeassistant.util.json import json_loads_object from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER +BUF_SIZE = 2**20 * 4 # 4MB -@dataclass + +@dataclass(slots=True) class Backup: """Backup class.""" @@ -99,7 +101,7 @@ class BackupManager: backups: dict[str, Backup] = {} for backup_path in self.backup_dir.glob("*.tar"): try: - with tarfile.open(backup_path, "r:") as backup_file: + with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file: if data_file := backup_file.extractfile("./backup.json"): data = json_loads_object(data_file.read()) backup = Backup( @@ -227,7 +229,7 @@ class BackupManager: self.backup_dir.mkdir() with TemporaryDirectory() as tmp_dir, SecureTarFile( - tar_file_path, "w", gzip=False + tar_file_path, "w", gzip=False, bufsize=BUF_SIZE ) as tar_file: tmp_dir_path = Path(tmp_dir) save_json( @@ -237,6 +239,7 @@ class BackupManager: with SecureTarFile( tmp_dir_path.joinpath("./homeassistant.tar.gz").as_posix(), "w", + bufsize=BUF_SIZE, ) as core_tar: atomic_contents_add( tar_file=core_tar, diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index 7b495912f5..fb7e9eff78 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["securetar==2022.2.0"] + "requirements": ["securetar==2023.3.0"] } diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index cf9a943b3d..b43b1fb6b7 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -84,7 +84,7 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle zeroconf discovery.""" hass = self.hass - ipaddress = host_port(discovery_info.__dict__) + ipaddress = (discovery_info.host, discovery_info.port) self.device_config["host"] = discovery_info.host self.device_config["port"] = discovery_info.port diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py index 80e2fbd30e..658a9bc30c 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -8,6 +8,7 @@ import blebox_uniapi.cover from homeassistant.components.cover import ( ATTR_POSITION, + ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, CoverEntityFeature, @@ -67,6 +68,10 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity): self._attr_supported_features = ( position | stop | CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) + if feature.has_tilt: + self._attr_supported_features = ( + self._attr_supported_features | CoverEntityFeature.SET_TILT_POSITION + ) @property def current_cover_position(self) -> int | None: @@ -77,6 +82,12 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity): return None if position is None else 100 - position + @property + def current_cover_tilt_position(self) -> int | None: + """Return the current tilt of shutter.""" + position = self._feature.tilt_current + return None if position is None else 100 - position + @property def is_opening(self) -> bool | None: """Return whether cover is opening.""" @@ -110,6 +121,12 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity): """Stop the cover.""" await self._feature.async_stop() + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Set the tilt position.""" + + position = kwargs[ATTR_TILT_POSITION] + await self._feature.async_set_tilt_position(100 - position) + def _is_state(self, state_name) -> bool | None: value = BLEBOX_TO_HASS_COVER_STATES[self._feature.state] return None if value is None else value == state_name diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index add7dad1a1..2c48b473b7 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -31,7 +31,7 @@ from homeassistant.config_entries import ( ConfigEntry, ) from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant, callback as hass_callback +from homeassistant.core import Event, HassJob, HomeAssistant, callback as hass_callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, discovery_flow from homeassistant.helpers.debounce import Debouncer @@ -198,10 +198,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: function=_async_rediscover_adapters, ) + async def _async_shutdown_debouncer(_: Event) -> None: + """Shutdown debouncer.""" + await discovery_debouncer.async_shutdown() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown_debouncer) + async def _async_call_debouncer(now: datetime.datetime) -> None: """Call the debouncer at a later time.""" await discovery_debouncer.async_call() + call_debouncer_job = HassJob(_async_call_debouncer, cancel_on_shutdown=True) + def _async_trigger_discovery() -> None: # There are so many bluetooth adapter models that # we check the bus whenever a usb device is plugged in @@ -220,7 +228,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_call_later( hass, BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS + LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, - _async_call_debouncer, + call_debouncer_job, ) cancel = usb.async_register_scan_request_callback(hass, _async_trigger_discovery) diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index d5cf65d872..5fa05b87cc 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -143,7 +143,7 @@ class ActiveBluetoothDataUpdateCoordinator( self._last_poll = monotonic_time_coarse() if not self.last_poll_successful: - self.logger.debug("%s: Polling recovered") + self.logger.debug("%s: Polling recovered", self.address) self.last_poll_successful = True self._async_handle_bluetooth_poll() @@ -169,3 +169,9 @@ class ActiveBluetoothDataUpdateCoordinator( # possible after a device comes online or back in range, if a poll is due if self.needs_poll(service_info): self.hass.async_create_task(self._debounced_poll.async_call()) + + @callback + def _async_stop(self) -> None: + """Cancel debouncer and stop the callbacks.""" + self._debounced_poll.async_cancel() + super()._async_stop() diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index aabc27ff14..8e38191c82 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -136,7 +136,7 @@ class ActiveBluetoothProcessorCoordinator( self._last_poll = monotonic_time_coarse() if not self.last_poll_successful: - self.logger.debug("%s: Polling recovered") + self.logger.debug("%s: Polling recovered", self.address) self.last_poll_successful = True for processor in self._processors: @@ -158,3 +158,9 @@ class ActiveBluetoothProcessorCoordinator( # possible after a device comes online or back in range, if a poll is due if self.needs_poll(service_info): self.hass.async_create_task(self._debounced_poll.async_call()) + + @callback + def _async_stop(self) -> None: + """Cancel debouncer and stop the callbacks.""" + self._debounced_poll.async_cancel() + super()._async_stop() diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 1c16639d61..8f7750fe32 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -39,7 +39,7 @@ MONOTONIC_TIME: Final = monotonic_time_coarse _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(slots=True) class BluetoothScannerDevice: """Data for a bluetooth device from a given scanner.""" @@ -98,7 +98,10 @@ class BaseHaScanner(ABC): self._start_time = self._last_detection = MONOTONIC_TIME() if not self._cancel_watchdog: self._cancel_watchdog = async_track_time_interval( - self.hass, self._async_scanner_watchdog, SCANNER_WATCHDOG_INTERVAL + self.hass, + self._async_scanner_watchdog, + SCANNER_WATCHDOG_INTERVAL, + name=f"{self.name} Bluetooth scanner watchdog", ) @hass_callback @@ -224,7 +227,10 @@ class BaseHaRemoteScanner(BaseHaScanner): self._async_expire_devices(dt_util.utcnow()) cancel_track = async_track_time_interval( - self.hass, self._async_expire_devices, timedelta(seconds=30) + self.hass, + self._async_expire_devices, + timedelta(seconds=30), + name=f"{self.name} Bluetooth scanner device expire", ) cancel_stop = self.hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, self._async_save_history @@ -303,48 +309,58 @@ class BaseHaRemoteScanner(BaseHaScanner): # merges the dicts on PropertiesChanged prev_device = prev_discovery[0] prev_advertisement = prev_discovery[1] - if ( - local_name - and prev_device.name - and len(prev_device.name) > len(local_name) - ): - local_name = prev_device.name - if service_uuids and service_uuids != prev_advertisement.service_uuids: - service_uuids = list( - set(service_uuids + prev_advertisement.service_uuids) - ) + prev_service_uuids = prev_advertisement.service_uuids + prev_service_data = prev_advertisement.service_data + prev_manufacturer_data = prev_advertisement.manufacturer_data + prev_name = prev_device.name + + if local_name and prev_name and len(prev_name) > len(local_name): + local_name = prev_name + + if service_uuids and service_uuids != prev_service_uuids: + service_uuids = list(set(service_uuids + prev_service_uuids)) elif not service_uuids: - service_uuids = prev_advertisement.service_uuids - if service_data and service_data != prev_advertisement.service_data: - service_data = {**prev_advertisement.service_data, **service_data} + service_uuids = prev_service_uuids + + if service_data and service_data != prev_service_data: + service_data = prev_service_data | service_data elif not service_data: - service_data = prev_advertisement.service_data - if ( - manufacturer_data - and manufacturer_data != prev_advertisement.manufacturer_data - ): - manufacturer_data = { - **prev_advertisement.manufacturer_data, - **manufacturer_data, - } + service_data = prev_service_data + + if manufacturer_data and manufacturer_data != prev_manufacturer_data: + manufacturer_data = prev_manufacturer_data | manufacturer_data elif not manufacturer_data: - manufacturer_data = prev_advertisement.manufacturer_data + manufacturer_data = prev_manufacturer_data + # + # Bleak updates the BLEDevice via create_or_update_device. + # We need to do the same to ensure integrations that already + # have the BLEDevice object get the updated details when they + # change. + # + # https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203 + # + device = prev_device + device.name = local_name + device.details = self._details | details + # pylint: disable-next=protected-access + device._rssi = rssi # deprecated, will be removed in newer bleak + else: + device = BLEDevice( + address=address, + name=local_name, + details=self._details | details, + rssi=rssi, # deprecated, will be removed in newer bleak + ) advertisement_data = AdvertisementData( local_name=None if local_name == "" else local_name, manufacturer_data=manufacturer_data, service_data=service_data, service_uuids=service_uuids, - rssi=rssi, tx_power=NO_RSSI_VALUE if tx_power is None else tx_power, + rssi=rssi, platform_data=(), ) - device = BLEDevice( - address=address, - name=local_name, - details=self._details | details, - rssi=rssi, # deprecated, will be removed in newer bleak - ) self._discovered_device_advertisement_datas[address] = ( device, advertisement_data, @@ -352,12 +368,12 @@ class BaseHaRemoteScanner(BaseHaScanner): self._discovered_device_timestamps[address] = now self._new_info_callback( BluetoothServiceInfoBleak( - name=advertisement_data.local_name or device.name or device.address, - address=device.address, + name=local_name or address, + address=address, rssi=rssi, - manufacturer_data=advertisement_data.manufacturer_data, - service_data=advertisement_data.service_data, - service_uuids=advertisement_data.service_uuids, + manufacturer_data=manufacturer_data, + service_data=service_data, + service_uuids=service_uuids, source=self.source, device=device, advertisement=advertisement_data, diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index bc21051656..75809f42e6 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -276,6 +276,7 @@ class BluetoothManager: self.hass, self._async_check_unavailable, timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), + name="Bluetooth manager unavailable tracking", ) @hass_callback diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f6cbe5b3e5..512d8d4ab9 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,11 +15,11 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.20.0", - "bleak-retry-connector==3.0.1", + "bleak==0.20.1", + "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.0.3", - "bluetooth-data-tools==0.3.1", - "dbus-fast==1.84.2" + "bluetooth-data-tools==0.4.0", + "dbus-fast==1.85.0" ] } diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index a7308bfd7f..1315d0a834 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -61,7 +61,7 @@ class BluetoothCallbackMatcherWithCallback( """Callback matcher for the bluetooth integration that stores the callback.""" -@dataclass(frozen=False) +@dataclass(slots=True, frozen=False) class IntegrationMatchHistory: """Track which fields have been seen.""" diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 40ac86de60..1856ccd599 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -20,7 +20,7 @@ MANAGER: BluetoothManager | None = None MONOTONIC_TIME: Final = monotonic_time_coarse -@dataclass +@dataclass(slots=True) class HaBluetoothConnector: """Data for how to connect a BLEDevice from a given scanner.""" diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index e170148740..607abaa016 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(slots=True, frozen=True) class PassiveBluetoothEntityKey: """Key for a passive bluetooth entity. @@ -36,7 +36,7 @@ class PassiveBluetoothEntityKey: _T = TypeVar("_T") -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(slots=True, frozen=True) class PassiveBluetoothDataUpdate(Generic[_T]): """Generic bluetooth data.""" diff --git a/homeassistant/components/bluetooth/usage.py b/homeassistant/components/bluetooth/usage.py index b751559e7a..d89f0b5b68 100644 --- a/homeassistant/components/bluetooth/usage.py +++ b/homeassistant/components/bluetooth/usage.py @@ -10,9 +10,10 @@ from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper ORIGINAL_BLEAK_SCANNER = bleak.BleakScanner ORIGINAL_BLEAK_CLIENT = bleak.BleakClient -ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT = ( +ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE = ( bleak_retry_connector.BleakClientWithServiceCache ) +ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT = bleak_retry_connector.BleakClient def install_multiple_bleak_catcher() -> None: @@ -23,6 +24,7 @@ def install_multiple_bleak_catcher() -> None: bleak.BleakScanner = HaBleakScannerWrapper # type: ignore[misc, assignment] bleak.BleakClient = HaBleakClientWrapper # type: ignore[misc] bleak_retry_connector.BleakClientWithServiceCache = HaBleakClientWithServiceCache # type: ignore[misc,assignment] # noqa: E501 + bleak_retry_connector.BleakClient = HaBleakClientWrapper # type: ignore[misc] # noqa: E501 def uninstall_multiple_bleak_catcher() -> None: @@ -30,6 +32,9 @@ def uninstall_multiple_bleak_catcher() -> None: bleak.BleakScanner = ORIGINAL_BLEAK_SCANNER # type: ignore[misc] bleak.BleakClient = ORIGINAL_BLEAK_CLIENT # type: ignore[misc] bleak_retry_connector.BleakClientWithServiceCache = ( # type: ignore[misc] + ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE + ) + bleak_retry_connector.BleakClient = ( # type: ignore[misc] ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT ) diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index cf17796105..67e401cd40 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -34,7 +34,7 @@ if TYPE_CHECKING: from .manager import BluetoothManager -@dataclass +@dataclass(slots=True) class _HaWrappedBleakBackend: """Wrap bleak backend to make it usable by Home Assistant.""" @@ -251,8 +251,10 @@ class HaBleakClientWrapper(BleakClient): assert models.MANAGER is not None manager = models.MANAGER wrapped_backend = self._async_get_best_available_backend_and_device(manager) + device = wrapped_backend.device + scanner = wrapped_backend.scanner self._backend = wrapped_backend.client( - wrapped_backend.device, + device, disconnected_callback=self._make_disconnected_callback( self.__disconnected_callback ), @@ -261,8 +263,9 @@ class HaBleakClientWrapper(BleakClient): ) if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG): # Only lookup the description if we are going to log it - description = ble_device_description(wrapped_backend.device) - rssi = wrapped_backend.device.rssi + description = ble_device_description(device) + _, adv = scanner.discovered_devices_and_advertisement_data[device.address] + rssi = adv.rssi _LOGGER.debug("%s: Connecting (last rssi: %s)", description, rssi) connected = None try: @@ -271,11 +274,11 @@ class HaBleakClientWrapper(BleakClient): # If we failed to connect and its a local adapter (no source) # we release the connection slot if not connected: - self.__connect_failures[wrapped_backend.scanner] = ( - self.__connect_failures.get(wrapped_backend.scanner, 0) + 1 + self.__connect_failures[scanner] = ( + self.__connect_failures.get(scanner, 0) + 1 ) if not wrapped_backend.source: - manager.async_release_connection_slot(wrapped_backend.device) + manager.async_release_connection_slot(device) if debug_logging: _LOGGER.debug("%s: Connected (last rssi: %s)", description, rssi) diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py index d3d19f4308..3739734223 100644 --- a/homeassistant/components/bluetooth_le_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -70,6 +70,7 @@ async def async_setup_scanner( # noqa: C901 yaml_path = hass.config.path(YAML_DEVICES) devs_to_track: set[str] = set() devs_no_track: set[str] = set() + devs_advertise_time: dict[str, float] = {} devs_track_battery = {} interval: timedelta = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) # if track new devices is true discover new devices @@ -178,6 +179,7 @@ async def async_setup_scanner( # noqa: C901 """Update from a ble callback.""" mac = service_info.address if mac in devs_to_track: + devs_advertise_time[mac] = service_info.time now = dt_util.utcnow() hass.async_create_task(async_see_device(mac, service_info.name)) if ( @@ -205,7 +207,9 @@ async def async_setup_scanner( # noqa: C901 # there have been no callbacks because the RSSI or # other properties have not changed. for service_info in bluetooth.async_discovered_service_info(hass, False): - _async_update_ble(service_info, bluetooth.BluetoothChange.ADVERTISEMENT) + # Only call _async_update_ble if the advertisement time has changed + if service_info.time != devs_advertise_time.get(service_info.address): + _async_update_ble(service_info, bluetooth.BluetoothChange.ADVERTISEMENT) cancels = [ bluetooth.async_register_callback( diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index a47f2bed59..e91943034d 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -41,6 +41,7 @@ PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.LOCK, Platform.NOTIFY, + Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 4f05794e31..0cde37ba6b 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -1,6 +1,7 @@ """Config flow for BMW ConnectedDrive integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from bimmer_connected.api.authentication import MyBMWAuthentication @@ -55,36 +56,61 @@ class BMWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + _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: unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}" - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() + if not self._reauth_entry: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() info = None try: info = await validate_input(self.hass, user_input) + entry_data = { + **user_input, + CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), + } except CannotConnect: errors["base"] = "cannot_connect" if info: + if self._reauth_entry: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=entry_data + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload( + self._reauth_entry.entry_id + ) + ) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=info["title"], - data={ - **user_input, - CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), - }, + data=entry_data, ) - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, self._reauth_entry.data if self._reauth_entry else {} ) + 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: + """Handle configuration by re-auth.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 0f03505ff2..ae139d4c64 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -12,6 +12,7 @@ from httpx import HTTPError, HTTPStatusError, TimeoutException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN @@ -65,8 +66,9 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): 401, 403, ): - # Clear refresh token only on issues with authorization + # Clear refresh token only and trigger reauth self._update_config_entry_refresh_token(None) + raise ConfigEntryAuthFailed(str(err)) from err raise UpdateFailed(f"Error communicating with BMW API: {err}") from err if self.account.refresh_token != old_refresh_token: diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index cafaced522..f1768d5a0c 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.12.1"] + "requirements": ["bimmer_connected==0.13.0"] } diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py new file mode 100644 index 0000000000..e8e8dd5ca4 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -0,0 +1,139 @@ +"""Select platform for BMW.""" +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from bimmer_connected.vehicle import MyBMWVehicle +from bimmer_connected.vehicle.charging_profile import ChargingMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfElectricCurrent +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BMWBaseEntity +from .const import DOMAIN +from .coordinator import BMWDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BMWRequiredKeysMixin: + """Mixin for required keys.""" + + current_option: Callable[[MyBMWVehicle], str] + remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]] + + +@dataclass +class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): + """Describes BMW sensor entity.""" + + is_available: Callable[[MyBMWVehicle], bool] = lambda _: False + dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None + + +SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { + # --- Generic --- + "target_soc": BMWSelectEntityDescription( + key="target_soc", + name="Target SoC", + is_available=lambda v: v.is_remote_set_target_soc_enabled, + options=[str(i * 5 + 20) for i in range(17)], + current_option=lambda v: str(v.fuel_and_battery.charging_target), + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + target_soc=int(o) + ), + icon="mdi:battery-charging-medium", + unit_of_measurement=PERCENTAGE, + ), + "ac_limit": BMWSelectEntityDescription( + key="ac_limit", + name="AC Charging Limit", + is_available=lambda v: v.is_remote_set_ac_limit_enabled, + dynamic_options=lambda v: [ + str(lim) for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr] + ], + current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr] + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + ac_limit=int(o) + ), + icon="mdi:current-ac", + unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ), + "charging_mode": BMWSelectEntityDescription( + key="charging_mode", + name="Charging Mode", + is_available=lambda v: v.is_charging_plan_supported, + options=[c.value for c in ChargingMode if c != ChargingMode.UNKNOWN], + current_option=lambda v: str(v.charging_profile.charging_mode.value), # type: ignore[union-attr] + remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update( + charging_mode=ChargingMode(o) + ), + icon="mdi:vector-point-select", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MyBMW lock from config entry.""" + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWSelect] = [] + + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.extend( + [ + BMWSelect(coordinator, vehicle, description) + for description in SELECT_TYPES.values() + if description.is_available(vehicle) + ] + ) + async_add_entities(entities) + + +class BMWSelect(BMWBaseEntity, SelectEntity): + """Representation of BMW select entity.""" + + entity_description: BMWSelectEntityDescription + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + description: BMWSelectEntityDescription, + ) -> None: + """Initialize an BMW select.""" + super().__init__(coordinator, vehicle) + self.entity_description = description + self._attr_unique_id = f"{vehicle.vin}-{description.key}" + if description.dynamic_options: + self._attr_options = description.dynamic_options(vehicle) + self._attr_current_option = description.current_option(vehicle) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug( + "Updating select '%s' of %s", self.entity_description.key, self.vehicle.name + ) + self._attr_current_option = self.entity_description.current_option(self.vehicle) + super()._handle_coordinator_update() + + async def async_select_option(self, option: str) -> None: + """Update to the vehicle.""" + _LOGGER.debug( + "Executing '%s' on vehicle '%s' to value '%s'", + self.entity_description.key, + self.vehicle.vin, + option, + ) + await self.entity_description.remote_service(self.vehicle, option) diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 3e93cccb8c..506175becd 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -14,7 +14,8 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 8c9fef6bd7..36af397448 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -17,9 +17,9 @@ from homeassistant.const import ( ATTR_SW_VERSION, ATTR_VIA_DEVICE, ) -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.entity import DeviceInfo, Entity -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import async_call_later from .const import DOMAIN from .utils import BondDevice, BondHub @@ -27,6 +27,7 @@ from .utils import BondDevice, BondHub _LOGGER = logging.getLogger(__name__) _FALLBACK_SCAN_INTERVAL = timedelta(seconds=10) +_BPUP_ALIVE_SCAN_INTERVAL = timedelta(seconds=60) class BondEntity(Entity): @@ -65,6 +66,7 @@ class BondEntity(Entity): self._attr_name = device.name self._attr_assumed_state = self._hub.is_bridge and not self._device.trust_state self._apply_state() + self._bpup_polling_fallback: CALLBACK_TYPE | None = None @property def device_info(self) -> DeviceInfo: @@ -100,12 +102,13 @@ class BondEntity(Entity): return device_info async def async_update(self) -> None: - """Fetch assumed state of the cover from the hub using API.""" + """Perform a manual update from API.""" await self._async_update_from_api() @callback def _async_update_if_bpup_not_alive(self, now: datetime) -> None: """Fetch via the API if BPUP is not alive.""" + self._async_schedule_bpup_alive_or_poll() if ( self.hass.is_stopping or self._bpup_subs.alive @@ -172,13 +175,22 @@ class BondEntity(Entity): """Subscribe to BPUP and start polling.""" await super().async_added_to_hass() self._bpup_subs.subscribe(self._device_id, self._async_bpup_callback) - self.async_on_remove( - async_track_time_interval( - self.hass, self._async_update_if_bpup_not_alive, _FALLBACK_SCAN_INTERVAL - ) + self._async_schedule_bpup_alive_or_poll() + + @callback + def _async_schedule_bpup_alive_or_poll(self) -> None: + """Schedule the BPUP alive or poll.""" + alive = self._bpup_subs.alive + self._bpup_polling_fallback = async_call_later( + self.hass, + _BPUP_ALIVE_SCAN_INTERVAL if alive else _FALLBACK_SCAN_INTERVAL, + self._async_update_if_bpup_not_alive, ) async def async_will_remove_from_hass(self) -> None: """Unsubscribe from BPUP data on remove.""" await super().async_will_remove_from_hass() self._bpup_subs.unsubscribe(self._device_id, self._async_bpup_callback) + if self._bpup_polling_fallback: + self._bpup_polling_fallback() + self._bpup_polling_fallback = None diff --git a/homeassistant/components/braviatv/button.py b/homeassistant/components/braviatv/button.py index 768ded6b64..b382d97a2a 100644 --- a/homeassistant/components/braviatv/button.py +++ b/homeassistant/components/braviatv/button.py @@ -36,14 +36,14 @@ class BraviaTVButtonDescription( BUTTONS: tuple[BraviaTVButtonDescription, ...] = ( BraviaTVButtonDescription( key="reboot", - name="Reboot", + translation_key="restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=lambda coordinator: coordinator.async_reboot_device(), ), BraviaTVButtonDescription( key="terminate_apps", - name="Terminate apps", + translation_key="terminate_apps", entity_category=EntityCategory.CONFIG, press_action=lambda coordinator: coordinator.async_terminate_apps(), ), diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index 917bd1d541..ff5691f9ae 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -136,7 +136,7 @@ class BraviaTVMediaPlayer(BraviaTVEntity, MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Browse apps and channels.""" @@ -231,7 +231,7 @@ class BraviaTVMediaPlayer(BraviaTVEntity, MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/braviatv/strings.json b/homeassistant/components/braviatv/strings.json index d66f44acc6..aacaf81465 100644 --- a/homeassistant/components/braviatv/strings.json +++ b/homeassistant/components/braviatv/strings.json @@ -44,5 +44,15 @@ "not_bravia_device": "The device is not a Bravia TV.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "button": { + "restart": { + "name": "[%key:component::button::entity_component::restart::name%]" + }, + "terminate_apps": { + "name": "Terminate apps" + } + } } } diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index 5d996c2ee1..559aae25ab 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -16,7 +16,7 @@ from .heartbeat import BroadlinkHeartbeat class BroadlinkData: """Class for sharing data within the Broadlink integration.""" - devices: dict = field(default_factory=dict) + devices: dict[str, BroadlinkDevice] = field(default_factory=dict) platforms: dict = field(default_factory=dict) heartbeat: BroadlinkHeartbeat | None = None @@ -29,7 +29,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Broadlink device from a config entry.""" - data = hass.data[DOMAIN] + data: BroadlinkData = hass.data[DOMAIN] if data.heartbeat is None: data.heartbeat = BroadlinkHeartbeat(hass) @@ -41,12 +41,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - data = hass.data[DOMAIN] + data: BroadlinkData = hass.data[DOMAIN] device = data.devices.pop(entry.entry_id) result = await device.async_unload() - if not data.devices: + if data.heartbeat and not data.devices: await data.heartbeat.async_unload() data.heartbeat = None diff --git a/homeassistant/components/broadlink/device.py b/homeassistant/components/broadlink/device.py index d6d064ea01..87d8cf398f 100644 --- a/homeassistant/components/broadlink/device.py +++ b/homeassistant/components/broadlink/device.py @@ -13,8 +13,15 @@ from broadlink.exceptions import ( ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT, CONF_TYPE -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_NAME, + CONF_TIMEOUT, + CONF_TYPE, + Platform, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -24,7 +31,7 @@ from .updater import get_update_manager _LOGGER = logging.getLogger(__name__) -def get_domains(device_type): +def get_domains(device_type: str) -> set[Platform]: """Return the domains available for a device type.""" return {d for d, t in DOMAINS_AND_TYPES.items() if device_type in t} @@ -32,33 +39,34 @@ def get_domains(device_type): class BroadlinkDevice: """Manages a Broadlink device.""" - def __init__(self, hass, config): + api: blk.Device + + def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: """Initialize the device.""" self.hass = hass self.config = config - self.api = None self.update_manager = None - self.fw_version = None - self.authorized = None - self.reset_jobs = [] + self.fw_version: int | None = None + self.authorized: bool | None = None + self.reset_jobs: list[CALLBACK_TYPE] = [] @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self.config.title @property - def unique_id(self): + def unique_id(self) -> str | None: """Return the unique id of the device.""" return self.config.unique_id @property - def mac_address(self): + def mac_address(self) -> str: """Return the mac address of the device.""" return self.config.data[CONF_MAC] @property - def available(self): + def available(self) -> bool | None: """Return True if the device is available.""" if self.update_manager is None: return False @@ -77,14 +85,14 @@ class BroadlinkDevice: device_registry.async_update_device(device_entry.id, name=entry.title) await hass.config_entries.async_reload(entry.entry_id) - def _get_firmware_version(self): + def _get_firmware_version(self) -> int | None: """Get firmware version.""" self.api.auth() with suppress(BroadlinkException, OSError): return self.api.get_fwversion() return None - async def async_setup(self): + async def async_setup(self) -> bool: """Set up the device and related entities.""" config = self.config @@ -132,7 +140,7 @@ class BroadlinkDevice: return True - async def async_unload(self): + async def async_unload(self) -> bool: """Unload the device and related entities.""" if self.update_manager is None: return True @@ -144,7 +152,7 @@ class BroadlinkDevice: self.config, get_domains(self.api.type) ) - async def async_auth(self): + async def async_auth(self) -> bool: """Authenticate to the device.""" try: await self.hass.async_add_executor_job(self.api.auth) @@ -167,7 +175,7 @@ class BroadlinkDevice: raise return await self.hass.async_add_executor_job(request) - async def _async_handle_auth_error(self): + async def _async_handle_auth_error(self) -> None: """Handle an authentication error.""" if self.authorized is False: return diff --git a/homeassistant/components/broadlink/heartbeat.py b/homeassistant/components/broadlink/heartbeat.py index b4deffa5b8..70f6aec0d0 100644 --- a/homeassistant/components/broadlink/heartbeat.py +++ b/homeassistant/components/broadlink/heartbeat.py @@ -5,6 +5,7 @@ import logging import broadlink as blk from homeassistant.const import CONF_HOST +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import event from .const import DOMAIN @@ -21,12 +22,12 @@ class BroadlinkHeartbeat: HEARTBEAT_INTERVAL = dt.timedelta(minutes=2) - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize the heartbeat.""" self._hass = hass - self._unsubscribe = None + self._unsubscribe: CALLBACK_TYPE | None = None - async def async_setup(self): + async def async_setup(self) -> None: """Set up the heartbeat.""" if self._unsubscribe is None: await self.async_heartbeat(dt.datetime.now()) @@ -34,21 +35,21 @@ class BroadlinkHeartbeat: self._hass, self.async_heartbeat, self.HEARTBEAT_INTERVAL ) - async def async_unload(self): + async def async_unload(self) -> None: """Unload the heartbeat.""" if self._unsubscribe is not None: self._unsubscribe() self._unsubscribe = None - async def async_heartbeat(self, now): + async def async_heartbeat(self, _: dt.datetime) -> None: """Send packets to feed watchdog timers.""" hass = self._hass config_entries = hass.config_entries.async_entries(DOMAIN) - hosts = {entry.data[CONF_HOST] for entry in config_entries} + hosts: set[str] = {entry.data[CONF_HOST] for entry in config_entries} await hass.async_add_executor_job(self.heartbeat, hosts) @staticmethod - def heartbeat(hosts): + def heartbeat(hosts: set[str]) -> None: """Send packets to feed watchdog timers.""" for host in hosts: try: diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index 33f1badcc1..50c58d4166 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -25,61 +25,61 @@ from .entity import BroadlinkEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="temperature", - name="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="air_quality", - name="Air quality", + translation_key="air_quality", ), SensorEntityDescription( key="humidity", - name="Humidity", + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="light", - name="Light", + translation_key="light", ), SensorEntityDescription( key="noise", - name="Noise", + translation_key="noise", ), SensorEntityDescription( key="power", - name="Current power", + translation_key="power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="volt", - name="Voltage", + translation_key="voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="current", - name="Current", + translation_key="current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="overload", - name="Overload", + translation_key="overload", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="totalconsum", - name="Total consumption", + translation_key="total_consumption", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index d324b0272a..adff2303c7 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -43,5 +43,39 @@ "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "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" + }, + "total_consumption": { + "name": "Total consumption" + } + } } } diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 274576f0f3..191bfff249 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -53,14 +53,14 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="status", icon="mdi:printer", - name="Status", + translation_key="status", entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.status, ), BrotherSensorEntityDescription( key="page_counter", icon="mdi:file-document-outline", - name="Page counter", + translation_key="page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -69,7 +69,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="bw_counter", icon="mdi:file-document-outline", - name="B/W counter", + translation_key="bw_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -78,7 +78,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="color_counter", icon="mdi:file-document-outline", - name="Color counter", + translation_key="color_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -87,7 +87,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="duplex_unit_pages_counter", icon="mdi:file-document-outline", - name="Duplex unit pages counter", + translation_key="duplex_unit_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -96,7 +96,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="drum_remaining_life", icon="mdi:chart-donut", - name="Drum remaining life", + translation_key="drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -105,7 +105,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="drum_remaining_pages", icon="mdi:chart-donut", - name="Drum remaining pages", + translation_key="drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -114,7 +114,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="drum_counter", icon="mdi:chart-donut", - name="Drum counter", + translation_key="drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -123,7 +123,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_drum_remaining_life", icon="mdi:chart-donut", - name="Black drum remaining life", + translation_key="black_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -132,7 +132,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_drum_remaining_pages", icon="mdi:chart-donut", - name="Black drum remaining pages", + translation_key="black_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -141,7 +141,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_drum_counter", icon="mdi:chart-donut", - name="Black drum counter", + translation_key="black_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -150,7 +150,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_drum_remaining_life", icon="mdi:chart-donut", - name="Cyan drum remaining life", + translation_key="cyan_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -159,7 +159,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_drum_remaining_pages", icon="mdi:chart-donut", - name="Cyan drum remaining pages", + translation_key="cyan_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -168,7 +168,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_drum_counter", icon="mdi:chart-donut", - name="Cyan drum counter", + translation_key="cyan_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -177,7 +177,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_drum_remaining_life", icon="mdi:chart-donut", - name="Magenta drum remaining life", + translation_key="magenta_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -186,7 +186,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_drum_remaining_pages", icon="mdi:chart-donut", - name="Magenta drum remaining pages", + translation_key="magenta_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -195,7 +195,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_drum_counter", icon="mdi:chart-donut", - name="Magenta drum counter", + translation_key="magenta_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -204,7 +204,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_drum_remaining_life", icon="mdi:chart-donut", - name="Yellow drum remaining life", + translation_key="yellow_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -213,7 +213,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_drum_remaining_pages", icon="mdi:chart-donut", - name="Yellow drum remaining pages", + translation_key="yellow_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -222,7 +222,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_drum_counter", icon="mdi:chart-donut", - name="Yellow drum counter", + translation_key="yellow_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -231,7 +231,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="belt_unit_remaining_life", icon="mdi:current-ac", - name="Belt unit remaining life", + translation_key="belt_unit_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -240,7 +240,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="fuser_remaining_life", icon="mdi:water-outline", - name="Fuser remaining life", + translation_key="fuser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -249,7 +249,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="laser_remaining_life", icon="mdi:spotlight-beam", - name="Laser remaining life", + translation_key="laser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -258,7 +258,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="pf_kit_1_remaining_life", icon="mdi:printer-3d", - name="PF Kit 1 remaining life", + translation_key="pf_kit_1_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -267,7 +267,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="pf_kit_mp_remaining_life", icon="mdi:printer-3d", - name="PF Kit MP remaining life", + translation_key="pf_kit_mp_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -276,7 +276,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Black toner remaining", + translation_key="black_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -285,7 +285,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Cyan toner remaining", + translation_key="cyan_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -294,7 +294,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Magenta toner remaining", + translation_key="magenta_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -303,7 +303,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Yellow toner remaining", + translation_key="yellow_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -312,7 +312,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Black ink remaining", + translation_key="black_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -321,7 +321,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Cyan ink remaining", + translation_key="cyan_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -330,7 +330,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Magenta ink remaining", + translation_key="magenta_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -339,7 +339,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Yellow ink remaining", + translation_key="yellow_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -347,7 +347,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="last_restart", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index 9d7d42abef..3ee3fe7609 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -25,5 +25,111 @@ "unsupported_model": "This printer model is not supported.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "status": { + "name": "Status" + }, + "page_counter": { + "name": "Page counter" + }, + "bw_pages": { + "name": "B/W pages" + }, + "color_pages": { + "name": "Color pages" + }, + "duplex_unit_page_counter": { + "name": "Duplex unit page counter" + }, + "drum_remaining_life": { + "name": "Drum remaining life" + }, + "drum_remaining_pages": { + "name": "Drum remaining pages" + }, + "drum_page_counter": { + "name": "Drum page counter" + }, + "black_drum_remaining_life": { + "name": "Black drum remaining life" + }, + "black_drum_remaining_pages": { + "name": "Black drum remaining pages" + }, + "black_drum_page_counter": { + "name": "Black drum page counter" + }, + "cyan_drum_remaining_life": { + "name": "Cyan drum remaining life" + }, + "cyan_drum_remaining_pages": { + "name": "Cyan drum remaining pages" + }, + "cyan_drum_page_counter": { + "name": "Cyan drum page counter" + }, + "magenta_drum_remaining_life": { + "name": "Magenta drum remaining life" + }, + "magenta_drum_remaining_pages": { + "name": "Magenta drum remaining pages" + }, + "magenta_drum_page_counter": { + "name": "Magenta drum page counter" + }, + "yellow_drum_remaining_life": { + "name": "Yellow drum remaining life" + }, + "yellow_drum_remaining_pages": { + "name": "Yellow drum remaining pages" + }, + "yellow_drum_page_counter": { + "name": "Yellow drum page counter" + }, + "belt_unit_remaining_life": { + "name": "Belt unit remaining life" + }, + "fuser_remaining_life": { + "name": "Fuser remaining life" + }, + "laser_remaining_life": { + "name": "Laser remaining life" + }, + "pf_kit_1_remaining_life": { + "name": "PF Kit 1 remaining life" + }, + "pf_kit_mp_remaining_life": { + "name": "PF Kit MP remaining life" + }, + "black_toner_remaining": { + "name": "Black toner remaining" + }, + "cyan_toner_remaining": { + "name": "Cyan toner remaining" + }, + "magenta_toner_remaining": { + "name": "Magenta toner remaining" + }, + "yellow_toner_remaining": { + "name": "Yellow toner remaining" + }, + "black_ink_remaining": { + "name": "Black ink remaining" + }, + "cyan_ink_remaining": { + "name": "Cyan ink remaining" + }, + "magenta_ink_remaining": { + "name": "Magenta ink remaining" + }, + "yellow_ink_remaining": { + "name": "Yellow ink remaining" + }, + "last_restart": { + "name": "Last restart" + } + } } } diff --git a/homeassistant/components/brottsplatskartan/__init__.py b/homeassistant/components/brottsplatskartan/__init__.py index d519909b29..14e6e383e8 100644 --- a/homeassistant/components/brottsplatskartan/__init__.py +++ b/homeassistant/components/brottsplatskartan/__init__.py @@ -1 +1,21 @@ """The brottsplatskartan component.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up brottsplatskartan from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload brottsplatskartan config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/brottsplatskartan/config_flow.py b/homeassistant/components/brottsplatskartan/config_flow.py new file mode 100644 index 0000000000..1de24ffa76 --- /dev/null +++ b/homeassistant/components/brottsplatskartan/config_flow.py @@ -0,0 +1,97 @@ +"""Adds config flow for Brottsplatskartan integration.""" +from __future__ import annotations + +from typing import Any +import uuid + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector + +from .const import AREAS, CONF_APP_ID, CONF_AREA, DEFAULT_NAME, DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_LOCATION): selector.LocationSelector( + selector.LocationSelectorConfig(radius=False, icon="") + ), + vol.Optional(CONF_AREA, default="none"): selector.SelectSelector( + selector.SelectSelectorConfig( + options=AREAS, + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="areas", + ) + ), + } +) + + +class BPKConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Brottsplatskartan integration.""" + + VERSION = 1 + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a configuration from config.yaml.""" + + if config.get(CONF_LATITUDE): + config[CONF_LOCATION] = { + CONF_LATITUDE: config[CONF_LATITUDE], + CONF_LONGITUDE: config[CONF_LONGITUDE], + } + if not config.get(CONF_AREA): + config[CONF_AREA] = "none" + else: + config[CONF_AREA] = config[CONF_AREA][0] + + return await self.async_step_user(user_input=config) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step.""" + errors: dict[str, str] = {} + + if user_input is not None: + latitude: float | None = None + longitude: float | None = None + area: str | None = ( + user_input[CONF_AREA] if user_input[CONF_AREA] != "none" else None + ) + + if area: + name = f"{DEFAULT_NAME} {area}" + elif location := user_input.get(CONF_LOCATION): + lat: float = location[CONF_LATITUDE] + long: float = location[CONF_LONGITUDE] + latitude = lat + longitude = long + name = f"{DEFAULT_NAME} {round(latitude, 2)}, {round(longitude, 2)}" + else: + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + name = f"{DEFAULT_NAME} HOME" + + app = f"ha-{uuid.getnode()}" + + self._async_abort_entries_match( + {CONF_AREA: area, CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude} + ) + return self.async_create_entry( + title=name, + data={ + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_AREA: area, + CONF_APP_ID: app, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/brottsplatskartan/const.py b/homeassistant/components/brottsplatskartan/const.py new file mode 100644 index 0000000000..8bd08f452f --- /dev/null +++ b/homeassistant/components/brottsplatskartan/const.py @@ -0,0 +1,39 @@ +"""Adds constants for brottsplatskartan integration.""" + +import logging + +from homeassistant.const import Platform + +DOMAIN = "brottsplatskartan" +PLATFORMS = [Platform.SENSOR] + +LOGGER = logging.getLogger(__package__) + +CONF_AREA = "area" +CONF_APP_ID = "app_id" +DEFAULT_NAME = "Brottsplatskartan" + +AREAS = [ + "none", + "Blekinge län", + "Dalarnas län", + "Gotlands län", + "Gävleborgs län", + "Hallands län", + "Jämtlands län", + "Jönköpings län", + "Kalmar län", + "Kronobergs län", + "Norrbottens län", + "Skåne län", + "Stockholms län", + "Södermanlands län", + "Uppsala län", + "Värmlands län", + "Västerbottens län", + "Västernorrlands län", + "Västmanlands län", + "Västra Götalands län", + "Örebro län", + "Östergötlands län", +] diff --git a/homeassistant/components/brottsplatskartan/manifest.json b/homeassistant/components/brottsplatskartan/manifest.json index 8007fb6d11..14c4a5e39c 100644 --- a/homeassistant/components/brottsplatskartan/manifest.json +++ b/homeassistant/components/brottsplatskartan/manifest.json @@ -1,7 +1,8 @@ { "domain": "brottsplatskartan", "name": "Brottsplatskartan", - "codeowners": [], + "codeowners": ["@gjohansson-ST"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/brottsplatskartan", "iot_class": "cloud_polling", "loggers": ["brottsplatskartan"], diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index d76cb7c8a5..ca6173d2ef 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -3,52 +3,29 @@ from __future__ import annotations from collections import defaultdict from datetime import timedelta -import logging -import uuid -import brottsplatskartan +from brottsplatskartan import ATTRIBUTION, BrottsplatsKartan import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, + SensorEntity, +) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) - -CONF_AREA = "area" - -DEFAULT_NAME = "Brottsplatskartan" +from .const import AREAS, CONF_APP_ID, CONF_AREA, DEFAULT_NAME, DOMAIN, LOGGER SCAN_INTERVAL = timedelta(minutes=30) -AREAS = [ - "Blekinge län", - "Dalarnas län", - "Gotlands län", - "Gävleborgs län", - "Hallands län", - "Jämtlands län", - "Jönköpings län", - "Kalmar län", - "Kronobergs län", - "Norrbottens län", - "Skåne län", - "Stockholms län", - "Södermanlands län", - "Uppsala län", - "Värmlands län", - "Västerbottens län", - "Västernorrlands län", - "Västmanlands län", - "Västra Götalands län", - "Örebro län", - "Östergötlands län", -] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, @@ -58,39 +35,65 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Brottsplatskartan platform.""" - area = config.get(CONF_AREA) - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - name = config[CONF_NAME] - - # Every Home Assistant instance should have their own unique - # app parameter: https://brottsplatskartan.se/sida/api - app = f"ha-{uuid.getnode()}" - - bpk = brottsplatskartan.BrottsplatsKartan( - app=app, area=area, latitude=latitude, longitude=longitude + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", ) - add_entities([BrottsplatskartanSensor(bpk, name)], True) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Brottsplatskartan sensor entry.""" + + area = entry.data.get(CONF_AREA) + latitude = entry.data.get(CONF_LATITUDE) + longitude = entry.data.get(CONF_LONGITUDE) + app = entry.data[CONF_APP_ID] + name = entry.title + + bpk = BrottsplatsKartan(app=app, area=area, latitude=latitude, longitude=longitude) + + async_add_entities([BrottsplatskartanSensor(bpk, name, entry.entry_id)], True) class BrottsplatskartanSensor(SensorEntity): """Representation of a Brottsplatskartan Sensor.""" - _attr_attribution = brottsplatskartan.ATTRIBUTION + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True - def __init__(self, bpk: brottsplatskartan.BrottsplatsKartan, name: str) -> None: + def __init__(self, bpk: BrottsplatsKartan, name: str, entry_id: str) -> None: """Initialize the Brottsplatskartan sensor.""" self._brottsplatskartan = bpk - self._attr_name = name + self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="Brottsplatskartan", + name=name, + ) def update(self) -> None: """Update device state.""" @@ -99,7 +102,7 @@ class BrottsplatskartanSensor(SensorEntity): incidents = self._brottsplatskartan.get_incidents() if incidents is False: - _LOGGER.debug("Problems fetching incidents") + LOGGER.debug("Problems fetching incidents") return for incident in incidents: diff --git a/homeassistant/components/brottsplatskartan/strings.json b/homeassistant/components/brottsplatskartan/strings.json new file mode 100644 index 0000000000..8d9677a0af --- /dev/null +++ b/homeassistant/components/brottsplatskartan/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "step": { + "user": { + "data": { + "location": "[%key:common::config_flow::data::location%]", + "area": "Area" + }, + "data_description": { + "location": "Put marker on location to cover within 5km radius", + "area": "If area is selected, any marked location is ignored" + } + } + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Brottsplatskartan YAML configuration is being removed", + "description": "Configuring Brottsplatskartan using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Brottsplatskartan YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + }, + "selector": { + "areas": { + "options": { + "none": "No area" + } + } + } +} diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index f53e395f0c..0e945d13d4 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bsblan", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==0.5.9"] + "requirements": ["python-bsblan==0.5.11"] } diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py index 539aa112a0..1255def44c 100644 --- a/homeassistant/components/bthome/__init__.py +++ b/homeassistant/components/bthome/__init__.py @@ -7,6 +7,7 @@ from bthome_ble import BTHomeBluetoothDeviceData, SensorUpdate from bthome_ble.parser import EncryptionScheme from homeassistant.components.bluetooth import ( + DOMAIN as BLUETOOTH_DOMAIN, BluetoothScanningMode, BluetoothServiceInfoBleak, ) @@ -16,8 +17,16 @@ from homeassistant.components.bluetooth.passive_update_processor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry, async_get -from .const import DOMAIN +from .const import ( + BTHOME_BLE_EVENT, + CONF_BINDKEY, + CONF_DISCOVERED_EVENT_CLASSES, + DOMAIN, + BTHomeBleEvent, +) +from .models import BTHomeData PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -29,10 +38,53 @@ def process_service_info( entry: ConfigEntry, data: BTHomeBluetoothDeviceData, service_info: BluetoothServiceInfoBleak, + device_registry: DeviceRegistry, ) -> SensorUpdate: """Process a BluetoothServiceInfoBleak, running side effects and returning sensor data.""" update = data.update(service_info) - # If that payload was encrypted and the bindkey was not verified then we need to reauth + domain_data: BTHomeData = hass.data[DOMAIN][entry.entry_id] + if update.events: + address = service_info.device.address + for device_key, event in update.events.items(): + sensor_device_info = update.devices[device_key.device_id] + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(BLUETOOTH_DOMAIN, address)}, + manufacturer=sensor_device_info.manufacturer, + model=sensor_device_info.model, + name=sensor_device_info.name, + sw_version=sensor_device_info.sw_version, + hw_version=sensor_device_info.hw_version, + ) + event_class = event.device_key.key + event_type = event.event_type + + if event_class not in domain_data.discovered_event_classes: + domain_data.discovered_event_classes.add(event_class) + hass.config_entries.async_update_entry( + entry, + data=entry.data + | { + CONF_DISCOVERED_EVENT_CLASSES: list( + domain_data.discovered_event_classes + ) + }, + ) + + hass.bus.async_fire( + BTHOME_BLE_EVENT, + dict( + BTHomeBleEvent( + device_id=device.id, + address=address, + event_class=event_class, # ie 'button' + event_type=event_type, # ie 'press' + event_properties=event.event_properties, + ) + ), + ) + + # If payload is encrypted and the bindkey is not verified then we need to reauth if data.encryption_scheme != EncryptionScheme.NONE and not data.bindkey_verified: entry.async_start_reauth(hass, data={"device": data}) @@ -45,10 +97,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: assert address is not None kwargs = {} - if bindkey := entry.data.get("bindkey"): - kwargs["bindkey"] = bytes.fromhex(bindkey) + if bindkey := entry.data.get(CONF_BINDKEY): + kwargs[CONF_BINDKEY] = bytes.fromhex(bindkey) data = BTHomeBluetoothDeviceData(**kwargs) + device_registry = async_get(hass) coordinator = hass.data.setdefault(DOMAIN, {})[ entry.entry_id ] = PassiveBluetoothProcessorCoordinator( @@ -57,11 +110,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: address=address, mode=BluetoothScanningMode.PASSIVE, update_method=lambda service_info: process_service_info( - hass, entry, data, service_info + hass, entry, data, service_info, device_registry ), connectable=False, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + domain_data = BTHomeData(set(entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []))) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = domain_data + entry.async_on_unload( coordinator.async_start() ) # only start after all platforms have had a chance to subscribe diff --git a/homeassistant/components/bthome/const.py b/homeassistant/components/bthome/const.py index e46aa50e14..75a8ab4fc8 100644 --- a/homeassistant/components/bthome/const.py +++ b/homeassistant/components/bthome/const.py @@ -1,3 +1,32 @@ """Constants for the BTHome Bluetooth integration.""" +from __future__ import annotations + +from typing import Final, TypedDict DOMAIN = "bthome" + +CONF_BINDKEY: Final = "bindkey" +CONF_DISCOVERED_EVENT_CLASSES: Final = "known_events" +CONF_SUBTYPE: Final = "subtype" + +EVENT_TYPE: Final = "event_type" +EVENT_CLASS: Final = "event_class" +EVENT_PROPERTIES: Final = "event_properties" +BTHOME_BLE_EVENT: Final = "bthome_ble_event" + + +EVENT_CLASS_BUTTON: Final = "button" +EVENT_CLASS_DIMMER: Final = "dimmer" + +CONF_EVENT_CLASS: Final = "event_class" +CONF_EVENT_PROPERTIES: Final = "event_properties" + + +class BTHomeBleEvent(TypedDict): + """BTHome BLE event data.""" + + device_id: str + address: str + event_class: str # ie 'button' + event_type: str # ie 'press' + event_properties: dict[str, str | int | float | None] | None diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py new file mode 100644 index 0000000000..a81c30eee8 --- /dev/null +++ b/homeassistant/components/bthome/device_trigger.py @@ -0,0 +1,130 @@ +"""Provides device triggers for BTHome BLE.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_EVENT, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from .const import ( + BTHOME_BLE_EVENT, + CONF_DISCOVERED_EVENT_CLASSES, + CONF_SUBTYPE, + DOMAIN, + EVENT_CLASS, + EVENT_CLASS_BUTTON, + EVENT_CLASS_DIMMER, + EVENT_TYPE, +) + +TRIGGERS_BY_EVENT_CLASS = { + EVENT_CLASS_BUTTON: { + "press", + "double_press", + "triple_press", + "long_press", + "long_double_press", + "long_triple_press", + }, + EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"}, +} + +SCHEMA_BY_EVENT_CLASS = { + EVENT_CLASS_BUTTON: DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_BUTTON]), + vol.Required(CONF_SUBTYPE): vol.In( + TRIGGERS_BY_EVENT_CLASS[EVENT_CLASS_BUTTON] + ), + } + ), + EVENT_CLASS_DIMMER: DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_DIMMER]), + vol.Required(CONF_SUBTYPE): vol.In( + TRIGGERS_BY_EVENT_CLASS[EVENT_CLASS_DIMMER] + ), + } + ), +} + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate trigger config.""" + return SCHEMA_BY_EVENT_CLASS.get(config[CONF_TYPE], DEVICE_TRIGGER_BASE_SCHEMA)( + config + ) + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """Return a list of triggers for BTHome BLE devices.""" + device_registry = dr.async_get(hass) + device = device_registry.async_get(device_id) + assert device is not None + config_entries = [ + hass.config_entries.async_get_entry(entry_id) + for entry_id in device.config_entries + ] + bthome_config_entry = next( + iter(entry for entry in config_entries if entry and entry.domain == DOMAIN), + None, + ) + assert bthome_config_entry is not None + return [ + { + # Required fields of TRIGGER_BASE_SCHEMA + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + # Required fields of TRIGGER_SCHEMA + CONF_TYPE: event_class, + CONF_SUBTYPE: event_type, + } + for event_class in bthome_config_entry.data.get( + CONF_DISCOVERED_EVENT_CLASSES, [] + ) + for event_type in TRIGGERS_BY_EVENT_CLASS.get(event_class, []) + ] + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + return await event_trigger.async_attach_trigger( + hass, + event_trigger.TRIGGER_SCHEMA( + { + event_trigger.CONF_PLATFORM: CONF_EVENT, + event_trigger.CONF_EVENT_TYPE: BTHOME_BLE_EVENT, + event_trigger.CONF_EVENT_DATA: { + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + EVENT_CLASS: config[CONF_TYPE], + EVENT_TYPE: config[CONF_SUBTYPE], + }, + } + ), + action, + trigger_info, + platform_type="device", + ) diff --git a/homeassistant/components/bthome/models.py b/homeassistant/components/bthome/models.py new file mode 100644 index 0000000000..558f19c774 --- /dev/null +++ b/homeassistant/components/bthome/models.py @@ -0,0 +1,11 @@ +"""The bthome integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class BTHomeData: + """Data for the bthome integration.""" + + discovered_event_classes: set[str] diff --git a/homeassistant/components/bthome/strings.json b/homeassistant/components/bthome/strings.json index f2fdcc6482..020a0206e7 100644 --- a/homeassistant/components/bthome/strings.json +++ b/homeassistant/components/bthome/strings.json @@ -28,5 +28,21 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "device_automation": { + "trigger_subtype": { + "press": "Press", + "double_press": "Double Press", + "triple_press": "Triple Press", + "long_press": "Long Press", + "long_double_press": "Long Double Press", + "long_triple_press": "Long Triple Press", + "rotate_right": "Rotate Right", + "rotate_left": "Rotate Left" + }, + "trigger_type": { + "button": "Button \"{subtype}\"", + "dimmer": "Dimmer \"{subtype}\"" + } } } diff --git a/homeassistant/components/buienradar/__init__.py b/homeassistant/components/buienradar/__init__.py index 9055220b7e..e259dbac69 100644 --- a/homeassistant/components/buienradar/__init__.py +++ b/homeassistant/components/buienradar/__init__.py @@ -12,7 +12,7 @@ PLATFORMS = [Platform.CAMERA, Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up buienradar from a config entry.""" - hass.data.setdefault(DOMAIN, {}) + hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {}) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_update_options)) return True @@ -20,7 +20,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + entry_data = hass.data[DOMAIN].pop(entry.entry_id) + for platform in PLATFORMS: + if (data := entry_data.get(platform)) and ( + unsub := data.unsub_schedule_update + ): + unsub() return unload_ok diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index b7061abeab..06b97cdeda 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -35,6 +35,7 @@ from homeassistant.const import ( CONF_NAME, DEGREE, PERCENTAGE, + Platform, UnitOfIrradiance, UnitOfLength, UnitOfPrecipitationDepth, @@ -47,7 +48,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import CONF_TIMEFRAME, DEFAULT_TIMEFRAME +from .const import CONF_TIMEFRAME, DEFAULT_TIMEFRAME, DOMAIN from .util import BrData _LOGGER = logging.getLogger(__name__) @@ -684,6 +685,7 @@ async def async_setup_entry( data = BrData(hass, coordinates, timeframe, entities) # schedule the first update in 1 minute from now: await data.schedule_update(1) + hass.data[DOMAIN][entry.entry_id][Platform.SENSOR] = data class BrSensor(SensorEntity): diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 06cd1b32cf..54f3732afe 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -27,6 +27,7 @@ from buienradar.constants import ( from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import CALLBACK_TYPE from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util import dt as dt_util @@ -65,6 +66,7 @@ class BrData: self.hass = hass self.coordinates = coordinates self.timeframe = timeframe + self.unsub_schedule_update: CALLBACK_TYPE | None = None async def update_devices(self): """Update all devices/sensors.""" @@ -79,7 +81,9 @@ class BrData: """Schedule an update after minute minutes.""" _LOGGER.debug("Scheduling next update in %s minutes", minute) nxt = dt_util.utcnow() + timedelta(minutes=minute) - async_track_point_in_utc_time(self.hass, self.async_update, nxt) + self.unsub_schedule_update = async_track_point_in_utc_time( + self.hass, self.async_update, nxt + ) async def get_data(self, url): """Load data from specified url.""" diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 4cee98c07b..c2a276eed1 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -41,6 +41,7 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, + Platform, UnitOfLength, UnitOfPrecipitationDepth, UnitOfPressure, @@ -100,6 +101,7 @@ async def async_setup_entry( # create weather data: data = BrData(hass, coordinates, DEFAULT_TIMEFRAME, None) + hass.data[DOMAIN][entry.entry_id][Platform.WEATHER] = data # create weather device: _LOGGER.debug("Initializing buienradar weather: coordinates %s", coordinates) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index d09a389ce8..aedfafbf36 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -42,6 +42,7 @@ from .const import ( EVENT_IN, EVENT_IN_DAYS, EVENT_IN_WEEKS, + EVENT_LOCATION, EVENT_RECURRENCE_ID, EVENT_RECURRENCE_RANGE, EVENT_RRULE, @@ -66,6 +67,13 @@ SCAN_INTERVAL = datetime.timedelta(seconds=60) # Don't support rrules more often than daily VALID_FREQS = {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"} +# Ensure events created in Home Assistant have a positive duration +MIN_NEW_EVENT_DURATION = datetime.timedelta(seconds=1) + +# Events must have a non-negative duration e.g. Google Calendar can create zero +# duration events in the UI. +MIN_EVENT_DURATION = datetime.timedelta(seconds=0) + def _has_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: """Assert that all datetime values have a timezone.""" @@ -115,17 +123,18 @@ def _as_local_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]] return validate -def _has_duration( - start_key: str, end_key: str +def _has_min_duration( + start_key: str, end_key: str, min_duration: datetime.timedelta ) -> Callable[[dict[str, Any]], dict[str, Any]]: - """Verify that the time span between start and end is positive.""" + """Verify that the time span between start and end has a minimum duration.""" def validate(obj: dict[str, Any]) -> dict[str, Any]: - """Test that all keys in the dict are in order.""" if (start := obj.get(start_key)) and (end := obj.get(end_key)): duration = end - start - if duration.total_seconds() <= 0: - raise vol.Invalid(f"Expected positive event duration ({start}, {end})") + if duration < min_duration: + raise vol.Invalid( + f"Expected minimum event duration of {min_duration} ({start}, {end})" + ) return obj return validate @@ -176,6 +185,7 @@ CREATE_EVENT_SCHEMA = vol.All( { vol.Required(EVENT_SUMMARY): cv.string, vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, + vol.Optional(EVENT_LOCATION): cv.string, vol.Inclusive( EVENT_START_DATE, "dates", "Start and end dates must both be specified" ): cv.date, @@ -202,8 +212,8 @@ CREATE_EVENT_SCHEMA = vol.All( ), _has_consistent_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME), _as_local_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME), - _has_duration(EVENT_START_DATE, EVENT_END_DATE), - _has_duration(EVENT_START_DATETIME, EVENT_END_DATETIME), + _has_min_duration(EVENT_START_DATE, EVENT_END_DATE, MIN_NEW_EVENT_DURATION), + _has_min_duration(EVENT_START_DATETIME, EVENT_END_DATETIME, MIN_NEW_EVENT_DURATION), ) WEBSOCKET_EVENT_SCHEMA = vol.Schema( @@ -213,12 +223,13 @@ WEBSOCKET_EVENT_SCHEMA = vol.Schema( vol.Required(EVENT_END): vol.Any(cv.date, cv.datetime), vol.Required(EVENT_SUMMARY): cv.string, vol.Optional(EVENT_DESCRIPTION): cv.string, + vol.Optional(EVENT_LOCATION): cv.string, vol.Optional(EVENT_RRULE): _validate_rrule, }, _has_same_type(EVENT_START, EVENT_END), _has_consistent_timezone(EVENT_START, EVENT_END), _as_local_timezone(EVENT_START, EVENT_END), - _has_duration(EVENT_START, EVENT_END), + _has_min_duration(EVENT_START, EVENT_END, MIN_NEW_EVENT_DURATION), ) ) @@ -233,9 +244,8 @@ CALENDAR_EVENT_SCHEMA = vol.Schema( }, _has_same_type("start", "end"), _has_timezone("start", "end"), - _has_consistent_timezone("start", "end"), _as_local_timezone("start", "end"), - _has_duration("start", "end"), + _has_min_duration("start", "end", MIN_EVENT_DURATION), ), extra=vol.ALLOW_EXTRA, ) @@ -343,6 +353,16 @@ class CalendarEvent: f"Failed to validate CalendarEvent: {err}" ) from err + # It is common to set a start an end date to be the same thing for + # an all day event, but that is not a valid duration. Fix to have a + # duration of one day. + if ( + not isinstance(self.start, datetime.datetime) + and not isinstance(self.end, datetime.datetime) + and self.start == self.end + ): + self.end = self.start + datetime.timedelta(days=1) + def _event_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: """Convert CalendarEvent dataclass items to dictionary of attributes.""" @@ -520,7 +540,7 @@ class CalendarEventView(http.HomeAssistantView): try: calendar_event_list = await entity.async_get_events( - request.app["hass"], start_date, end_date + request.app["hass"], dt.as_local(start_date), dt.as_local(end_date) ) except HomeAssistantError as err: _LOGGER.debug("Error reading events: %s", err) diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index dfe278a92d..5d1a3ccf0f 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -46,3 +46,9 @@ create_event: name: In description: Days or weeks that you want to create the event in. example: '{"days": 2} or {"weeks": 2}' + location: + name: Location + description: The location of the event. + example: "Conference Room - F123, Bldg. 002" + selector: + text: diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index e368779e94..c09586848d 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -56,6 +56,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.network import get_url +from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -379,7 +380,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity.async_update_token() entity.async_write_ha_state() - unsub = async_track_time_interval(hass, update_tokens, TOKEN_CHANGE_INTERVAL) + unsub = async_track_time_interval( + hass, update_tokens, TOKEN_CHANGE_INTERVAL, name="Camera update tokens" + ) @callback def unsub_track_time_interval(_event: Event) -> None: @@ -910,15 +913,16 @@ async def async_handle_snapshot_service( ) -> None: """Handle snapshot services calls.""" hass = camera.hass - filename = service_call.data[ATTR_FILENAME] + filename: Template = service_call.data[ATTR_FILENAME] filename.hass = hass snapshot_file = filename.async_render(variables={ATTR_ENTITY_ID: camera}) # check if we allow to access to that file if not hass.config.is_allowed_path(snapshot_file): - _LOGGER.error("Can't write %s, no access to path!", snapshot_file) - return + raise HomeAssistantError( + f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" + ) image = await camera.async_camera_image() diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index 28e4e1eeac..160f896c86 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -1,6 +1,7 @@ """Preference management for camera component.""" from __future__ import annotations +from collections.abc import Mapping from dataclasses import asdict, dataclass from typing import Final, cast @@ -89,7 +90,7 @@ class CameraPreferences: # Get preload stream setting from prefs # Get orientation setting from entity registry reg_entry = er.async_get(self._hass).async_get(entity_id) - er_prefs = reg_entry.options.get(DOMAIN, {}) if reg_entry else {} + er_prefs: Mapping = reg_entry.options.get(DOMAIN, {}) if reg_entry else {} preload_prefs = await self._store.async_load() or {} settings = DynamicStreamSettings( preload_stream=cast( diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index cc4a130a25..48921303ce 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.4"], + "requirements": ["pychromecast==13.0.7"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 8352b566af..0af85fe9d4 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entityfilter from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -50,9 +50,9 @@ from .const import ( CONF_RELAYER_SERVER, CONF_REMOTE_SNI_SERVER, CONF_REMOTESTATE_SERVER, + CONF_SERVICEHANDLERS_SERVER, CONF_THINGTALK_SERVER, CONF_USER_POOL_ID, - CONF_VOICE_SERVER, DOMAIN, MODE_DEV, MODE_PROD, @@ -119,7 +119,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_REMOTE_SNI_SERVER): str, vol.Optional(CONF_REMOTESTATE_SERVER): str, vol.Optional(CONF_THINGTALK_SERVER): str, - vol.Optional(CONF_VOICE_SERVER): str, + vol.Optional(CONF_SERVICEHANDLERS_SERVER): str, } ) }, @@ -241,6 +241,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websession = async_get_clientsession(hass) client = CloudClient(hass, prefs, websession, alexa_conf, google_conf) cloud = hass.data[DOMAIN] = Cloud(client, **kwargs) + cloud.iot.register_on_connect(client.on_cloud_connected) async def _shutdown(event): """Shutdown event.""" @@ -262,8 +263,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler ) - loaded = False - async def async_startup_repairs(_=None) -> None: """Create repair issues after startup.""" if not cloud.is_logged_in: @@ -272,8 +271,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if subscription_info := await async_subscription_info(cloud): async_manage_legacy_subscription_issue(hass, subscription_info) - async def _on_connect(): - """Discover RemoteUI binary sensor.""" + loaded = False + + async def _on_start(): + """Discover platforms.""" nonlocal loaded # Prevent multiple discovery @@ -281,10 +282,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return loaded = True - await async_load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config) - await async_load_platform(hass, Platform.STT, DOMAIN, {}, config) - await async_load_platform(hass, Platform.TTS, DOMAIN, {}, config) + stt_platform_loaded = asyncio.Event() + tts_platform_loaded = asyncio.Event() + stt_info = {"platform_loaded": stt_platform_loaded} + tts_info = {"platform_loaded": tts_platform_loaded} + await async_load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config) + await async_load_platform(hass, Platform.STT, DOMAIN, stt_info, config) + await async_load_platform(hass, Platform.TTS, DOMAIN, tts_info, config) + await asyncio.gather(stt_platform_loaded.wait(), tts_platform_loaded.wait()) + + async def _on_connect(): + """Handle cloud connect.""" async_dispatcher_send( hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_CONNECTED ) @@ -299,6 +308,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Update preferences.""" await prefs.async_update(remote_domain=cloud.remote.instance_domain) + cloud.register_on_start(_on_start) cloud.iot.register_on_connect(_on_connect) cloud.iot.register_on_disconnect(_on_disconnect) cloud.register_on_initialized(_on_initialized) @@ -311,7 +321,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_call_later( hass=hass, delay=timedelta(hours=STARTUP_REPAIR_DELAY), - action=async_startup_repairs, + action=HassJob( + async_startup_repairs, "cloud startup repairs", cancel_on_shutdown=True + ), ) return True diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 377da7d60b..44a42c78f0 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -20,6 +20,11 @@ from homeassistant.components.alexa import ( errors as alexa_errors, state_report as alexa_state_report, ) +from homeassistant.components.homeassistant.exposed_entities import ( + async_get_assistant_settings, + async_listen_entity_updates, + async_should_expose, +) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.helpers import entity_registry as er, start @@ -30,16 +35,17 @@ from homeassistant.util.dt import utcnow from .const import ( CONF_ENTITY_CONFIG, CONF_FILTER, - PREF_ALEXA_DEFAULT_EXPOSE, - PREF_ALEXA_ENTITY_CONFIGS, + DOMAIN as CLOUD_DOMAIN, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, PREF_SHOULD_EXPOSE, ) -from .prefs import CloudPreferences +from .prefs import ALEXA_SETTINGS_VERSION, CloudPreferences _LOGGER = logging.getLogger(__name__) +CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}" + # Time to wait when entity preferences have changed before syncing it to # the cloud. SYNC_DELAY = 1 @@ -64,7 +70,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._cloud = cloud self._token = None self._token_valid = None - self._cur_entity_prefs = prefs.alexa_entity_configs + self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA) self._alexa_sync_unsub: Callable[[], None] | None = None self._endpoint = None @@ -115,10 +121,31 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): """Return an identifier for the user that represents this config.""" return self._cloud_user + def _migrate_alexa_entity_settings_v1(self): + """Migrate alexa entity settings to entity registry options.""" + if not self._config[CONF_FILTER].empty_filter: + # Don't migrate if there's a YAML config + return + + entity_registry = er.async_get(self.hass) + + for entity_id, entry in entity_registry.entities.items(): + if CLOUD_ALEXA in entry.options: + continue + options = {"should_expose": self._should_expose_legacy(entity_id)} + entity_registry.async_update_entity_options(entity_id, CLOUD_ALEXA, options) + async def async_initialize(self): """Initialize the Alexa config.""" await super().async_initialize() + if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION: + if self._prefs.alexa_settings_version < 2: + self._migrate_alexa_entity_settings_v1() + await self._prefs.async_update( + alexa_settings_version=ALEXA_SETTINGS_VERSION + ) + async def hass_started(hass): if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, ALEXA_DOMAIN, {}) @@ -126,19 +153,19 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): start.async_at_start(self.hass, hass_started) self._prefs.async_listen_updates(self._async_prefs_updated) + async_listen_entity_updates( + self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated + ) self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated, ) - def should_expose(self, entity_id): + def _should_expose_legacy(self, entity_id): """If an entity should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - if not self._config[CONF_FILTER].empty_filter: - return self._config[CONF_FILTER](entity_id) - entity_configs = self._prefs.alexa_entity_configs entity_config = entity_configs.get(entity_id, {}) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) @@ -160,6 +187,15 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + def should_expose(self, entity_id): + """If an entity should be exposed.""" + if not self._config[CONF_FILTER].empty_filter: + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + return self._config[CONF_FILTER](entity_id) + + return async_should_expose(self.hass, CLOUD_ALEXA, entity_id) + @callback def async_invalidate_access_token(self): """Invalidate access token.""" @@ -233,32 +269,30 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): if not any( key in updated_prefs for key in ( - PREF_ALEXA_DEFAULT_EXPOSE, - PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, ) ): return - # If we update just entity preferences, delay updating - # as we might update more - if updated_prefs == {PREF_ALEXA_ENTITY_CONFIGS}: - if self._alexa_sync_unsub: - self._alexa_sync_unsub() - - self._alexa_sync_unsub = async_call_later( - self.hass, SYNC_DELAY, self._sync_prefs - ) - return - await self.async_sync_entities() + @callback + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" + # Delay updating as we might update more + if self._alexa_sync_unsub: + self._alexa_sync_unsub() + + self._alexa_sync_unsub = async_call_later( + self.hass, SYNC_DELAY, self._sync_prefs + ) + async def _sync_prefs(self, _now): """Sync the updated preferences to Alexa.""" self._alexa_sync_unsub = None old_prefs = self._cur_entity_prefs - new_prefs = self._prefs.alexa_entity_configs + new_prefs = async_get_assistant_settings(self.hass, CLOUD_ALEXA) seen = set() to_update = [] diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 08d4364424..631c0641b4 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -47,6 +47,7 @@ class CloudClient(Interface): self._google_config: google_config.CloudGoogleConfig | None = None self._alexa_config_init_lock = asyncio.Lock() self._google_config_init_lock = asyncio.Lock() + self._relayer_region: str | None = None @property def base_path(self) -> Path: @@ -84,6 +85,11 @@ class CloudClient(Interface): """Return true if we want start a remote connection.""" return self._prefs.remote_enabled + @property + def relayer_region(self) -> str | None: + """Return the connected relayer region.""" + return self._relayer_region + async def get_alexa_config(self) -> alexa_config.CloudAlexaConfig: """Return Alexa config.""" if self._alexa_config is None: @@ -130,8 +136,8 @@ class CloudClient(Interface): return self._google_config - async def cloud_started(self) -> None: - """When cloud is started.""" + async def on_cloud_connected(self) -> None: + """When cloud is connected.""" is_new_user = await self.prefs.async_set_username(self.cloud.username) async def enable_alexa(_): @@ -175,6 +181,9 @@ class CloudClient(Interface): if tasks: await asyncio.gather(*(task(None) for task in tasks)) + async def cloud_started(self) -> None: + """When cloud is started.""" + async def cloud_stopped(self) -> None: """When the cloud is stopped.""" @@ -256,6 +265,11 @@ class CloudClient(Interface): "headers": {"Content-Type": response.content_type}, } + async def async_system_message(self, payload: dict[Any, Any] | None) -> None: + """Handle system messages.""" + if payload and (region := payload.get("region")): + self._relayer_region = region + async def async_cloudhooks_update(self, data: dict[str, dict[str, str]]) -> None: """Update local list of cloudhooks.""" await self._prefs.async_update(cloudhooks=data) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 9d5ed2ca28..7aa39efbf0 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -19,6 +19,8 @@ PREF_USERNAME = "username" PREF_REMOTE_DOMAIN = "remote_domain" PREF_ALEXA_DEFAULT_EXPOSE = "alexa_default_expose" PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose" +PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version" +PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_TTS_DEFAULT_VOICE = "tts_default_voice" DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female") DEFAULT_DISABLE_2FA = False @@ -56,7 +58,7 @@ CONF_RELAYER_SERVER = "relayer_server" CONF_REMOTE_SNI_SERVER = "remote_sni_server" CONF_REMOTESTATE_SERVER = "remotestate_server" CONF_THINGTALK_SERVER = "thingtalk_server" -CONF_VOICE_SERVER = "voice_server" +CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" MODE_DEV = "development" MODE_PROD = "production" diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index cf5a1de73a..c47b05c264 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -9,6 +9,10 @@ from hass_nabucasa.google_report_state import ErrorResponse from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN from homeassistant.components.google_assistant.helpers import AbstractConfig +from homeassistant.components.homeassistant.exposed_entities import ( + async_listen_entity_updates, + async_should_expose, +) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import ( CoreState, @@ -22,14 +26,18 @@ from homeassistant.setup import async_setup_component from .const import ( CONF_ENTITY_CONFIG, + CONF_FILTER, DEFAULT_DISABLE_2FA, + DOMAIN as CLOUD_DOMAIN, PREF_DISABLE_2FA, PREF_SHOULD_EXPOSE, ) -from .prefs import CloudPreferences +from .prefs import GOOGLE_SETTINGS_VERSION, CloudPreferences _LOGGER = logging.getLogger(__name__) +CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}" + class CloudGoogleConfig(AbstractConfig): """HA Cloud Configuration for Google Assistant.""" @@ -48,8 +56,6 @@ class CloudGoogleConfig(AbstractConfig): self._user = cloud_user self._prefs = prefs self._cloud = cloud - self._cur_entity_prefs = self._prefs.google_entity_configs - self._cur_default_expose = self._prefs.google_default_expose self._sync_entities_lock = asyncio.Lock() @property @@ -89,10 +95,35 @@ class CloudGoogleConfig(AbstractConfig): """Return Cloud User account.""" return self._user + def _migrate_google_entity_settings_v1(self): + """Migrate Google entity settings to entity registry options.""" + if not self._config[CONF_FILTER].empty_filter: + # Don't migrate if there's a YAML config + return + + entity_registry = er.async_get(self.hass) + + for entity_id, entry in entity_registry.entities.items(): + if CLOUD_GOOGLE in entry.options: + continue + options = {"should_expose": self._should_expose_legacy(entity_id)} + if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None): + options[PREF_DISABLE_2FA] = _2fa_disabled + entity_registry.async_update_entity_options( + entity_id, CLOUD_GOOGLE, options + ) + async def async_initialize(self): """Perform async initialization of config.""" await super().async_initialize() + if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: + if self._prefs.google_settings_version < 2: + self._migrate_google_entity_settings_v1() + await self._prefs.async_update( + google_settings_version=GOOGLE_SETTINGS_VERSION + ) + async def hass_started(hass): if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) @@ -109,7 +140,9 @@ class CloudGoogleConfig(AbstractConfig): await self.async_disconnect_agent_user(agent_user_id) self._prefs.async_listen_updates(self._async_prefs_updated) - + async_listen_entity_updates( + self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated + ) self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated, @@ -123,14 +156,11 @@ class CloudGoogleConfig(AbstractConfig): """If a state object should be exposed.""" return self._should_expose_entity_id(state.entity_id) - def _should_expose_entity_id(self, entity_id): + def _should_expose_legacy(self, entity_id): """If an entity ID should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - if not self._config["filter"].empty_filter: - return self._config["filter"](entity_id) - entity_configs = self._prefs.google_entity_configs entity_config = entity_configs.get(entity_id, {}) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) @@ -154,6 +184,15 @@ class CloudGoogleConfig(AbstractConfig): return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + def _should_expose_entity_id(self, entity_id): + """If an entity should be exposed.""" + if not self._config[CONF_FILTER].empty_filter: + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + return self._config[CONF_FILTER](entity_id) + + return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) + @property def agent_user_id(self): """Return Agent User Id to use for query responses.""" @@ -168,11 +207,23 @@ class CloudGoogleConfig(AbstractConfig): """Get agent user ID making request.""" return self.agent_user_id - def should_2fa(self, state): + def _2fa_disabled_legacy(self, entity_id): """If an entity should be checked for 2FA.""" entity_configs = self._prefs.google_entity_configs - entity_config = entity_configs.get(state.entity_id, {}) - return not entity_config.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) + entity_config = entity_configs.get(entity_id, {}) + return entity_config.get(PREF_DISABLE_2FA) + + def should_2fa(self, state): + """If an entity should be checked for 2FA.""" + entity_registry = er.async_get(self.hass) + + registry_entry = entity_registry.async_get(state.entity_id) + if not registry_entry: + # Handle the entity has been removed + return False + + assistant_options = registry_entry.options.get(CLOUD_GOOGLE, {}) + return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) async def async_report_state(self, message, agent_user_id: str): """Send a state report to Google.""" @@ -218,14 +269,6 @@ class CloudGoogleConfig(AbstractConfig): # So when we change it, we need to sync all entities. sync_entities = True - # If entity prefs are the same or we have filter in config.yaml, - # don't sync. - elif ( - self._cur_entity_prefs is not prefs.google_entity_configs - or self._cur_default_expose is not prefs.google_default_expose - ) and self._config["filter"].empty_filter: - self.async_schedule_google_sync_all() - if self.enabled and not self.is_local_sdk_active: self.async_enable_local_sdk() sync_entities = True @@ -233,12 +276,14 @@ class CloudGoogleConfig(AbstractConfig): self.async_disable_local_sdk() sync_entities = True - self._cur_entity_prefs = prefs.google_entity_configs - self._cur_default_expose = prefs.google_default_expose - if sync_entities and self.hass.is_running: await self.async_sync_entities_all() + @callback + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" + self.async_schedule_google_sync_all() + @callback def _handle_entity_registry_updated(self, event: Event) -> None: """Handle when entity registry updated.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 6c4115ae28..8d6c4e65e3 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,5 +1,6 @@ """The HTTP api to control the cloud integration.""" import asyncio +from collections.abc import Mapping import dataclasses from functools import wraps from http import HTTPStatus @@ -14,7 +15,7 @@ from hass_nabucasa.const import STATE_DISCONNECTED from hass_nabucasa.voice import MAP_VOICE import voluptuous as vol -from homeassistant.components import websocket_api +from homeassistant.components import assist_pipeline, conversation, websocket_api from homeassistant.components.alexa import ( entities as alexa_entities, errors as alexa_errors, @@ -22,22 +23,24 @@ from homeassistant.components.alexa import ( from homeassistant.components.google_assistant import helpers as google_helpers from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.location import async_detect_location_info from .const import ( DOMAIN, - PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_REPORT_STATE, + PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_DEFAULT_EXPOSE, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, ) +from .google_config import CLOUD_GOOGLE from .repairs import async_manage_legacy_subscription_issue from .subscription import async_subscription_info @@ -66,11 +69,11 @@ async def async_setup(hass): websocket_api.async_register_command(hass, websocket_remote_connect) websocket_api.async_register_command(hass, websocket_remote_disconnect) + websocket_api.async_register_command(hass, google_assistant_get) websocket_api.async_register_command(hass, google_assistant_list) websocket_api.async_register_command(hass, google_assistant_update) websocket_api.async_register_command(hass, alexa_list) - websocket_api.async_register_command(hass, alexa_update) websocket_api.async_register_command(hass, alexa_sync) websocket_api.async_register_command(hass, thingtalk_convert) @@ -179,11 +182,32 @@ class CloudLoginView(HomeAssistantView): ) async def post(self, request, data): """Handle login request.""" + + def cloud_assist_pipeline(hass: HomeAssistant) -> str | None: + """Return the ID of a cloud-enabled assist pipeline or None.""" + for pipeline in assist_pipeline.async_get_pipelines(hass): + if ( + pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT + and pipeline.stt_engine == DOMAIN + and pipeline.tts_engine == DOMAIN + ): + return pipeline.id + return None + hass = request.app["hass"] cloud = hass.data[DOMAIN] await cloud.login(data["email"], data["password"]) - return self.json({"success": True}) + # Make sure the pipeline store is loaded, needed because assist_pipeline + # is an after dependency of cloud + await assist_pipeline.async_setup_pipeline_store(hass) + new_cloud_pipeline_id: str | None = None + if (cloud_assist_pipeline(hass)) is None: + if cloud_pipeline := await assist_pipeline.async_create_default_pipeline( + hass, DOMAIN, DOMAIN + ): + new_cloud_pipeline_id = cloud_pipeline.id + return self.json({"success": True, "cloud_pipeline": new_cloud_pipeline_id}) class CloudLogoutView(HomeAssistantView): @@ -350,8 +374,6 @@ async def websocket_subscription( vol.Optional(PREF_ENABLE_ALEXA): bool, vol.Optional(PREF_ALEXA_REPORT_STATE): bool, vol.Optional(PREF_GOOGLE_REPORT_STATE): bool, - vol.Optional(PREF_ALEXA_DEFAULT_EXPOSE): [str], - vol.Optional(PREF_GOOGLE_DEFAULT_EXPOSE): [str], vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( vol.Coerce(tuple), vol.In(MAP_VOICE) @@ -484,6 +506,7 @@ async def _account_data(hass: HomeAssistant, cloud: Cloud): "logged_in": True, "prefs": client.prefs.as_dict(), "remote_certificate": certificate, + "remote_certificate_status": remote.certificate_status, "remote_connected": remote.is_connected, "remote_domain": remote.instance_domain, "http_use_ssl": hass.config.api.use_ssl, @@ -523,6 +546,54 @@ async def websocket_remote_disconnect( connection.send_result(msg["id"], await _account_data(hass, cloud)) +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.websocket_command( + { + "type": "cloud/google_assistant/entities/get", + "entity_id": str, + } +) +@websocket_api.async_response +@_ws_handle_cloud_errors +async def google_assistant_get( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get data for a single google assistant entity.""" + cloud = hass.data[DOMAIN] + gconf = await cloud.client.get_google_config() + entity_registry = er.async_get(hass) + entity_id: str = msg["entity_id"] + state = hass.states.get(entity_id) + + if not entity_registry.async_is_registered(entity_id) or not state: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"{entity_id} unknown or not in the entity registry", + ) + return + + entity = google_helpers.GoogleEntity(hass, gconf, state) + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity.is_supported(): + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_SUPPORTED, + f"{entity_id} not supported by Google assistant", + ) + return + + result = { + "entity_id": entity.entity_id, + "traits": [trait.name for trait in entity.traits()], + "might_2fa": entity.might_2fa_traits(), + } + + connection.send_result(msg["id"], result) + + @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({"type": "cloud/google_assistant/entities"}) @@ -536,11 +607,14 @@ async def google_assistant_list( """List all google assistant entities.""" cloud = hass.data[DOMAIN] gconf = await cloud.client.get_google_config() + entity_registry = er.async_get(hass) entities = google_helpers.async_get_entities(hass, gconf) result = [] for entity in entities: + if not entity_registry.async_is_registered(entity.entity_id): + continue result.append( { "entity_id": entity.entity_id, @@ -558,8 +632,7 @@ async def google_assistant_list( { "type": "cloud/google_assistant/entities/update", "entity_id": str, - vol.Optional("should_expose"): vol.Any(None, bool), - vol.Optional("disable_2fa"): bool, + vol.Optional(PREF_DISABLE_2FA): bool, } ) @websocket_api.async_response @@ -569,17 +642,30 @@ async def google_assistant_update( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Update google assistant config.""" - cloud = hass.data[DOMAIN] - changes = dict(msg) - changes.pop("type") - changes.pop("id") + """Update google assistant entity config.""" + entity_registry = er.async_get(hass) + entity_id: str = msg["entity_id"] - await cloud.client.prefs.async_update_google_entity_config(**changes) + if not (registry_entry := entity_registry.async_get(entity_id)): + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_ALLOWED, + f"can't configure {entity_id}", + ) + return - connection.send_result( - msg["id"], cloud.client.prefs.google_entity_configs.get(msg["entity_id"]) + disable_2fa = msg[PREF_DISABLE_2FA] + assistant_options: Mapping[str, Any] + if ( + assistant_options := registry_entry.options.get(CLOUD_GOOGLE, {}) + ) and assistant_options.get(PREF_DISABLE_2FA) == disable_2fa: + return + + assistant_options = assistant_options | {PREF_DISABLE_2FA: disable_2fa} + entity_registry.async_update_entity_options( + entity_id, CLOUD_GOOGLE, assistant_options ) + connection.send_result(msg["id"]) @websocket_api.require_admin @@ -595,11 +681,14 @@ async def alexa_list( """List all alexa entities.""" cloud = hass.data[DOMAIN] alexa_config = await cloud.client.get_alexa_config() + entity_registry = er.async_get(hass) entities = alexa_entities.async_get_entities(hass, alexa_config) result = [] for entity in entities: + if not entity_registry.async_is_registered(entity.entity_id): + continue result.append( { "entity_id": entity.entity_id, @@ -611,35 +700,6 @@ async def alexa_list( connection.send_result(msg["id"], result) -@websocket_api.require_admin -@_require_cloud_login -@websocket_api.websocket_command( - { - "type": "cloud/alexa/entities/update", - "entity_id": str, - vol.Optional("should_expose"): vol.Any(None, bool), - } -) -@websocket_api.async_response -@_ws_handle_cloud_errors -async def alexa_update( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Update alexa entity config.""" - cloud = hass.data[DOMAIN] - changes = dict(msg) - changes.pop("type") - changes.pop("id") - - await cloud.client.prefs.async_update_alexa_entity_config(**changes) - - connection.send_result( - msg["id"], cloud.client.prefs.alexa_entity_configs.get(msg["entity_id"]) - ) - - @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({"type": "cloud/alexa/sync"}) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7f8dfca144..2dbbc81e4c 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -1,12 +1,12 @@ { "domain": "cloud", "name": "Home Assistant Cloud", - "after_dependencies": ["google_assistant", "alexa"], + "after_dependencies": ["assist_pipeline", "google_assistant", "alexa"], "codeowners": ["@home-assistant/cloud"], - "dependencies": ["http", "webhook"], + "dependencies": ["homeassistant", "http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/cloud", "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.61.1"] + "requirements": ["hass-nabucasa==0.66.2"] } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 7f27e7cf39..75e1856503 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,6 +1,8 @@ """Preference management for cloud.""" from __future__ import annotations +from typing import Any + from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.components import webhook @@ -18,9 +20,9 @@ from .const import ( PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, + PREF_ALEXA_SETTINGS_VERSION, PREF_CLOUD_USER, PREF_CLOUDHOOKS, - PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, @@ -29,14 +31,33 @@ from .const import ( PREF_GOOGLE_LOCAL_WEBHOOK_ID, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, + PREF_GOOGLE_SETTINGS_VERSION, PREF_REMOTE_DOMAIN, - PREF_SHOULD_EXPOSE, PREF_TTS_DEFAULT_VOICE, PREF_USERNAME, ) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 2 + +ALEXA_SETTINGS_VERSION = 2 +GOOGLE_SETTINGS_VERSION = 2 + + +class CloudPreferencesStore(Store): + """Store entity registry data.""" + + async def _async_migrate_func( + self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] + ) -> dict[str, Any]: + """Migrate to the new version.""" + if old_major_version == 1: + if old_minor_version < 2: + old_data.setdefault(PREF_ALEXA_SETTINGS_VERSION, 1) + old_data.setdefault(PREF_GOOGLE_SETTINGS_VERSION, 1) + + return old_data class CloudPreferences: @@ -45,7 +66,9 @@ class CloudPreferences: def __init__(self, hass): """Initialize cloud prefs.""" self._hass = hass - self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._store = CloudPreferencesStore( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ) self._prefs = None self._listeners = [] self.last_updated: set[str] = set() @@ -79,14 +102,12 @@ class CloudPreferences: google_secure_devices_pin=UNDEFINED, cloudhooks=UNDEFINED, cloud_user=UNDEFINED, - google_entity_configs=UNDEFINED, - alexa_entity_configs=UNDEFINED, alexa_report_state=UNDEFINED, google_report_state=UNDEFINED, - alexa_default_expose=UNDEFINED, - google_default_expose=UNDEFINED, tts_default_voice=UNDEFINED, remote_domain=UNDEFINED, + alexa_settings_version=UNDEFINED, + google_settings_version=UNDEFINED, ): """Update user preferences.""" prefs = {**self._prefs} @@ -98,12 +119,10 @@ class CloudPreferences: (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), (PREF_CLOUDHOOKS, cloudhooks), (PREF_CLOUD_USER, cloud_user), - (PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs), - (PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs), (PREF_ALEXA_REPORT_STATE, alexa_report_state), (PREF_GOOGLE_REPORT_STATE, google_report_state), - (PREF_ALEXA_DEFAULT_EXPOSE, alexa_default_expose), - (PREF_GOOGLE_DEFAULT_EXPOSE, google_default_expose), + (PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version), + (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version), (PREF_TTS_DEFAULT_VOICE, tts_default_voice), (PREF_REMOTE_DOMAIN, remote_domain), ): @@ -112,53 +131,6 @@ class CloudPreferences: await self._save_prefs(prefs) - async def async_update_google_entity_config( - self, - *, - entity_id, - disable_2fa=UNDEFINED, - should_expose=UNDEFINED, - ): - """Update config for a Google entity.""" - entities = self.google_entity_configs - entity = entities.get(entity_id, {}) - - changes = {} - for key, value in ( - (PREF_DISABLE_2FA, disable_2fa), - (PREF_SHOULD_EXPOSE, should_expose), - ): - if value is not UNDEFINED: - changes[key] = value - - if not changes: - return - - updated_entity = {**entity, **changes} - - updated_entities = {**entities, entity_id: updated_entity} - await self.async_update(google_entity_configs=updated_entities) - - async def async_update_alexa_entity_config( - self, *, entity_id, should_expose=UNDEFINED - ): - """Update config for an Alexa entity.""" - entities = self.alexa_entity_configs - entity = entities.get(entity_id, {}) - - changes = {} - for key, value in ((PREF_SHOULD_EXPOSE, should_expose),): - if value is not UNDEFINED: - changes[key] = value - - if not changes: - return - - updated_entity = {**entity, **changes} - - updated_entities = {**entities, entity_id: updated_entity} - await self.async_update(alexa_entity_configs=updated_entities) - async def async_set_username(self, username) -> bool: """Set the username that is logged in.""" # Logging out. @@ -186,14 +158,12 @@ class CloudPreferences: """Return dictionary version.""" return { PREF_ALEXA_DEFAULT_EXPOSE: self.alexa_default_expose, - PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs, PREF_ALEXA_REPORT_STATE: self.alexa_report_state, PREF_CLOUDHOOKS: self.cloudhooks, PREF_ENABLE_ALEXA: self.alexa_enabled, PREF_ENABLE_GOOGLE: self.google_enabled, PREF_ENABLE_REMOTE: self.remote_enabled, PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose, - PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs, PREF_GOOGLE_REPORT_STATE: self.google_report_state, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, @@ -235,6 +205,11 @@ class CloudPreferences: """Return Alexa Entity configurations.""" return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) + @property + def alexa_settings_version(self): + """Return version of Alexa settings.""" + return self._prefs[PREF_ALEXA_SETTINGS_VERSION] + @property def google_enabled(self): """Return if Google is enabled.""" @@ -255,6 +230,11 @@ class CloudPreferences: """Return Google Entity configurations.""" return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) + @property + def google_settings_version(self): + """Return version of Google settings.""" + return self._prefs[PREF_GOOGLE_SETTINGS_VERSION] + @property def google_local_webhook_id(self): """Return Google webhook ID to receive local messages.""" @@ -319,6 +299,7 @@ class CloudPreferences: return { PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_ALEXA_ENTITY_CONFIGS: {}, + PREF_ALEXA_SETTINGS_VERSION: ALEXA_SETTINGS_VERSION, PREF_CLOUD_USER: None, PREF_CLOUDHOOKS: {}, PREF_ENABLE_ALEXA: True, @@ -326,6 +307,7 @@ class CloudPreferences: PREF_ENABLE_REMOTE: False, PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_GOOGLE_ENTITY_CONFIGS: {}, + PREF_GOOGLE_SETTINGS_VERSION: GOOGLE_SETTINGS_VERSION, PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_REMOTE_DOMAIN: None, diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index e437fca9ed..a3cf7fe045 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -4,7 +4,9 @@ "can_reach_cert_server": "Reach Certificate Server", "can_reach_cloud": "Reach Home Assistant Cloud", "can_reach_cloud_auth": "Reach Authentication Server", + "certificate_status": "Certificate Status", "relayer_connected": "Relayer Connected", + "relayer_region": "Relayer Region", "remote_connected": "Remote Connected", "remote_enabled": "Remote Enabled", "remote_server": "Remote Server", diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index 70618ab38e..84e1e088d4 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -1,9 +1,11 @@ """Support for the cloud for speech to text service.""" from __future__ import annotations -from aiohttp import StreamReader +from collections.abc import AsyncIterable +import logging + from hass_nabucasa import Cloud -from hass_nabucasa.voice import VoiceError +from hass_nabucasa.voice import STT_LANGUAGES, VoiceError from homeassistant.components.stt import ( AudioBitRates, @@ -19,35 +21,17 @@ from homeassistant.components.stt import ( from .const import DOMAIN -SUPPORT_LANGUAGES = [ - "da-DK", - "de-DE", - "en-AU", - "en-CA", - "en-GB", - "en-US", - "es-ES", - "fi-FI", - "fr-CA", - "fr-FR", - "it-IT", - "ja-JP", - "nl-NL", - "pl-PL", - "pt-PT", - "ru-RU", - "sv-SE", - "th-TH", - "zh-CN", - "zh-HK", -] +_LOGGER = logging.getLogger(__name__) async def async_get_engine(hass, config, discovery_info=None): """Set up Cloud speech component.""" cloud: Cloud = hass.data[DOMAIN] - return CloudProvider(cloud) + cloud_provider = CloudProvider(cloud) + if discovery_info is not None: + discovery_info["platform_loaded"].set() + return cloud_provider class CloudProvider(Provider): @@ -60,7 +44,7 @@ class CloudProvider(Provider): @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" - return SUPPORT_LANGUAGES + return STT_LANGUAGES @property def supported_formats(self) -> list[AudioFormats]: @@ -88,10 +72,10 @@ class CloudProvider(Provider): return [AudioChannels.CHANNEL_MONO] async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: StreamReader + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service.""" - content = ( + content_type = ( f"audio/{metadata.format!s}; codecs=audio/{metadata.codec!s};" " samplerate=16000" ) @@ -99,9 +83,12 @@ class CloudProvider(Provider): # Process STT try: result = await self.cloud.voice.process_stt( - stream, content, metadata.language + stream=stream, + content_type=content_type, + language=metadata.language, ) - except VoiceError: + except VoiceError as err: + _LOGGER.error("Voice error: %s", err) return SpeechResult(None, SpeechResultState.ERROR) # Return Speech as Text diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py index 9f836114b3..592338144f 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -28,11 +28,13 @@ async def system_health_info(hass): if cloud.is_logged_in: data["subscription_expiration"] = cloud.expiration_date data["relayer_connected"] = cloud.is_connected + data["relayer_region"] = client.relayer_region data["remote_enabled"] = client.prefs.remote_enabled data["remote_connected"] = cloud.remote.is_connected data["alexa_enabled"] = client.prefs.alexa_enabled data["google_enabled"] = client.prefs.google_enabled data["remote_server"] = cloud.remote.snitun_server + data["certificate_status"] = cloud.remote.certificate_status data["can_reach_cert_server"] = system_health.async_check_can_reach_url( hass, f"https://{cloud.acme_server}/directory" diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 00eacf7ca5..fea2ffca98 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -1,16 +1,28 @@ """Support for the cloud for text to speech service.""" +import logging + from hass_nabucasa import Cloud -from hass_nabucasa.voice import MAP_VOICE, VoiceError +from hass_nabucasa.voice import MAP_VOICE, TTS_VOICES, AudioOutput, VoiceError import voluptuous as vol -from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider +from homeassistant.components.tts import ( + ATTR_AUDIO_OUTPUT, + ATTR_VOICE, + CONF_LANG, + PLATFORM_SCHEMA, + Provider, + Voice, +) +from homeassistant.core import callback from .const import DOMAIN -CONF_GENDER = "gender" +ATTR_GENDER = "gender" -SUPPORT_LANGUAGES = list({key[0] for key in MAP_VOICE}) +SUPPORT_LANGUAGES = list(TTS_VOICES) + +_LOGGER = logging.getLogger(__name__) def validate_lang(value): @@ -18,8 +30,8 @@ def validate_lang(value): if (lang := value.get(CONF_LANG)) is None: return value - if (gender := value.get(CONF_GENDER)) is None: - gender = value[CONF_GENDER] = next( + if (gender := value.get(ATTR_GENDER)) is None: + gender = value[ATTR_GENDER] = next( (chk_gender for chk_lang, chk_gender in MAP_VOICE if chk_lang == lang), None ) @@ -33,7 +45,7 @@ PLATFORM_SCHEMA = vol.All( PLATFORM_SCHEMA.extend( { vol.Optional(CONF_LANG): str, - vol.Optional(CONF_GENDER): str, + vol.Optional(ATTR_GENDER): str, } ), validate_lang, @@ -49,9 +61,12 @@ async def async_get_engine(hass, config, discovery_info=None): gender = None else: language = config[CONF_LANG] - gender = config[CONF_GENDER] + gender = config[ATTR_GENDER] - return CloudProvider(cloud, language, gender) + cloud_provider = CloudProvider(cloud, language, gender) + if discovery_info is not None: + discovery_info["platform_loaded"].set() + return cloud_provider class CloudProvider(Provider): @@ -87,21 +102,36 @@ class CloudProvider(Provider): @property def supported_options(self): """Return list of supported options like voice, emotion.""" - return [CONF_GENDER] + return [ATTR_GENDER, ATTR_VOICE, ATTR_AUDIO_OUTPUT] + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + if not (voices := TTS_VOICES.get(language)): + return None + return [Voice(voice, voice) for voice in voices] @property def default_options(self): """Return a dict include default options.""" - return {CONF_GENDER: self._gender} + return { + ATTR_GENDER: self._gender, + ATTR_AUDIO_OUTPUT: AudioOutput.MP3, + } async def async_get_tts_audio(self, message, language, options=None): """Load TTS from NabuCasa Cloud.""" # Process TTS try: data = await self.cloud.voice.process_tts( - message, language, gender=options[CONF_GENDER] + text=message, + language=language, + gender=options.get(ATTR_GENDER), + voice=options.get(ATTR_VOICE), + output=options[ATTR_AUDIO_OUTPUT], ) - except VoiceError: + except VoiceError as err: + _LOGGER.error("Voice error: %s", err) return (None, None) - return ("mp3", data) + return (str(options[ATTR_AUDIO_OUTPUT]), data) diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 680220371b..9f133c0b0c 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -35,13 +35,13 @@ class CO2SensorEntityDescription(SensorEntityDescription): SENSORS = ( CO2SensorEntityDescription( key="carbonIntensity", - name="CO2 intensity", + translation_key="carbon_intensity", unique_id="co2intensity", # No unit, it's extracted from response. ), CO2SensorEntityDescription( key="fossilFuelPercentage", - name="Grid fossil fuel percentage", + translation_key="fossil_fuel_percentage", native_unit_of_measurement=PERCENTAGE, ), ) diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index 2fe5b79c90..05ea76f317 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -30,5 +30,11 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "api_ratelimit": "API Ratelimit exceeded" } + }, + "entity": { + "sensor": { + "carbon_intensity": { "name": "CO2 intensity" }, + "fossil_fuel_percentage": { "name": "Grid fossil fuel percentage" } + } } } diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index c0713d0780..fe0640d3ef 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -1,62 +1 @@ """The command_line component.""" -from __future__ import annotations - -import logging -import subprocess - -_LOGGER = logging.getLogger(__name__) - - -def call_shell_with_timeout( - command: str, timeout: int, *, log_return_code: bool = True -) -> int: - """Run a shell command with a timeout. - - If log_return_code is set to False, it will not print an error if a non-zero - return code is returned. - """ - try: - _LOGGER.debug("Running command: %s", command) - subprocess.check_output( - command, - shell=True, # nosec # shell by design - timeout=timeout, - close_fds=False, # required for posix_spawn - ) - return 0 - except subprocess.CalledProcessError as proc_exception: - if log_return_code: - _LOGGER.error( - "Command failed (with return code %s): %s", - proc_exception.returncode, - command, - ) - return proc_exception.returncode - except subprocess.TimeoutExpired: - _LOGGER.error("Timeout for command: %s", command) - return -1 - except subprocess.SubprocessError: - _LOGGER.error("Error trying to exec command: %s", command) - return -1 - - -def check_output_or_log(command: str, timeout: int) -> str | None: - """Run a shell command with a timeout and return the output.""" - try: - return_value = subprocess.check_output( - command, - shell=True, # nosec # shell by design - timeout=timeout, - close_fds=False, # required for posix_spawn - ) - return return_value.strip().decode("utf-8") - except subprocess.CalledProcessError as err: - _LOGGER.error( - "Command failed (with return code %s): %s", err.returncode, command - ) - except subprocess.TimeoutExpired: - _LOGGER.error("Timeout for command: %s", command) - except subprocess.SubprocessError: - _LOGGER.error("Error trying to exec command: %s", command) - - return None diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 2e1ddb7a96..0c2edb8f19 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -25,10 +25,6 @@ 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.template import Template -from homeassistant.helpers.template_entity import ( - TEMPLATE_ENTITY_BASE_SCHEMA, - TemplateEntity, -) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS @@ -65,10 +61,6 @@ async def async_setup_platform( await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - binary_sensor_config = vol.Schema( - TEMPLATE_ENTITY_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA - )(config) - name: str = config.get(CONF_NAME, DEFAULT_NAME) command: str = config[CONF_COMMAND] payload_off: str = config[CONF_PAYLOAD_OFF] @@ -84,8 +76,6 @@ async def async_setup_platform( async_add_entities( [ CommandBinarySensor( - hass, - binary_sensor_config, data, name, device_class, @@ -99,13 +89,11 @@ async def async_setup_platform( ) -class CommandBinarySensor(TemplateEntity, BinarySensorEntity): +class CommandBinarySensor(BinarySensorEntity): """Representation of a command line binary sensor.""" def __init__( self, - hass: HomeAssistant, - config: ConfigType, data: CommandSensorData, name: str, device_class: BinarySensorDeviceClass | None, @@ -115,19 +103,14 @@ class CommandBinarySensor(TemplateEntity, BinarySensorEntity): unique_id: str | None, ) -> None: """Initialize the Command line binary sensor.""" - TemplateEntity.__init__( - self, - hass, - config=config, - fallback_name=name, - unique_id=unique_id, - ) 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 async def async_update(self) -> None: """Get the latest data and updates the state.""" @@ -135,9 +118,10 @@ class CommandBinarySensor(TemplateEntity, BinarySensorEntity): value = self.data.value if self._value_template is not None: - value = await self.hass.async_add_executor_job( - self._value_template.render_with_possible_json_value, value, False + value = self._value_template.async_render_with_possible_json_value( + value, None ) + self._attr_is_on = None if value == self._payload_on: self._attr_is_on = True elif value == self._payload_off: diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 53773ae4e9..e477affc85 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -22,14 +22,10 @@ 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.template import Template -from homeassistant.helpers.template_entity import ( - TEMPLATE_ENTITY_BASE_SCHEMA, - TemplateEntity, -) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import call_shell_with_timeout, check_output_or_log from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .utils import call_shell_with_timeout, check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -69,14 +65,8 @@ async def async_setup_platform( if value_template is not None: value_template.hass = hass - cover_config = vol.Schema( - TEMPLATE_ENTITY_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA - )(device_config) - covers.append( CommandCover( - hass, - cover_config, device_config.get(CONF_FRIENDLY_NAME, device_name), device_config[CONF_COMMAND_OPEN], device_config[CONF_COMMAND_CLOSE], @@ -95,13 +85,11 @@ async def async_setup_platform( async_add_entities(covers) -class CommandCover(TemplateEntity, CoverEntity): +class CommandCover(CoverEntity): """Representation a command line cover.""" def __init__( self, - hass: HomeAssistant, - config: ConfigType, name: str, command_open: str, command_close: str, @@ -112,13 +100,7 @@ class CommandCover(TemplateEntity, CoverEntity): unique_id: str | None, ) -> None: """Initialize the cover.""" - TemplateEntity.__init__( - self, - hass, - config=config, - fallback_name=name, - unique_id=unique_id, - ) + self._attr_name = name self._state: int | None = None self._command_open = command_open self._command_close = command_close @@ -126,6 +108,7 @@ class CommandCover(TemplateEntity, CoverEntity): 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) def _move_cover(self, command: str) -> bool: @@ -170,10 +153,12 @@ class CommandCover(TemplateEntity, CoverEntity): if self._command_state: payload = str(await self.hass.async_add_executor_job(self._query_state)) if self._value_template: - payload = await self.hass.async_add_executor_job( - self._value_template.render_with_possible_json_value, payload + payload = self._value_template.async_render_with_possible_json_value( + payload, None ) - self._state = int(payload) + self._state = None + if payload: + self._state = int(payload) def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 24224c12ca..b6a2b8d83f 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -22,7 +22,6 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError @@ -30,14 +29,10 @@ 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.template import Template -from homeassistant.helpers.template_entity import ( - TEMPLATE_SENSOR_BASE_SCHEMA, - TemplateSensor, -) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import check_output_or_log from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .utils import check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -72,10 +67,6 @@ async def async_setup_platform( await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - sensor_config = vol.Schema( - TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA - )(config) - name: str = config[CONF_NAME] command: str = config[CONF_COMMAND] unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT) @@ -90,8 +81,6 @@ async def async_setup_platform( async_add_entities( [ CommandSensor( - hass, - sensor_config, data, name, unit, @@ -104,13 +93,11 @@ async def async_setup_platform( ) -class CommandSensor(TemplateSensor, SensorEntity): +class CommandSensor(SensorEntity): """Representation of a sensor that is using shell commands.""" def __init__( self, - hass: HomeAssistant, - config: ConfigType, data: CommandSensorData, name: str, unit_of_measurement: str | None, @@ -119,18 +106,14 @@ class CommandSensor(TemplateSensor, SensorEntity): unique_id: str | None, ) -> None: """Initialize the sensor.""" - TemplateSensor.__init__( - self, - hass, - config=config, - fallback_name=name, - unique_id=unique_id, - ) + self._attr_name = name 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 async def async_update(self) -> None: """Get the latest data and updates the state.""" @@ -154,14 +137,16 @@ class CommandSensor(TemplateSensor, SensorEntity): _LOGGER.warning("Unable to parse output as JSON: %s", value) else: _LOGGER.warning("Empty reply found when expecting JSON data") + if self._value_template is None: + self._attr_native_value = None + return - if value is None: - value = STATE_UNKNOWN - elif self._value_template is not None: - self._attr_native_value = await self.hass.async_add_executor_job( - self._value_template.render_with_possible_json_value, - value, - STATE_UNKNOWN, + if self._value_template is not None: + self._attr_native_value = ( + self._value_template.async_render_with_possible_json_value( + value, + None, + ) ) else: self._attr_native_value = value diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 7142f14e82..3c344891fb 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -24,12 +24,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import setup_reload_service +from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import call_shell_with_timeout, check_output_or_log from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .utils import call_shell_with_timeout, check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -51,15 +51,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Find and return switches controlled by shell commands.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) devices: dict[str, Any] = config.get(CONF_SWITCHES, {}) switches = [] @@ -92,7 +92,7 @@ def setup_platform( _LOGGER.error("No switches added") return - add_entities(switches) + async_add_entities(switches) class CommandSwitch(SwitchEntity): @@ -123,11 +123,16 @@ class CommandSwitch(SwitchEntity): self._attr_unique_id = unique_id self._attr_should_poll = bool(command_state) - def _switch(self, command: str) -> bool: + async def _switch(self, command: str) -> bool: """Execute the actual commands.""" _LOGGER.info("Running command: %s", command) - success = call_shell_with_timeout(command, self._timeout) == 0 + success = ( + await self.hass.async_add_executor_job( + call_shell_with_timeout, command, self._timeout + ) + == 0 + ) if not success: _LOGGER.error("Command failed: %s", command) @@ -160,26 +165,30 @@ class CommandSwitch(SwitchEntity): if TYPE_CHECKING: return None - def update(self) -> None: + async def async_update(self) -> None: """Update device state.""" if self._command_state: - payload = str(self._query_state()) + payload = str(await self.hass.async_add_executor_job(self._query_state)) if self._icon_template: - self._attr_icon = self._icon_template.render_with_possible_json_value( - payload + self._attr_icon = ( + self._icon_template.async_render_with_possible_json_value(payload) ) if self._value_template: - payload = self._value_template.render_with_possible_json_value(payload) - self._attr_is_on = payload.lower() == "true" + payload = self._value_template.async_render_with_possible_json_value( + payload, None + ) + self._attr_is_on = None + if payload: + self._attr_is_on = payload.lower() == "true" - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - if self._switch(self._command_on) and not self._command_state: + if await self._switch(self._command_on) and not self._command_state: self._attr_is_on = True - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - if self._switch(self._command_off) and not self._command_state: + if await self._switch(self._command_off) and not self._command_state: self._attr_is_on = False - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py new file mode 100644 index 0000000000..2d42732190 --- /dev/null +++ b/homeassistant/components/command_line/utils.py @@ -0,0 +1,62 @@ +"""The command_line component utils.""" +from __future__ import annotations + +import logging +import subprocess + +_LOGGER = logging.getLogger(__name__) + + +def call_shell_with_timeout( + command: str, timeout: int, *, log_return_code: bool = True +) -> int: + """Run a shell command with a timeout. + + If log_return_code is set to False, it will not print an error if a non-zero + return code is returned. + """ + try: + _LOGGER.debug("Running command: %s", command) + subprocess.check_output( + command, + shell=True, # nosec # shell by design + timeout=timeout, + close_fds=False, # required for posix_spawn + ) + return 0 + except subprocess.CalledProcessError as proc_exception: + if log_return_code: + _LOGGER.error( + "Command failed (with return code %s): %s", + proc_exception.returncode, + command, + ) + return proc_exception.returncode + except subprocess.TimeoutExpired: + _LOGGER.error("Timeout for command: %s", command) + return -1 + except subprocess.SubprocessError: + _LOGGER.error("Error trying to exec command: %s", command) + return -1 + + +def check_output_or_log(command: str, timeout: int) -> str | None: + """Run a shell command with a timeout and return the output.""" + try: + return_value = subprocess.check_output( + command, + shell=True, # nosec # shell by design + timeout=timeout, + close_fds=False, # required for posix_spawn + ) + return return_value.strip().decode("utf-8") + except subprocess.CalledProcessError as err: + _LOGGER.error( + "Command failed (with return code %s): %s", err.returncode, command + ) + except subprocess.TimeoutExpired: + _LOGGER.error("Timeout for command: %s", command) + except subprocess.SubprocessError: + _LOGGER.error("Error trying to exec command: %s", command) + + return None diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 88715ab876..c6fd400315 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -535,8 +535,8 @@ def entry_json(entry: config_entries.ConfigEntry) -> dict: "source": entry.source, "state": entry.state.value, "supports_options": supports_options, - "supports_remove_device": entry.supports_remove_device, - "supports_unload": entry.supports_unload, + "supports_remove_device": entry.supports_remove_device or False, + "supports_unload": entry.supports_unload or False, "pref_disable_new_entities": entry.pref_disable_new_entities, "pref_disable_polling": entry.pref_disable_polling, "disabled_by": entry.disabled_by, diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index c99af1f89c..de4c8208ee 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -35,7 +35,6 @@ from .const import ( CONF_DIRECTOR_ALL_ITEMS, CONF_DIRECTOR_MODEL, CONF_DIRECTOR_SW_VERSION, - CONF_DIRECTOR_TOKEN_EXPIRATION, DEFAULT_SCAN_INTERVAL, DOMAIN, ) @@ -79,7 +78,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session ) entry_data[CONF_DIRECTOR] = director - entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] = director_token_dict["token_expiration"] # Add Control4 controller to device registry controller_href = (await account.getAccountControllers())["href"] diff --git a/homeassistant/components/control4/const.py b/homeassistant/components/control4/const.py index 2759088198..677610a161 100644 --- a/homeassistant/components/control4/const.py +++ b/homeassistant/components/control4/const.py @@ -7,7 +7,6 @@ MIN_SCAN_INTERVAL = 1 CONF_ACCOUNT = "account" CONF_DIRECTOR = "director" -CONF_DIRECTOR_TOKEN_EXPIRATION = "director_token_expiry" CONF_DIRECTOR_SW_VERSION = "director_sw_version" CONF_DIRECTOR_MODEL = "director_model" CONF_DIRECTOR_ALL_ITEMS = "director_all_items" diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py index fc4ca9e358..3d360e3643 100644 --- a/homeassistant/components/control4/director_utils.py +++ b/homeassistant/components/control4/director_utils.py @@ -1,5 +1,8 @@ """Provides data updates from the Control4 controller for platforms.""" +from collections import defaultdict +from collections.abc import Set import logging +from typing import Any from pyControl4.account import C4Account from pyControl4.director import C4Director @@ -10,32 +13,33 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERN from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import ( - CONF_ACCOUNT, - CONF_CONTROLLER_UNIQUE_ID, - CONF_DIRECTOR, - CONF_DIRECTOR_TOKEN_EXPIRATION, - DOMAIN, -) +from .const import CONF_ACCOUNT, CONF_CONTROLLER_UNIQUE_ID, CONF_DIRECTOR, DOMAIN _LOGGER = logging.getLogger(__name__) -async def director_update_data( - hass: HomeAssistant, entry: ConfigEntry, var: str -) -> dict: - """Retrieve data from the Control4 director for update_coordinator.""" - # possibly implement usage of director_token_expiration to start - # token refresh without waiting for error to occur +async def _update_variables_for_config_entry( + hass: HomeAssistant, entry: ConfigEntry, variable_names: Set[str] +) -> dict[int, dict[str, Any]]: + """Retrieve data from the Control4 director.""" + director: C4Director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] + data = await director.getAllItemVariableValue(variable_names) + result_dict: defaultdict[int, dict[str, Any]] = defaultdict(dict) + for item in data: + result_dict[item["id"]][item["varName"]] = item["value"] + return dict(result_dict) + + +async def update_variables_for_config_entry( + hass: HomeAssistant, entry: ConfigEntry, variable_names: Set[str] +) -> dict[int, dict[str, Any]]: + """Try to Retrieve data from the Control4 director for update_coordinator.""" try: - director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] - data = await director.getAllItemVariableValue(var) + return await _update_variables_for_config_entry(hass, entry, variable_names) except BadToken: _LOGGER.info("Updating Control4 director token") await refresh_tokens(hass, entry) - director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] - data = await director.getAllItemVariableValue(var) - return {key["id"]: key for key in data} + return await _update_variables_for_config_entry(hass, entry, variable_names) async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): @@ -53,10 +57,8 @@ async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): director = C4Director( config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session ) - director_token_expiry = director_token_dict["token_expiration"] _LOGGER.debug("Saving new tokens in hass data") entry_data = hass.data[DOMAIN][entry.entry_id] entry_data[CONF_ACCOUNT] = account entry_data[CONF_DIRECTOR] = director - entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] = director_token_expiry diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index 2c92010901..fde9b00aba 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -24,7 +24,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from . import Control4Entity, get_items_of_category from .const import CONF_DIRECTOR, CONTROL4_ENTITY_TYPE, DOMAIN -from .director_utils import director_update_data +from .director_utils import update_variables_for_config_entry _LOGGER = logging.getLogger(__name__) @@ -47,14 +47,18 @@ async def async_setup_entry( async def async_update_data_non_dimmer(): """Fetch data from Control4 director for non-dimmer lights.""" try: - return await director_update_data(hass, entry, CONTROL4_NON_DIMMER_VAR) + return await update_variables_for_config_entry( + hass, entry, {CONTROL4_NON_DIMMER_VAR} + ) except C4Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err async def async_update_data_dimmer(): """Fetch data from Control4 director for dimmer lights.""" try: - return await director_update_data(hass, entry, CONTROL4_DIMMER_VAR) + return await update_variables_for_config_entry( + hass, entry, {CONTROL4_DIMMER_VAR} + ) except C4Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err @@ -175,7 +179,7 @@ class Control4Light(Control4Entity, LightEntity): self._attr_color_mode = ColorMode.ONOFF self._attr_supported_color_modes = {ColorMode.ONOFF} - def create_api_object(self): + def _create_api_object(self): """Create a pyControl4 device object. This exists so the director token used is always the latest one, without needing to re-init the entire entity. @@ -185,13 +189,15 @@ class Control4Light(Control4Entity, LightEntity): @property def is_on(self): """Return whether this light is on or off.""" - return self.coordinator.data[self._idx]["value"] > 0 + if self._is_dimmer: + return self.coordinator.data[self._idx][CONTROL4_DIMMER_VAR] > 0 + return self.coordinator.data[self._idx][CONTROL4_NON_DIMMER_VAR] > 0 @property def brightness(self): """Return the brightness of this light between 0..255.""" if self._is_dimmer: - return round(self.coordinator.data[self._idx]["value"] * 2.55) + return round(self.coordinator.data[self._idx][CONTROL4_DIMMER_VAR] * 2.55) return None @property @@ -203,7 +209,7 @@ class Control4Light(Control4Entity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - c4_light = self.create_api_object() + c4_light = self._create_api_object() if self._is_dimmer: if ATTR_TRANSITION in kwargs: transition_length = kwargs[ATTR_TRANSITION] * 1000 @@ -226,7 +232,7 @@ class Control4Light(Control4Entity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - c4_light = self.create_api_object() + c4_light = self._create_api_object() if self._is_dimmer: if ATTR_TRANSITION in kwargs: transition_length = kwargs[ATTR_TRANSITION] * 1000 diff --git a/homeassistant/components/control4/manifest.json b/homeassistant/components/control4/manifest.json index 125e3c2e38..765f0dce78 100644 --- a/homeassistant/components/control4/manifest.json +++ b/homeassistant/components/control4/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/control4", "iot_class": "local_polling", "loggers": ["pyControl4"], - "requirements": ["pyControl4==0.0.6"], + "requirements": ["pyControl4==1.1.0"], "ssdp": [ { "st": "c4:director" diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index e2e00a2652..2796e51c27 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -2,9 +2,11 @@ from __future__ import annotations import asyncio +from collections.abc import Iterable +from dataclasses import dataclass import logging import re -from typing import Any +from typing import Any, Literal import voluptuous as vol @@ -12,14 +14,27 @@ from homeassistant import core from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.config_entries import ConfigEntry +from homeassistant.const import MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, intent, singleton from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from homeassistant.util import language as language_util from .agent import AbstractConversationAgent, ConversationInput, ConversationResult +from .const import HOME_ASSISTANT_AGENT from .default_agent import DefaultAgent +__all__ = [ + "DOMAIN", + "HOME_ASSISTANT_AGENT", + "async_converse", + "async_get_agent_info", + "async_set_agent", + "async_unset_agent", + "async_setup", +] + _LOGGER = logging.getLogger(__name__) ATTR_TEXT = "text" @@ -102,6 +117,34 @@ def async_unset_agent( _get_agent_manager(hass).async_unset_agent(config_entry.entry_id) +async def async_get_conversation_languages( + hass: HomeAssistant, agent_id: str | None = None +) -> set[str] | Literal["*"]: + """Return languages supported by conversation agents. + + If an agent is specified, returns a set of languages supported by that agent. + If no agent is specified, return a set with the union of languages supported by + all conversation agents. + """ + agent_manager = _get_agent_manager(hass) + languages = set() + + agent_ids: Iterable[str] + if agent_id is None: + agent_ids = iter(info.id for info in agent_manager.async_get_agent_info()) + else: + agent_ids = (agent_id,) + + for _agent_id in agent_ids: + agent = await agent_manager.async_get_agent(_agent_id) + if agent.supported_languages == MATCH_ALL: + return MATCH_ALL + for language_tag in agent.supported_languages: + languages.add(language_tag) + + return languages + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the process service.""" agent_manager = _get_agent_manager(hass) @@ -218,24 +261,38 @@ async def websocket_get_agent_info( @websocket_api.websocket_command( { vol.Required("type"): "conversation/agent/list", + vol.Optional("language"): str, + vol.Optional("country"): str, } ) -@core.callback -def websocket_list_agents( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], +@websocket_api.async_response +async def websocket_list_agents( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: - """List available agents.""" + """List conversation agents and, optionally, if they support a given language.""" manager = _get_agent_manager(hass) - connection.send_result( - msg["id"], - { - "default_agent": manager.default_agent, - "agents": manager.async_get_agent_info(), - }, - ) + country = msg.get("country") + language = msg.get("language") + agents = [] + + for agent_info in manager.async_get_agent_info(): + agent = await manager.async_get_agent(agent_info.id) + + supported_languages = agent.supported_languages + if language and supported_languages != MATCH_ALL: + supported_languages = language_util.matches( + language, supported_languages, country + ) + + agent_dict: dict[str, Any] = { + "id": agent_info.id, + "name": agent_info.name, + "supported_languages": supported_languages, + } + agents.append(agent_dict) + + connection.send_message(websocket_api.result_message(msg["id"], {"agents": agents})) class ConversationProcessView(http.HomeAssistantView): @@ -270,6 +327,32 @@ class ConversationProcessView(http.HomeAssistantView): return self.json(result.as_dict()) +@dataclass(frozen=True) +class AgentInfo: + """Container for conversation agent info.""" + + id: str + name: str + + +@core.callback +def async_get_agent_info( + hass: core.HomeAssistant, + agent_id: str | None = None, +) -> AgentInfo | None: + """Get information on the agent or None if not found.""" + manager = _get_agent_manager(hass) + + if agent_id is None: + agent_id = manager.default_agent + + for agent_info in manager.async_get_agent_info(): + if agent_info.id == agent_id: + return agent_info + + return None + + async def async_converse( hass: core.HomeAssistant, text: str, @@ -299,8 +382,6 @@ async def async_converse( class AgentManager: """Class to manage conversation agents.""" - HOME_ASSISTANT_AGENT = "homeassistant" - default_agent: str = HOME_ASSISTANT_AGENT _builtin_agent: AbstractConversationAgent | None = None @@ -317,7 +398,7 @@ class AgentManager: if agent_id is None: agent_id = self.default_agent - if agent_id == AgentManager.HOME_ASSISTANT_AGENT: + if agent_id == HOME_ASSISTANT_AGENT: if self._builtin_agent is not None: return self._builtin_agent @@ -332,50 +413,55 @@ class AgentManager: return self._builtin_agent + if agent_id not in self._agents: + raise ValueError(f"Agent {agent_id} not found") + return self._agents[agent_id] @core.callback - def async_get_agent_info(self) -> list[dict[str, Any]]: + def async_get_agent_info(self) -> list[AgentInfo]: """List all agents.""" - agents = [ - { - "id": AgentManager.HOME_ASSISTANT_AGENT, - "name": "Home Assistant", - } + agents: list[AgentInfo] = [ + AgentInfo( + id=HOME_ASSISTANT_AGENT, + name="Home Assistant", + ) ] for agent_id, agent in self._agents.items(): config_entry = self.hass.config_entries.async_get_entry(agent_id) - # This is a bug, agent should have been unset when config entry was unloaded + # Guard against potential bugs in conversation agents where the agent is not + # removed from the manager when the config entry is removed if config_entry is None: _LOGGER.warning( - "Agent was still loaded while config entry is gone: %s", agent + "Conversation agent %s is still loaded after config entry removal", + agent, ) continue agents.append( - { - "id": agent_id, - "name": config_entry.title, - } + AgentInfo( + id=agent_id, + name=config_entry.title or config_entry.domain, + ) ) return agents @core.callback def async_is_valid_agent_id(self, agent_id: str) -> bool: """Check if the agent id is valid.""" - return agent_id in self._agents or agent_id == AgentManager.HOME_ASSISTANT_AGENT + return agent_id in self._agents or agent_id == HOME_ASSISTANT_AGENT @core.callback def async_set_agent(self, agent_id: str, agent: AbstractConversationAgent) -> None: """Set the agent.""" self._agents[agent_id] = agent - if self.default_agent == AgentManager.HOME_ASSISTANT_AGENT: + if self.default_agent == HOME_ASSISTANT_AGENT: self.default_agent = agent_id @core.callback def async_unset_agent(self, agent_id: str) -> None: """Unset the agent.""" if self.default_agent == agent_id: - self.default_agent = AgentManager.HOME_ASSISTANT_AGENT + self.default_agent = HOME_ASSISTANT_AGENT self._agents.pop(agent_id, None) diff --git a/homeassistant/components/conversation/agent.py b/homeassistant/components/conversation/agent.py index 2b2c307f82..162338a6ff 100644 --- a/homeassistant/components/conversation/agent.py +++ b/homeassistant/components/conversation/agent.py @@ -3,13 +3,13 @@ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, TypedDict +from typing import Any, Literal, TypedDict from homeassistant.core import Context from homeassistant.helpers import intent -@dataclass +@dataclass(slots=True) class ConversationInput: """User input to be processed.""" @@ -19,7 +19,7 @@ class ConversationInput: language: str -@dataclass +@dataclass(slots=True) class ConversationResult: """Result of async_process.""" @@ -49,6 +49,11 @@ class AbstractConversationAgent(ABC): """Return the attribution.""" return None + @property + @abstractmethod + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + @abstractmethod async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index 1cae975c95..7ba12ec830 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -1,21 +1,4 @@ """Const for conversation integration.""" DOMAIN = "conversation" - -DEFAULT_EXPOSED_DOMAINS = { - "binary_sensor", - "climate", - "cover", - "fan", - "humidifier", - "light", - "lock", - "scene", - "script", - "sensor", - "switch", - "vacuum", - "water_heater", -} - -DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} +HOME_ASSISTANT_AGENT = "homeassistant" diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 49569f66ac..b3a66d8030 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -13,10 +13,15 @@ from typing import IO, Any from hassil.intents import Intents, ResponseType, SlotList, TextSlotList from hassil.recognize import RecognizeResult, recognize_all from hassil.util import merge_dict -from home_assistant_intents import get_intents +from home_assistant_intents import get_domains_and_languages, get_intents import yaml from homeassistant import core, setup +from homeassistant.components.homeassistant.exposed_entities import ( + async_listen_entity_updates, + async_should_expose, +) +from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -28,25 +33,21 @@ from homeassistant.helpers import ( from homeassistant.util.json import JsonObjectType, json_loads_object from .agent import AbstractConversationAgent, ConversationInput, ConversationResult -from .const import DEFAULT_EXPOSED_ATTRIBUTES, DEFAULT_EXPOSED_DOMAINS, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" +_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] REGEX_TYPE = type(re.compile("")) -def is_entity_exposed(state: core.State) -> bool: - """Return true if entity belongs to exposed domain list.""" - return state.domain in DEFAULT_EXPOSED_DOMAINS - - def json_load(fp: IO[str]) -> JsonObjectType: """Wrap json_loads for get_intents.""" return json_loads_object(fp.read()) -@dataclass +@dataclass(slots=True) class LanguageIntents: """Loaded intents for a language.""" @@ -85,6 +86,11 @@ class DefaultAgent(AbstractConversationAgent): self._config_intents: dict[str, Any] = {} self._slot_lists: dict[str, SlotList] | None = None + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return get_domains_and_languages()["homeassistant"] + async def async_initialize(self, config_intents): """Initialize the default agent.""" if "intent" not in self.hass.config.components: @@ -104,10 +110,8 @@ class DefaultAgent(AbstractConversationAgent): self._async_handle_entity_registry_changed, run_immediately=True, ) - self.hass.bus.async_listen( - core.EVENT_STATE_CHANGED, - self._async_handle_state_changed, - run_immediately=True, + async_listen_entity_updates( + self.hass, DOMAIN, self._async_exposed_entities_updated ) async def async_process(self, user_input: ConversationInput) -> ConversationResult: @@ -450,16 +454,16 @@ class DefaultAgent(AbstractConversationAgent): @core.callback def _async_handle_entity_registry_changed(self, event: core.Event) -> None: - """Clear names list cache when an entity changes aliases.""" - if event.data["action"] == "update" and "aliases" not in event.data["changes"]: + """Clear names list cache when an entity registry entry has changed.""" + if event.data["action"] == "update" and not any( + field in event.data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS + ): return self._slot_lists = None @core.callback - def _async_handle_state_changed(self, event: core.Event) -> None: - """Clear names list cache when a state is added or removed from the state machine.""" - if event.data.get("old_state") and event.data.get("new_state"): - return + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" self._slot_lists = None def _make_slot_lists(self) -> dict[str, SlotList]: @@ -468,48 +472,40 @@ class DefaultAgent(AbstractConversationAgent): return self._slot_lists area_ids_with_entities: set[str] = set() - states = [ - state for state in self.hass.states.async_all() if is_entity_exposed(state) + all_entities = er.async_get(self.hass) + entities = [ + entity + for entity in all_entities.entities.values() + if async_should_expose(self.hass, DOMAIN, entity.entity_id) ] - entities = er.async_get(self.hass) devices = dr.async_get(self.hass) # Gather exposed entity names entity_names = [] - for state in states: + for entity in entities: # Checked against "requires_context" and "excludes_context" in hassil - context = {"domain": state.domain} - if state.attributes: - # Include some attributes - for attr_key, attr_value in state.attributes.items(): - if attr_key not in DEFAULT_EXPOSED_ATTRIBUTES: - continue - context[attr_key] = attr_value + context = {"domain": entity.domain} + if entity.device_class: + context[ATTR_DEVICE_CLASS] = entity.device_class - entity = entities.async_get(state.entity_id) - if entity is not None: - if entity.entity_category or entity.hidden: - # Skip configuration/diagnostic/hidden entities - continue + if entity.aliases: + for alias in entity.aliases: + entity_names.append((alias, alias, context)) - if entity.aliases: - for alias in entity.aliases: - entity_names.append((alias, alias, context)) + # Default name + name = entity.async_friendly_name(self.hass) or entity.entity_id.replace( + "_", " " + ) + entity_names.append((name, name, context)) - # Default name - entity_names.append((state.name, state.name, context)) - - if entity.area_id: - # Expose area too - area_ids_with_entities.add(entity.area_id) - elif entity.device_id: - # Check device for area as well - device = devices.async_get(entity.device_id) - if (device is not None) and device.area_id: - area_ids_with_entities.add(device.area_id) - else: - # Default name - entity_names.append((state.name, state.name, context)) + if entity.area_id: + # Expose area too + area_ids_with_entities.add(entity.area_id) + elif entity.device_id: + # Check device for area as well + device = devices.async_get(entity.device_id) + if (device is not None) and device.area_id: + area_ids_with_entities.add(device.area_id) # Gather areas from exposed entities areas = ar.async_get(self.hass) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 7630eed01f..9b1f111d55 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -2,10 +2,10 @@ "domain": "conversation", "name": "Conversation", "codeowners": ["@home-assistant/core", "@synesthesiam"], - "dependencies": ["http"], + "dependencies": ["homeassistant", "http"], "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.2.28"] + "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.4.17-1"] } diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py deleted file mode 100644 index a3bc07ee0a..0000000000 --- a/homeassistant/components/coronavirus/__init__.py +++ /dev/null @@ -1,88 +0,0 @@ -"""The Coronavirus integration.""" -from datetime import timedelta -import logging - -import async_timeout -import coronavirus - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - aiohttp_client, - entity_registry as er, - update_coordinator, -) -from homeassistant.helpers.typing import ConfigType - -from .const import DOMAIN - -PLATFORMS = [Platform.SENSOR] - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Coronavirus component.""" - # Make sure coordinator is initialized. - await get_coordinator(hass) - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Coronavirus from a config entry.""" - if isinstance(entry.data["country"], int): - hass.config_entries.async_update_entry( - entry, data={**entry.data, "country": entry.title} - ) - - @callback - def _async_migrator(entity_entry: er.RegistryEntry): - """Migrate away from unstable ID.""" - country, info_type = entity_entry.unique_id.rsplit("-", 1) - if not country.isnumeric(): - return None - return {"new_unique_id": f"{entry.title}-{info_type}"} - - await er.async_migrate_entries(hass, entry.entry_id, _async_migrator) - - if not entry.unique_id: - hass.config_entries.async_update_entry(entry, unique_id=entry.data["country"]) - - coordinator = await get_coordinator(hass) - if not coordinator.last_update_success: - await coordinator.async_config_entry_first_refresh() - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def get_coordinator( - hass: HomeAssistant, -) -> update_coordinator.DataUpdateCoordinator: - """Get the data update coordinator.""" - if DOMAIN in hass.data: - return hass.data[DOMAIN] - - async def async_get_cases(): - async with async_timeout.timeout(10): - return { - case.country: case - for case in await coronavirus.get_cases( - aiohttp_client.async_get_clientsession(hass) - ) - } - - hass.data[DOMAIN] = update_coordinator.DataUpdateCoordinator( - hass, - logging.getLogger(__name__), - name=DOMAIN, - update_method=async_get_cases, - update_interval=timedelta(hours=1), - ) - await hass.data[DOMAIN].async_refresh() - return hass.data[DOMAIN] diff --git a/homeassistant/components/coronavirus/config_flow.py b/homeassistant/components/coronavirus/config_flow.py deleted file mode 100644 index 81e4f06f57..0000000000 --- a/homeassistant/components/coronavirus/config_flow.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Config flow for Coronavirus integration.""" -from __future__ import annotations - -from typing import Any - -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.data_entry_flow import FlowResult - -from . import get_coordinator -from .const import DOMAIN, OPTION_WORLDWIDE - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Coronavirus.""" - - VERSION = 1 - - _options: dict[str, Any] | None = None - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - errors: dict[str, str] = {} - - if self._options is None: - coordinator = await get_coordinator(self.hass) - if not coordinator.last_update_success or coordinator.data is None: - return self.async_abort(reason="cannot_connect") - - self._options = {OPTION_WORLDWIDE: "Worldwide"} - for case in sorted( - coordinator.data.values(), key=lambda case: case.country - ): - self._options[case.country] = case.country - - if user_input is not None: - await self.async_set_unique_id(user_input["country"]) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=self._options[user_input["country"]], data=user_input - ) - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema({vol.Required("country"): vol.In(self._options)}), - errors=errors, - ) diff --git a/homeassistant/components/coronavirus/const.py b/homeassistant/components/coronavirus/const.py deleted file mode 100644 index e1ffa64e88..0000000000 --- a/homeassistant/components/coronavirus/const.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Constants for the Coronavirus integration.""" -from coronavirus import DEFAULT_SOURCE - -DOMAIN = "coronavirus" -OPTION_WORLDWIDE = "__worldwide" -ATTRIBUTION = f"Data provided by {DEFAULT_SOURCE.NAME}" diff --git a/homeassistant/components/coronavirus/manifest.json b/homeassistant/components/coronavirus/manifest.json deleted file mode 100644 index a053b4056c..0000000000 --- a/homeassistant/components/coronavirus/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "coronavirus", - "name": "Coronavirus (COVID-19)", - "codeowners": ["@home-assistant/core"], - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/coronavirus", - "iot_class": "cloud_polling", - "loggers": ["coronavirus"], - "requirements": ["coronavirus==1.1.1"] -} diff --git a/homeassistant/components/coronavirus/sensor.py b/homeassistant/components/coronavirus/sensor.py deleted file mode 100644 index 7fa7c5aed0..0000000000 --- a/homeassistant/components/coronavirus/sensor.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Sensor platform for the Corona virus.""" -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from . import get_coordinator -from .const import ATTRIBUTION, OPTION_WORLDWIDE - -SENSORS = { - "confirmed": "mdi:emoticon-neutral-outline", - "current": "mdi:emoticon-sad-outline", - "recovered": "mdi:emoticon-happy-outline", - "deaths": "mdi:emoticon-cry-outline", -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Defer sensor setup to the shared sensor module.""" - coordinator = await get_coordinator(hass) - - async_add_entities( - CoronavirusSensor(coordinator, config_entry.data["country"], info_type) - for info_type in SENSORS - ) - - -class CoronavirusSensor(CoordinatorEntity, SensorEntity): - """Sensor representing corona virus data.""" - - _attr_attribution = ATTRIBUTION - _attr_native_unit_of_measurement = "people" - - def __init__(self, coordinator, country, info_type): - """Initialize coronavirus sensor.""" - super().__init__(coordinator) - self._attr_icon = SENSORS[info_type] - self._attr_unique_id = f"{country}-{info_type}" - if country == OPTION_WORLDWIDE: - self._attr_name = f"Worldwide Coronavirus {info_type}" - else: - self._attr_name = ( - f"{coordinator.data[country].country} Coronavirus {info_type}" - ) - - self.country = country - self.info_type = info_type - - @property - def available(self) -> bool: - """Return if sensor is available.""" - return self.coordinator.last_update_success and ( - self.country in self.coordinator.data or self.country == OPTION_WORLDWIDE - ) - - @property - def native_value(self): - """State of the sensor.""" - if self.country == OPTION_WORLDWIDE: - sum_cases = 0 - for case in self.coordinator.data.values(): - if (value := getattr(case, self.info_type)) is None: - continue - sum_cases += value - - return sum_cases - - return getattr(self.coordinator.data[self.country], self.info_type) diff --git a/homeassistant/components/coronavirus/strings.json b/homeassistant/components/coronavirus/strings.json deleted file mode 100644 index e0b29d6c8d..0000000000 --- a/homeassistant/components/coronavirus/strings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Pick a country to monitor", - "data": { "country": "Country" } - } - }, - "abort": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" - } - } -} diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 30238073b1..768491f608 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -106,7 +106,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = CounterStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -118,7 +117,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) @@ -140,7 +139,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class CounterStorageCollection(collection.StorageCollection): +class CounterStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -154,10 +153,10 @@ class CounterStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class Counter(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index fb7d34edf4..548d155408 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -5,7 +5,7 @@ "name": "[%key:component::counter::title%]", "state_attributes": { "editable": { - "name": "UI-managed", + "name": "[%key:common::generic::ui_managed%]", "state": { "true": "[%key:common::state::yes%]", "false": "[%key:common::state::no%]" diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index 9b2bb05bb0..dd22821d5e 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -82,21 +82,19 @@ async def async_get_actions( if supported_features & SUPPORT_SET_POSITION: actions.append({**base_action, CONF_TYPE: "set_position"}) - else: - if supported_features & SUPPORT_OPEN: - actions.append({**base_action, CONF_TYPE: "open"}) - if supported_features & SUPPORT_CLOSE: - actions.append({**base_action, CONF_TYPE: "close"}) - if supported_features & SUPPORT_STOP: - actions.append({**base_action, CONF_TYPE: "stop"}) + if supported_features & SUPPORT_OPEN: + actions.append({**base_action, CONF_TYPE: "open"}) + if supported_features & SUPPORT_CLOSE: + actions.append({**base_action, CONF_TYPE: "close"}) + if supported_features & SUPPORT_STOP: + actions.append({**base_action, CONF_TYPE: "stop"}) if supported_features & SUPPORT_SET_TILT_POSITION: actions.append({**base_action, CONF_TYPE: "set_tilt_position"}) - else: - if supported_features & SUPPORT_OPEN_TILT: - actions.append({**base_action, CONF_TYPE: "open_tilt"}) - if supported_features & SUPPORT_CLOSE_TILT: - actions.append({**base_action, CONF_TYPE: "close_tilt"}) + if supported_features & SUPPORT_OPEN_TILT: + actions.append({**base_action, CONF_TYPE: "open_tilt"}) + if supported_features & SUPPORT_CLOSE_TILT: + actions.append({**base_action, CONF_TYPE: "close_tilt"}) return actions diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index 9905228c26..b4a3339289 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -20,7 +20,6 @@ _RESOURCE = "http://apilayer.net/api/live" DEFAULT_BASE = "USD" DEFAULT_NAME = "CurrencyLayer Sensor" -ICON = "mdi:currency" SCAN_INTERVAL = timedelta(hours=4) @@ -60,6 +59,7 @@ class CurrencylayerSensor(SensorEntity): """Implementing the Currencylayer sensor.""" _attr_attribution = "Data provided by currencylayer.com" + _attr_icon = "mdi:currency" def __init__(self, rest, base, quote): """Initialize the sensor.""" @@ -78,11 +78,6 @@ class CurrencylayerSensor(SensorEntity): """Return the name of the sensor.""" return self._base - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def native_value(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json index 5c75938479..7848949831 100644 --- a/homeassistant/components/daikin/strings.json +++ b/homeassistant/components/daikin/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Configure Daikin AC", - "description": "Enter [%key:common::config_flow::data::ip%] of your Daikin AC.\n\nNote that [%key:common::config_flow::data::api_key%] and [%key:common::config_flow::data::password%] only are used by BRP072Cxx and SKYFi devices respectively.", + "description": "Enter the IP address of your Daikin AC.\n\nNote that API key and password are only used by BRP072Cxx and SKYFi devices respectively.", "data": { "host": "[%key:common::config_flow::data::host%]", "api_key": "[%key:common::config_flow::data::api_key%]", @@ -18,7 +18,7 @@ "error": { "unknown": "[%key:common::config_flow::error::unknown%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "api_password": "[%key:common::config_flow::error::invalid_auth%], use either API Key or Password.", + "api_password": "Invalid authentication, use either API Key or Password.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } } diff --git a/homeassistant/components/darksky/__init__.py b/homeassistant/components/darksky/__init__.py deleted file mode 100644 index 90a5d06dc0..0000000000 --- a/homeassistant/components/darksky/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The darksky component.""" diff --git a/homeassistant/components/darksky/manifest.json b/homeassistant/components/darksky/manifest.json deleted file mode 100644 index 6ff20b11f2..0000000000 --- a/homeassistant/components/darksky/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "darksky", - "name": "Dark Sky", - "codeowners": ["@fabaff"], - "documentation": "https://www.home-assistant.io/integrations/darksky", - "iot_class": "cloud_polling", - "loggers": ["forecastio"], - "requirements": ["python-forecastio==1.4.0"] -} diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py deleted file mode 100644 index ef1ef0466d..0000000000 --- a/homeassistant/components/darksky/sensor.py +++ /dev/null @@ -1,927 +0,0 @@ -"""Support for Dark Sky weather service.""" -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import timedelta -import logging -from typing import Literal, NamedTuple - -import forecastio -from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import ( - CONF_API_KEY, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_MONITORED_CONDITIONS, - CONF_NAME, - CONF_SCAN_INTERVAL, - DEGREE, - PERCENTAGE, - UV_INDEX, - UnitOfLength, - UnitOfPrecipitationDepth, - UnitOfPressure, - UnitOfSpeed, - UnitOfTemperature, - UnitOfVolumetricFlux, -) -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -from homeassistant.util.unit_system import METRIC_SYSTEM - -_LOGGER = logging.getLogger(__name__) - -CONF_FORECAST = "forecast" -CONF_HOURLY_FORECAST = "hourly_forecast" -CONF_LANGUAGE = "language" -CONF_UNITS = "units" - -DEFAULT_LANGUAGE = "en" -DEFAULT_NAME = "Dark Sky" -SCAN_INTERVAL = timedelta(seconds=300) - -DEPRECATED_SENSOR_TYPES = { - "apparent_temperature_max", - "apparent_temperature_min", - "temperature_max", - "temperature_min", -} - -MAP_UNIT_SYSTEM: dict[ - Literal["si", "us", "ca", "uk", "uk2"], - Literal["si_unit", "us_unit", "ca_unit", "uk_unit", "uk2_unit"], -] = { - "si": "si_unit", - "us": "us_unit", - "ca": "ca_unit", - "uk": "uk_unit", - "uk2": "uk2_unit", -} - - -@dataclass -class DarkskySensorEntityDescription(SensorEntityDescription): - """Describes Darksky sensor entity.""" - - si_unit: str | None = None - us_unit: str | None = None - ca_unit: str | None = None - uk_unit: str | None = None - uk2_unit: str | None = None - forecast_mode: list[str] = field(default_factory=list) - - -SENSOR_TYPES: dict[str, DarkskySensorEntityDescription] = { - "summary": DarkskySensorEntityDescription( - key="summary", - name="Summary", - forecast_mode=["currently", "hourly", "daily"], - ), - "minutely_summary": DarkskySensorEntityDescription( - key="minutely_summary", - name="Minutely Summary", - forecast_mode=[], - ), - "hourly_summary": DarkskySensorEntityDescription( - key="hourly_summary", - name="Hourly Summary", - forecast_mode=[], - ), - "daily_summary": DarkskySensorEntityDescription( - key="daily_summary", - name="Daily Summary", - forecast_mode=[], - ), - "icon": DarkskySensorEntityDescription( - key="icon", - name="Icon", - forecast_mode=["currently", "hourly", "daily"], - ), - "nearest_storm_distance": DarkskySensorEntityDescription( - key="nearest_storm_distance", - name="Nearest Storm Distance", - si_unit=UnitOfLength.KILOMETERS, - us_unit=UnitOfLength.MILES, - ca_unit=UnitOfLength.KILOMETERS, - uk_unit=UnitOfLength.KILOMETERS, - uk2_unit=UnitOfLength.MILES, - icon="mdi:weather-lightning", - forecast_mode=["currently"], - ), - "nearest_storm_bearing": DarkskySensorEntityDescription( - key="nearest_storm_bearing", - name="Nearest Storm Bearing", - si_unit=DEGREE, - us_unit=DEGREE, - ca_unit=DEGREE, - uk_unit=DEGREE, - uk2_unit=DEGREE, - icon="mdi:weather-lightning", - forecast_mode=["currently"], - ), - "precip_type": DarkskySensorEntityDescription( - key="precip_type", - name="Precip", - icon="mdi:weather-pouring", - forecast_mode=["currently", "minutely", "hourly", "daily"], - ), - "precip_intensity": DarkskySensorEntityDescription( - key="precip_intensity", - name="Precip Intensity", - si_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - us_unit=UnitOfVolumetricFlux.INCHES_PER_HOUR, - ca_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - uk_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - uk2_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - icon="mdi:weather-rainy", - forecast_mode=["currently", "minutely", "hourly", "daily"], - ), - "precip_probability": DarkskySensorEntityDescription( - key="precip_probability", - name="Precip Probability", - si_unit=PERCENTAGE, - us_unit=PERCENTAGE, - ca_unit=PERCENTAGE, - uk_unit=PERCENTAGE, - uk2_unit=PERCENTAGE, - icon="mdi:water-percent", - forecast_mode=["currently", "minutely", "hourly", "daily"], - ), - "precip_accumulation": DarkskySensorEntityDescription( - key="precip_accumulation", - name="Precip Accumulation", - device_class=SensorDeviceClass.PRECIPITATION, - si_unit=UnitOfPrecipitationDepth.CENTIMETERS, - us_unit=UnitOfPrecipitationDepth.INCHES, - ca_unit=UnitOfPrecipitationDepth.CENTIMETERS, - uk_unit=UnitOfPrecipitationDepth.CENTIMETERS, - uk2_unit=UnitOfPrecipitationDepth.CENTIMETERS, - icon="mdi:weather-snowy", - forecast_mode=["hourly", "daily"], - ), - "temperature": DarkskySensorEntityDescription( - key="temperature", - name="Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["currently", "hourly"], - ), - "apparent_temperature": DarkskySensorEntityDescription( - key="apparent_temperature", - name="Apparent Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["currently", "hourly"], - ), - "dew_point": DarkskySensorEntityDescription( - key="dew_point", - name="Dew Point", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["currently", "hourly", "daily"], - ), - "wind_speed": DarkskySensorEntityDescription( - key="wind_speed", - name="Wind Speed", - device_class=SensorDeviceClass.WIND_SPEED, - si_unit=UnitOfSpeed.METERS_PER_SECOND, - us_unit=UnitOfSpeed.MILES_PER_HOUR, - ca_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - uk_unit=UnitOfSpeed.MILES_PER_HOUR, - uk2_unit=UnitOfSpeed.MILES_PER_HOUR, - forecast_mode=["currently", "hourly", "daily"], - ), - "wind_bearing": DarkskySensorEntityDescription( - key="wind_bearing", - name="Wind Bearing", - si_unit=DEGREE, - us_unit=DEGREE, - ca_unit=DEGREE, - uk_unit=DEGREE, - uk2_unit=DEGREE, - icon="mdi:compass", - forecast_mode=["currently", "hourly", "daily"], - ), - "wind_gust": DarkskySensorEntityDescription( - key="wind_gust", - name="Wind Gust", - device_class=SensorDeviceClass.WIND_SPEED, - si_unit=UnitOfSpeed.METERS_PER_SECOND, - us_unit=UnitOfSpeed.MILES_PER_HOUR, - ca_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - uk_unit=UnitOfSpeed.MILES_PER_HOUR, - uk2_unit=UnitOfSpeed.MILES_PER_HOUR, - icon="mdi:weather-windy-variant", - forecast_mode=["currently", "hourly", "daily"], - ), - "cloud_cover": DarkskySensorEntityDescription( - key="cloud_cover", - name="Cloud Coverage", - si_unit=PERCENTAGE, - us_unit=PERCENTAGE, - ca_unit=PERCENTAGE, - uk_unit=PERCENTAGE, - uk2_unit=PERCENTAGE, - icon="mdi:weather-partly-cloudy", - forecast_mode=["currently", "hourly", "daily"], - ), - "humidity": DarkskySensorEntityDescription( - key="humidity", - name="Humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - si_unit=PERCENTAGE, - us_unit=PERCENTAGE, - ca_unit=PERCENTAGE, - uk_unit=PERCENTAGE, - uk2_unit=PERCENTAGE, - forecast_mode=["currently", "hourly", "daily"], - ), - "pressure": DarkskySensorEntityDescription( - key="pressure", - name="Pressure", - device_class=SensorDeviceClass.PRESSURE, - si_unit=UnitOfPressure.MBAR, - us_unit=UnitOfPressure.MBAR, - ca_unit=UnitOfPressure.MBAR, - uk_unit=UnitOfPressure.MBAR, - uk2_unit=UnitOfPressure.MBAR, - forecast_mode=["currently", "hourly", "daily"], - ), - "visibility": DarkskySensorEntityDescription( - key="visibility", - name="Visibility", - si_unit=UnitOfLength.KILOMETERS, - us_unit=UnitOfLength.MILES, - ca_unit=UnitOfLength.KILOMETERS, - uk_unit=UnitOfLength.KILOMETERS, - uk2_unit=UnitOfLength.MILES, - icon="mdi:eye", - forecast_mode=["currently", "hourly", "daily"], - ), - "ozone": DarkskySensorEntityDescription( - key="ozone", - name="Ozone", - device_class=SensorDeviceClass.OZONE, - si_unit="DU", - us_unit="DU", - ca_unit="DU", - uk_unit="DU", - uk2_unit="DU", - forecast_mode=["currently", "hourly", "daily"], - ), - "apparent_temperature_max": DarkskySensorEntityDescription( - key="apparent_temperature_max", - name="Daily High Apparent Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "apparent_temperature_high": DarkskySensorEntityDescription( - key="apparent_temperature_high", - name="Daytime High Apparent Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "apparent_temperature_min": DarkskySensorEntityDescription( - key="apparent_temperature_min", - name="Daily Low Apparent Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "apparent_temperature_low": DarkskySensorEntityDescription( - key="apparent_temperature_low", - name="Overnight Low Apparent Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "temperature_max": DarkskySensorEntityDescription( - key="temperature_max", - name="Daily High Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "temperature_high": DarkskySensorEntityDescription( - key="temperature_high", - name="Daytime High Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "temperature_min": DarkskySensorEntityDescription( - key="temperature_min", - name="Daily Low Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "temperature_low": DarkskySensorEntityDescription( - key="temperature_low", - name="Overnight Low Temperature", - device_class=SensorDeviceClass.TEMPERATURE, - si_unit=UnitOfTemperature.CELSIUS, - us_unit=UnitOfTemperature.FAHRENHEIT, - ca_unit=UnitOfTemperature.CELSIUS, - uk_unit=UnitOfTemperature.CELSIUS, - uk2_unit=UnitOfTemperature.CELSIUS, - forecast_mode=["daily"], - ), - "precip_intensity_max": DarkskySensorEntityDescription( - key="precip_intensity_max", - name="Daily Max Precip Intensity", - si_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - us_unit=UnitOfVolumetricFlux.INCHES_PER_HOUR, - ca_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - uk_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - uk2_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - icon="mdi:thermometer", - forecast_mode=["daily"], - ), - "uv_index": DarkskySensorEntityDescription( - key="uv_index", - name="UV Index", - si_unit=UV_INDEX, - us_unit=UV_INDEX, - ca_unit=UV_INDEX, - uk_unit=UV_INDEX, - uk2_unit=UV_INDEX, - icon="mdi:weather-sunny", - forecast_mode=["currently", "hourly", "daily"], - ), - "moon_phase": DarkskySensorEntityDescription( - key="moon_phase", - name="Moon Phase", - icon="mdi:weather-night", - forecast_mode=["daily"], - ), - "sunrise_time": DarkskySensorEntityDescription( - key="sunrise_time", - name="Sunrise", - icon="mdi:white-balance-sunny", - forecast_mode=["daily"], - ), - "sunset_time": DarkskySensorEntityDescription( - key="sunset_time", - name="Sunset", - icon="mdi:weather-night", - forecast_mode=["daily"], - ), - "alerts": DarkskySensorEntityDescription( - key="alerts", - name="Alerts", - icon="mdi:alert-circle-outline", - forecast_mode=[], - ), -} - - -class ConditionPicture(NamedTuple): - """Entity picture and icon for condition.""" - - entity_picture: str - icon: str - - -CONDITION_PICTURES: dict[str, ConditionPicture] = { - "clear-day": ConditionPicture( - entity_picture="/static/images/darksky/weather-sunny.svg", - icon="mdi:weather-sunny", - ), - "clear-night": ConditionPicture( - entity_picture="/static/images/darksky/weather-night.svg", - icon="mdi:weather-night", - ), - "rain": ConditionPicture( - entity_picture="/static/images/darksky/weather-pouring.svg", - icon="mdi:weather-pouring", - ), - "snow": ConditionPicture( - entity_picture="/static/images/darksky/weather-snowy.svg", - icon="mdi:weather-snowy", - ), - "sleet": ConditionPicture( - entity_picture="/static/images/darksky/weather-hail.svg", - icon="mdi:weather-snowy-rainy", - ), - "wind": ConditionPicture( - entity_picture="/static/images/darksky/weather-windy.svg", - icon="mdi:weather-windy", - ), - "fog": ConditionPicture( - entity_picture="/static/images/darksky/weather-fog.svg", - icon="mdi:weather-fog", - ), - "cloudy": ConditionPicture( - entity_picture="/static/images/darksky/weather-cloudy.svg", - icon="mdi:weather-cloudy", - ), - "partly-cloudy-day": ConditionPicture( - entity_picture="/static/images/darksky/weather-partlycloudy.svg", - icon="mdi:weather-partly-cloudy", - ), - "partly-cloudy-night": ConditionPicture( - entity_picture="/static/images/darksky/weather-cloudy.svg", - icon="mdi:weather-night-partly-cloudy", - ), -} - -# Language Supported Codes -LANGUAGE_CODES = [ - "ar", - "az", - "be", - "bg", - "bn", - "bs", - "ca", - "cs", - "da", - "de", - "el", - "en", - "ja", - "ka", - "kn", - "ko", - "eo", - "es", - "et", - "fi", - "fr", - "he", - "hi", - "hr", - "hu", - "id", - "is", - "it", - "kw", - "lv", - "ml", - "mr", - "nb", - "nl", - "pa", - "pl", - "pt", - "ro", - "ru", - "sk", - "sl", - "sr", - "sv", - "ta", - "te", - "tet", - "tr", - "uk", - "ur", - "x-pig-latin", - "zh", - "zh-tw", -] - -ALLOWED_UNITS = ["auto", "si", "us", "ca", "uk", "uk2"] - -ALERTS_ATTRS = ["time", "description", "expires", "severity", "uri", "regions", "title"] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] - ), - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNITS): vol.In(ALLOWED_UNITS), - vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(LANGUAGE_CODES), - vol.Inclusive( - CONF_LATITUDE, "coordinates", "Latitude and longitude must exist together" - ): cv.latitude, - vol.Inclusive( - CONF_LONGITUDE, "coordinates", "Latitude and longitude must exist together" - ): cv.longitude, - vol.Optional(CONF_FORECAST): vol.All(cv.ensure_list, [vol.Range(min=0, max=7)]), - vol.Optional(CONF_HOURLY_FORECAST): vol.All( - cv.ensure_list, [vol.Range(min=0, max=48)] - ), - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Dark Sky sensor.""" - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - language = config.get(CONF_LANGUAGE) - interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) - - if CONF_UNITS in config: - units = config[CONF_UNITS] - elif hass.config.units is METRIC_SYSTEM: - units = "si" - else: - units = "us" - - forecast_data = DarkSkyData( - api_key=config.get(CONF_API_KEY), - latitude=latitude, - longitude=longitude, - units=units, - language=language, - interval=interval, - ) - forecast_data.update() - forecast_data.update_currently() - - # If connection failed don't setup platform. - if forecast_data.data is None: - return - - name = config.get(CONF_NAME) - - forecast = config.get(CONF_FORECAST) - forecast_hour = config.get(CONF_HOURLY_FORECAST) - sensors: list[SensorEntity] = [] - for variable in config[CONF_MONITORED_CONDITIONS]: - if variable in DEPRECATED_SENSOR_TYPES: - _LOGGER.warning("Monitored condition %s is deprecated", variable) - description = SENSOR_TYPES[variable] - if not description.forecast_mode or "currently" in description.forecast_mode: - if variable == "alerts": - sensors.append(DarkSkyAlertSensor(forecast_data, description, name)) - else: - sensors.append(DarkSkySensor(forecast_data, description, name)) - - if forecast is not None and "daily" in description.forecast_mode: - sensors.extend( - [ - DarkSkySensor( - forecast_data, description, name, forecast_day=forecast_day - ) - for forecast_day in forecast - ] - ) - if forecast_hour is not None and "hourly" in description.forecast_mode: - sensors.extend( - [ - DarkSkySensor( - forecast_data, description, name, forecast_hour=forecast_h - ) - for forecast_h in forecast_hour - ] - ) - - add_entities(sensors, True) - - -class DarkSkySensor(SensorEntity): - """Implementation of a Dark Sky sensor.""" - - _attr_attribution = "Powered by Dark Sky" - entity_description: DarkskySensorEntityDescription - - def __init__( - self, - forecast_data, - description: DarkskySensorEntityDescription, - name, - forecast_day=None, - forecast_hour=None, - ) -> None: - """Initialize the sensor.""" - self.entity_description = description - self.forecast_data = forecast_data - self.forecast_day = forecast_day - self.forecast_hour = forecast_hour - self._icon: str | None = None - - if forecast_day is not None: - self._attr_name = f"{name} {description.name} {forecast_day}d" - elif forecast_hour is not None: - self._attr_name = f"{name} {description.name} {forecast_hour}h" - else: - self._attr_name = f"{name} {description.name}" - - @property - def unit_system(self): - """Return the unit system of this entity.""" - return self.forecast_data.unit_system - - @property - def entity_picture(self) -> str | None: - """Return the entity picture to use in the frontend, if any.""" - if self._icon is None or "summary" not in self.entity_description.key: - return None - - if self._icon in CONDITION_PICTURES: - return CONDITION_PICTURES[self._icon].entity_picture - - return None - - def update_unit_of_measurement(self) -> None: - """Update units based on unit system.""" - unit_key = MAP_UNIT_SYSTEM.get(self.unit_system, "si_unit") - self._attr_native_unit_of_measurement = getattr( - self.entity_description, unit_key - ) - - @property - def icon(self) -> str | None: - """Icon to use in the frontend, if any.""" - if ( - "summary" in self.entity_description.key - and self._icon in CONDITION_PICTURES - ): - return CONDITION_PICTURES[self._icon].icon - - return self.entity_description.icon - - def update(self) -> None: - """Get the latest data from Dark Sky and updates the states.""" - # Call the API for new forecast data. Each sensor will re-trigger this - # same exact call, but that's fine. We cache results for a short period - # of time to prevent hitting API limits. Note that Dark Sky will - # charge users for too many calls in 1 day, so take care when updating. - self.forecast_data.update() - self.update_unit_of_measurement() - - sensor_type = self.entity_description.key - if sensor_type == "minutely_summary": - self.forecast_data.update_minutely() - minutely = self.forecast_data.data_minutely - self._attr_native_value = getattr(minutely, "summary", "") - self._icon = getattr(minutely, "icon", "") - elif sensor_type == "hourly_summary": - self.forecast_data.update_hourly() - hourly = self.forecast_data.data_hourly - self._attr_native_value = getattr(hourly, "summary", "") - self._icon = getattr(hourly, "icon", "") - elif self.forecast_hour is not None: - self.forecast_data.update_hourly() - hourly = self.forecast_data.data_hourly - if hasattr(hourly, "data"): - self._attr_native_value = self.get_state( - hourly.data[self.forecast_hour] - ) - else: - self._attr_native_value = 0 - elif sensor_type == "daily_summary": - self.forecast_data.update_daily() - daily = self.forecast_data.data_daily - self._attr_native_value = getattr(daily, "summary", "") - self._icon = getattr(daily, "icon", "") - elif self.forecast_day is not None: - self.forecast_data.update_daily() - daily = self.forecast_data.data_daily - if hasattr(daily, "data"): - self._attr_native_value = self.get_state(daily.data[self.forecast_day]) - else: - self._attr_native_value = 0 - else: - self.forecast_data.update_currently() - currently = self.forecast_data.data_currently - self._attr_native_value = self.get_state(currently) - - def get_state(self, data): - """Return a new state based on the type. - - If the sensor type is unknown, the current state is returned. - """ - sensor_type = self.entity_description.key - lookup_type = convert_to_camel(sensor_type) - - if (state := getattr(data, lookup_type, None)) is None: - return None - - if "summary" in sensor_type: - self._icon = getattr(data, "icon", "") - - # Some state data needs to be rounded to whole values or converted to - # percentages - if sensor_type in {"precip_probability", "cloud_cover", "humidity"}: - return round(state * 100, 1) - - if sensor_type in { - "dew_point", - "temperature", - "apparent_temperature", - "temperature_low", - "apparent_temperature_low", - "temperature_min", - "apparent_temperature_min", - "temperature_high", - "apparent_temperature_high", - "temperature_max", - "apparent_temperature_max", - "precip_accumulation", - "pressure", - "ozone", - "uvIndex", - }: - return round(state, 1) - return state - - -class DarkSkyAlertSensor(SensorEntity): - """Implementation of a Dark Sky sensor.""" - - entity_description: DarkskySensorEntityDescription - _attr_native_value: int | None - - def __init__( - self, forecast_data, description: DarkskySensorEntityDescription, name - ) -> None: - """Initialize the sensor.""" - self.entity_description = description - self.forecast_data = forecast_data - self._alerts = None - - self._attr_name = f"{name} {description.name}" - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - if self._attr_native_value is not None and self._attr_native_value > 0: - return "mdi:alert-circle" - return "mdi:alert-circle-outline" - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._alerts - - def update(self) -> None: - """Get the latest data from Dark Sky and updates the states.""" - # Call the API for new forecast data. Each sensor will re-trigger this - # same exact call, but that's fine. We cache results for a short period - # of time to prevent hitting API limits. Note that Dark Sky will - # charge users for too many calls in 1 day, so take care when updating. - self.forecast_data.update() - self.forecast_data.update_alerts() - alerts = self.forecast_data.data_alerts - self._attr_native_value = self.get_state(alerts) - - def get_state(self, data): - """Return a new state based on the type. - - If the sensor type is unknown, the current state is returned. - """ - alerts = {} - if data is None: - self._alerts = alerts - return data - - multiple_alerts = len(data) > 1 - for i, alert in enumerate(data): - for attr in ALERTS_ATTRS: - if multiple_alerts: - dkey = f"{attr}_{i!s}" - else: - dkey = attr - alerts[dkey] = getattr(alert, attr) - self._alerts = alerts - - return len(data) - - -def convert_to_camel(data): - """Convert snake case (foo_bar_bat) to camel case (fooBarBat). - - This is not pythonic, but needed for certain situations. - """ - components = data.split("_") - capital_components = "".join(x.title() for x in components[1:]) - return f"{components[0]}{capital_components}" - - -class DarkSkyData: - """Get the latest data from Darksky.""" - - def __init__(self, api_key, latitude, longitude, units, language, interval): - """Initialize the data object.""" - self._api_key = api_key - self.latitude = latitude - self.longitude = longitude - self.units = units - self.language = language - self._connect_error = False - - self.data = None - self.unit_system = None - self.data_currently = None - self.data_minutely = None - self.data_hourly = None - self.data_daily = None - self.data_alerts = None - - # Apply throttling to methods using configured interval - self.update = Throttle(interval)(self._update) - self.update_currently = Throttle(interval)(self._update_currently) - self.update_minutely = Throttle(interval)(self._update_minutely) - self.update_hourly = Throttle(interval)(self._update_hourly) - self.update_daily = Throttle(interval)(self._update_daily) - self.update_alerts = Throttle(interval)(self._update_alerts) - - def _update(self): - """Get the latest data from Dark Sky.""" - try: - self.data = forecastio.load_forecast( - self._api_key, - self.latitude, - self.longitude, - units=self.units, - lang=self.language, - ) - if self._connect_error: - self._connect_error = False - _LOGGER.info("Reconnected to Dark Sky") - except (ConnectError, HTTPError, Timeout, ValueError) as error: - if not self._connect_error: - self._connect_error = True - _LOGGER.error("Unable to connect to Dark Sky: %s", error) - self.data = None - self.unit_system = self.data and self.data.json["flags"]["units"] - - def _update_currently(self): - """Update currently data.""" - self.data_currently = self.data and self.data.currently() - - def _update_minutely(self): - """Update minutely data.""" - self.data_minutely = self.data and self.data.minutely() - - def _update_hourly(self): - """Update hourly data.""" - self.data_hourly = self.data and self.data.hourly() - - def _update_daily(self): - """Update daily data.""" - self.data_daily = self.data and self.data.daily() - - def _update_alerts(self): - """Update alerts data.""" - self.data_alerts = self.data and self.data.alerts() diff --git a/homeassistant/components/darksky/weather.py b/homeassistant/components/darksky/weather.py deleted file mode 100644 index 2567290867..0000000000 --- a/homeassistant/components/darksky/weather.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Support for retrieving meteorological data from Dark Sky.""" -from __future__ import annotations - -from datetime import timedelta -import logging - -import forecastio -from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout -import voluptuous as vol - -from homeassistant.components.weather import ( - ATTR_CONDITION_CLEAR_NIGHT, - ATTR_CONDITION_CLOUDY, - ATTR_CONDITION_FOG, - ATTR_CONDITION_HAIL, - ATTR_CONDITION_LIGHTNING, - ATTR_CONDITION_PARTLYCLOUDY, - ATTR_CONDITION_RAINY, - ATTR_CONDITION_SNOWY, - ATTR_CONDITION_SNOWY_RAINY, - ATTR_CONDITION_SUNNY, - ATTR_CONDITION_WINDY, - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_NATIVE_WIND_SPEED, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, - PLATFORM_SCHEMA, - WeatherEntity, -) -from homeassistant.const import ( - CONF_API_KEY, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_MODE, - CONF_NAME, - UnitOfLength, - UnitOfPrecipitationDepth, - UnitOfPressure, - UnitOfSpeed, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -from homeassistant.util.dt import utc_from_timestamp - -_LOGGER = logging.getLogger(__name__) - -ATTRIBUTION = "Powered by Dark Sky" - -FORECAST_MODE = ["hourly", "daily"] - -MAP_CONDITION = { - "clear-day": ATTR_CONDITION_SUNNY, - "clear-night": ATTR_CONDITION_CLEAR_NIGHT, - "rain": ATTR_CONDITION_RAINY, - "snow": ATTR_CONDITION_SNOWY, - "sleet": ATTR_CONDITION_SNOWY_RAINY, - "wind": ATTR_CONDITION_WINDY, - "fog": ATTR_CONDITION_FOG, - "cloudy": ATTR_CONDITION_CLOUDY, - "partly-cloudy-day": ATTR_CONDITION_PARTLYCLOUDY, - "partly-cloudy-night": ATTR_CONDITION_PARTLYCLOUDY, - "hail": ATTR_CONDITION_HAIL, - "thunderstorm": ATTR_CONDITION_LIGHTNING, - "tornado": None, -} - -CONF_UNITS = "units" - -DEFAULT_NAME = "Dark Sky" - -PLATFORM_SCHEMA = vol.All( - cv.removed(CONF_UNITS), - PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_MODE, default="hourly"): vol.In(FORECAST_MODE), - vol.Optional(CONF_UNITS): vol.In(["auto", "si", "us", "ca", "uk", "uk2"]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } - ), -) - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=3) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Dark Sky weather.""" - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - name = config.get(CONF_NAME) - mode = config.get(CONF_MODE) - - units = "si" - dark_sky = DarkSkyData(config.get(CONF_API_KEY), latitude, longitude, units) - - add_entities([DarkSkyWeather(name, dark_sky, mode)], True) - - -class DarkSkyWeather(WeatherEntity): - """Representation of a weather condition.""" - - _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS - _attr_native_pressure_unit = UnitOfPressure.MBAR - _attr_native_temperature_unit = UnitOfTemperature.CELSIUS - _attr_native_visibility_unit = UnitOfLength.KILOMETERS - _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND - - def __init__(self, name, dark_sky, mode): - """Initialize Dark Sky weather.""" - self._name = name - self._dark_sky = dark_sky - self._mode = mode - - self._ds_data = None - self._ds_currently = None - self._ds_hourly = None - self._ds_daily = None - - @property - def available(self) -> bool: - """Return if weather data is available from Dark Sky.""" - return self._ds_data is not None - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_temperature(self): - """Return the temperature.""" - return self._ds_currently.get("temperature") - - @property - def humidity(self): - """Return the humidity.""" - return round(self._ds_currently.get("humidity") * 100.0, 2) - - @property - def native_wind_speed(self): - """Return the wind speed.""" - return self._ds_currently.get("windSpeed") - - @property - def wind_bearing(self): - """Return the wind bearing.""" - return self._ds_currently.get("windBearing") - - @property - def ozone(self): - """Return the ozone level.""" - return self._ds_currently.get("ozone") - - @property - def native_pressure(self): - """Return the pressure.""" - return self._ds_currently.get("pressure") - - @property - def native_visibility(self): - """Return the visibility.""" - return self._ds_currently.get("visibility") - - @property - def condition(self): - """Return the weather condition.""" - return MAP_CONDITION.get(self._ds_currently.get("icon")) - - @property - def forecast(self): - """Return the forecast array.""" - - # Per conversation with Joshua Reyes of Dark Sky, to get the total - # forecasted precipitation, you have to multiple the intensity by - # the hours for the forecast interval - def calc_precipitation(intensity, hours): - amount = None - if intensity is not None: - amount = round((intensity * hours), 1) - return amount if amount > 0 else None - - data = None - - if self._mode == "daily": - data = [ - { - ATTR_FORECAST_TIME: utc_from_timestamp( - entry.d.get("time") - ).isoformat(), - ATTR_FORECAST_NATIVE_TEMP: entry.d.get("temperatureHigh"), - ATTR_FORECAST_NATIVE_TEMP_LOW: entry.d.get("temperatureLow"), - ATTR_FORECAST_NATIVE_PRECIPITATION: calc_precipitation( - entry.d.get("precipIntensity"), 24 - ), - ATTR_FORECAST_NATIVE_WIND_SPEED: entry.d.get("windSpeed"), - ATTR_FORECAST_WIND_BEARING: entry.d.get("windBearing"), - ATTR_FORECAST_CONDITION: MAP_CONDITION.get(entry.d.get("icon")), - } - for entry in self._ds_daily.data - ] - else: - data = [ - { - ATTR_FORECAST_TIME: utc_from_timestamp( - entry.d.get("time") - ).isoformat(), - ATTR_FORECAST_NATIVE_TEMP: entry.d.get("temperature"), - ATTR_FORECAST_NATIVE_PRECIPITATION: calc_precipitation( - entry.d.get("precipIntensity"), 1 - ), - ATTR_FORECAST_CONDITION: MAP_CONDITION.get(entry.d.get("icon")), - } - for entry in self._ds_hourly.data - ] - - return data - - def update(self) -> None: - """Get the latest data from Dark Sky.""" - self._dark_sky.update() - - self._ds_data = self._dark_sky.data - currently = self._dark_sky.currently - self._ds_currently = currently.d if currently else {} - self._ds_hourly = self._dark_sky.hourly - self._ds_daily = self._dark_sky.daily - - -class DarkSkyData: - """Get the latest data from Dark Sky.""" - - def __init__(self, api_key, latitude, longitude, units): - """Initialize the data object.""" - self._api_key = api_key - self.latitude = latitude - self.longitude = longitude - self.requested_units = units - - self.data = None - self.currently = None - self.hourly = None - self.daily = None - self._connect_error = False - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data from Dark Sky.""" - try: - self.data = forecastio.load_forecast( - self._api_key, self.latitude, self.longitude, units=self.requested_units - ) - self.currently = self.data.currently() - self.hourly = self.data.hourly() - self.daily = self.data.daily() - if self._connect_error: - self._connect_error = False - _LOGGER.info("Reconnected to Dark Sky") - except (ConnectError, HTTPError, Timeout, ValueError) as error: - if not self._connect_error: - self._connect_error = True - _LOGGER.error("Unable to connect to Dark Sky. %s", error) - self.data = None diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index bb8c863824..4fe141c494 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["debugpy==1.6.6"] + "requirements": ["debugpy==1.6.7"] } diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index d977e8813d..f4af733742 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -235,9 +235,15 @@ class DeconzGateway: ) -> None: """Handle signals of config entry being updated. - This is a static method because a class method (bound method), cannot be used with weak references. - Causes for this is either discovery updating host address or config entry options changing. + This is a static method because a class method (bound method), + cannot be used with weak references. + Causes for this is either discovery updating host address or + config entry options changing. """ + if entry.entry_id not in hass.data[DECONZ_DOMAIN]: + # A race condition can occur if multiple config entries are + # unloaded in parallel + return gateway = get_gateway_from_config_entry(hass, entry) if gateway.api.host != gateway.host: diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 5569f9d5e8..61794e7c70 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==110"], + "requirements": ["pydeconz==111"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index e908853275..136f582f5c 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -33,7 +33,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_VOLTAGE, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, EntityCategory, @@ -108,7 +110,7 @@ class DeconzSensorDescription(SensorEntityDescription, DeconzSensorDescriptionMi ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( DeconzSensorDescription[AirQuality]( key="air_quality", - supported_fn=lambda device: device.air_quality is not None, + supported_fn=lambda device: device.supports_air_quality, update_key="airquality", value_fn=lambda device: device.air_quality, instance_check=AirQuality, @@ -124,6 +126,39 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, ), + DeconzSensorDescription[AirQuality]( + key="air_quality_formaldehyde", + supported_fn=lambda device: device.air_quality_formaldehyde is not None, + update_key="airquality_formaldehyde_density", + value_fn=lambda device: device.air_quality_formaldehyde, + instance_check=AirQuality, + name_suffix="CH2O", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + DeconzSensorDescription[AirQuality]( + key="air_quality_co2", + supported_fn=lambda device: device.air_quality_co2 is not None, + update_key="airquality_co2_density", + value_fn=lambda device: device.air_quality_co2, + instance_check=AirQuality, + name_suffix="CO2", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + DeconzSensorDescription[AirQuality]( + key="air_quality_pm2_5", + supported_fn=lambda device: device.pm_2_5 is not None, + update_key="pm2_5", + value_fn=lambda device: device.pm_2_5, + instance_check=AirQuality, + name_suffix="PM25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), DeconzSensorDescription[Consumption]( key="consumption", supported_fn=lambda device: device.consumption is not None, diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py index 574d97c6d2..d91d06949e 100644 --- a/homeassistant/components/default_config/__init__.py +++ b/homeassistant/components/default_config/__init__.py @@ -1,10 +1,4 @@ """Component providing default configuration for new users.""" - -try: - import av -except ImportError: - av = None - from homeassistant.components.hassio import is_hassio from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType @@ -18,7 +12,4 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if not is_hassio(hass): await async_setup_component(hass, "backup", config) - if av is None: - return True - - return await async_setup_component(hass, "stream", config) + return True diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index d4faaddaa5..684013a563 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@home-assistant/core"], "dependencies": [ "application_credentials", + "assist_pipeline", "automation", "bluetooth", "cloud", @@ -33,6 +34,7 @@ "schedule", "script", "ssdp", + "stream", "sun", "system_health", "tag", diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 13e8e13539..82cb8eff62 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -36,6 +36,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.SELECT, Platform.SENSOR, Platform.SIREN, + Platform.STT, Platform.SWITCH, Platform.TEXT, Platform.UPDATE, diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py index 9c3cf89d80..07a844c048 100644 --- a/homeassistant/components/demo/stt.py +++ b/homeassistant/components/demo/stt.py @@ -1,7 +1,7 @@ """Support for the demo for speech to text service.""" from __future__ import annotations -from aiohttp import StreamReader +from collections.abc import AsyncIterable from homeassistant.components.stt import ( AudioBitRates, @@ -13,8 +13,11 @@ from homeassistant.components.stt import ( SpeechMetadata, SpeechResult, SpeechResultState, + SpeechToTextEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType SUPPORT_LANGUAGES = ["en", "de"] @@ -29,6 +32,62 @@ async def async_get_engine( return DemoProvider() +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Demo speech platform via config entry.""" + async_add_entities([DemoProviderEntity()]) + + +class DemoProviderEntity(SpeechToTextEntity): + """Demo speech API provider entity.""" + + _attr_name = "Demo STT" + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def supported_formats(self) -> list[AudioFormats]: + """Return a list of supported formats.""" + return [AudioFormats.WAV] + + @property + def supported_codecs(self) -> list[AudioCodecs]: + """Return a list of supported codecs.""" + return [AudioCodecs.PCM] + + @property + def supported_bit_rates(self) -> list[AudioBitRates]: + """Return a list of supported bit rates.""" + return [AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> list[AudioSampleRates]: + """Return a list of supported sample rates.""" + return [AudioSampleRates.SAMPLERATE_16000, AudioSampleRates.SAMPLERATE_44100] + + @property + def supported_channels(self) -> list[AudioChannels]: + """Return a list of supported channels.""" + return [AudioChannels.CHANNEL_STEREO] + + async def async_process_audio_stream( + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] + ) -> SpeechResult: + """Process an audio stream to STT service.""" + + # Read available data + async for _ in stream: + pass + + return SpeechResult("Turn the Kitchen Lights on", SpeechResultState.SUCCESS) + + class DemoProvider(Provider): """Demo speech API provider.""" @@ -63,12 +122,12 @@ class DemoProvider(Provider): return [AudioChannels.CHANNEL_STEREO] async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: StreamReader + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service.""" # Read available data - async for _ in stream.iter_chunked(4096): + async for _ in stream: pass return SpeechResult("Turn the Kitchen Lights on", SpeechResultState.SUCCESS) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index adf91eb706..e1cc278137 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import PLATFORM_SCHEMA, RestoreSensor, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -22,7 +22,6 @@ from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -126,7 +125,7 @@ async def async_setup_platform( async_add_entities([derivative]) -class DerivativeSensor(RestoreEntity, SensorEntity): +class DerivativeSensor(RestoreSensor, SensorEntity): """Representation of an derivative sensor.""" _attr_icon = ICON @@ -170,9 +169,13 @@ class DerivativeSensor(RestoreEntity, SensorEntity): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() - if (state := await self.async_get_last_state()) is not None: + restored_data = await self.async_get_last_sensor_data() + if restored_data: + self._attr_native_unit_of_measurement = ( + restored_data.native_unit_of_measurement + ) try: - self._state = Decimal(state.state) + self._state = Decimal(restored_data.native_value) # type: ignore[arg-type] except SyntaxError as err: _LOGGER.warning("Could not restore last state: %s", err) diff --git a/homeassistant/components/derivative/strings.json b/homeassistant/components/derivative/strings.json index 35f1679a31..7a4ee9d4fc 100644 --- a/homeassistant/components/derivative/strings.json +++ b/homeassistant/components/derivative/strings.json @@ -35,7 +35,7 @@ "data_description": { "round": "[%key:component::derivative::config::step::user::data_description::round%]", "time_window": "[%key:component::derivative::config::step::user::data_description::time_window%]", - "unit_prefix": "[%key:component::derivative::config::step::user::data_description::unit_prefix%]." + "unit_prefix": "[%key:component::derivative::config::step::user::data_description::unit_prefix%]" } } } diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 6a7133b948..5d56548f0e 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -47,6 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: component = hass.data[DOMAIN] = EntityComponent[BaseTrackerEntity]( LOGGER, DOMAIN, hass ) + component.register_shutdown() # Clean up old devices created by device tracker entities in the past. # Can be removed after 2022.6 @@ -348,7 +349,7 @@ class ScannerEntity(BaseTrackerEntity): self.mac_address, self.unique_id, ) - if self.is_connected: + if self.is_connected and self.ip_address: _async_connected_device_registered( hass, self.mac_address, @@ -405,7 +406,7 @@ class ScannerEntity(BaseTrackerEntity): """Return the device state attributes.""" attr: dict[str, StateType] = {} attr.update(super().state_attributes) - if self.ip_address is not None: + if self.ip_address: attr[ATTR_IP] = self.ip_address if self.mac_address is not None: attr[ATTR_MAC] = self.mac_address diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index bc792ee892..e27ff57f03 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -25,10 +25,11 @@ from homeassistant.const import ( CONF_MAC, CONF_NAME, DEVICE_DEFAULT_NAME, + EVENT_HOMEASSISTANT_STOP, STATE_HOME, STATE_NOT_HOME, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( config_per_platform, @@ -216,7 +217,7 @@ async def async_setup_integration(hass: HomeAssistant, config: ConfigType) -> No discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) # Clean up stale devices - async_track_utc_time_change( + cancel_update_stale = async_track_utc_time_change( hass, tracker.async_update_stale, second=range(0, 60, 5) ) @@ -235,6 +236,16 @@ async def async_setup_integration(hass: HomeAssistant, config: ConfigType) -> No # restore await tracker.async_setup_tracked_device() + @callback + def _on_hass_stop(_: Event) -> None: + """Cleanup when Home Assistant stops. + + Cancel the async_update_stale schedule. + """ + cancel_update_stale() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_hass_stop) + @attr.s class DeviceTrackerPlatform: @@ -356,6 +367,27 @@ async def async_create_platform_type( return DeviceTrackerPlatform(p_type, platform, p_config) +def _load_device_names_and_attributes( + scanner: DeviceScanner, + device_name_uses_executor: bool, + extra_attributes_uses_executor: bool, + seen: set[str], + found_devices: list[str], +) -> tuple[dict[str, str | None], dict[str, dict[str, Any]]]: + """Load device names and attributes in a single executor job.""" + host_name_by_mac: dict[str, str | None] = {} + extra_attributes_by_mac: dict[str, dict[str, Any]] = {} + for mac in found_devices: + if device_name_uses_executor and mac not in seen: + host_name_by_mac[mac] = scanner.get_device_name(mac) + if extra_attributes_uses_executor: + try: + extra_attributes_by_mac[mac] = scanner.get_extra_attributes(mac) + except NotImplementedError: + extra_attributes_by_mac[mac] = {} + return host_name_by_mac, extra_attributes_by_mac + + @callback def async_setup_scanner_platform( hass: HomeAssistant, @@ -373,7 +405,7 @@ def async_setup_scanner_platform( scanner.hass = hass # Initial scan of each mac we also tell about host name for config - seen: Any = set() + seen: set[str] = set() async def async_device_tracker_scan(now: datetime | None) -> None: """Handle interval matches.""" @@ -391,15 +423,42 @@ def async_setup_scanner_platform( async with update_lock: found_devices = await scanner.async_scan_devices() + device_name_uses_executor = ( + scanner.async_get_device_name.__func__ # type: ignore[attr-defined] + is DeviceScanner.async_get_device_name + ) + extra_attributes_uses_executor = ( + scanner.async_get_extra_attributes.__func__ # type: ignore[attr-defined] + is DeviceScanner.async_get_extra_attributes + ) + host_name_by_mac: dict[str, str | None] = {} + extra_attributes_by_mac: dict[str, dict[str, Any]] = {} + if device_name_uses_executor or extra_attributes_uses_executor: + ( + host_name_by_mac, + extra_attributes_by_mac, + ) = await hass.async_add_executor_job( + _load_device_names_and_attributes, + scanner, + device_name_uses_executor, + extra_attributes_uses_executor, + seen, + found_devices, + ) + for mac in found_devices: if mac in seen: host_name = None else: - host_name = await scanner.async_get_device_name(mac) + host_name = host_name_by_mac.get( + mac, await scanner.async_get_device_name(mac) + ) seen.add(mac) try: - extra_attributes = await scanner.async_get_extra_attributes(mac) + extra_attributes = extra_attributes_by_mac.get( + mac, await scanner.async_get_extra_attributes(mac) + ) except NotImplementedError: extra_attributes = {} @@ -423,9 +482,24 @@ def async_setup_scanner_platform( hass.async_create_task(async_see_device(**kwargs)) - async_track_time_interval(hass, async_device_tracker_scan, interval) + cancel_legacy_scan = async_track_time_interval( + hass, + async_device_tracker_scan, + interval, + name=f"device_tracker {platform} legacy scan", + ) hass.async_create_task(async_device_tracker_scan(None)) + @callback + def _on_hass_stop(_: Event) -> None: + """Cleanup when Home Assistant stops. + + Cancel the legacy scan. + """ + cancel_legacy_scan() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_hass_stop) + async def get_tracker(hass: HomeAssistant, config: ConfigType) -> DeviceTracker: """Create a tracker.""" diff --git a/homeassistant/components/devolo_home_control/strings.json b/homeassistant/components/devolo_home_control/strings.json index 293763c890..84f05b8838 100644 --- a/homeassistant/components/devolo_home_control/strings.json +++ b/homeassistant/components/devolo_home_control/strings.json @@ -11,16 +11,16 @@ "step": { "user": { "data": { - "username": "[%key:common::config_flow::data::email%] / devolo ID", + "username": "Email / devolo ID", "password": "[%key:common::config_flow::data::password%]", - "mydevolo_url": "mydevolo [%key:common::config_flow::data::url%]" + "mydevolo_url": "mydevolo URL" } }, "zeroconf_confirm": { "data": { - "username": "[%key:common::config_flow::data::email%] / devolo ID", + "username": "Email / devolo ID", "password": "[%key:common::config_flow::data::password%]", - "mydevolo_url": "mydevolo [%key:common::config_flow::data::url%]" + "mydevolo_url": "mydevolo URL" } } } diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index e927ea9333..809dc9086b 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -53,7 +53,6 @@ SENSOR_TYPES: dict[str, DevoloBinarySensorEntityDescription] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:router-network", - name="Connected to router", value_func=_is_connected_to_router, ), } diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index a26d8dce8f..8b665d7bf0 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -57,4 +57,5 @@ class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]): name=entry.title, sw_version=device.firmware_version, ) + self._attr_translation_key = self.entity_description.key self._attr_unique_id = f"{device.serial_number}_{self.entity_description.key}" diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 2c2637c2f8..aeeab2ce89 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -54,7 +54,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lan", - name="Connected PLC devices", value_func=lambda data: len( {device.mac_address_from for device in data.data_rates} ), @@ -62,7 +61,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription[list[ConnectedStationInfo]]( key=CONNECTED_WIFI_CLIENTS, icon="mdi:wifi", - name="Connected Wifi clients", state_class=SensorStateClass.MEASUREMENT, value_func=len, ), @@ -71,7 +69,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:wifi-marker", - name="Neighboring Wifi networks", value_func=len, ), } diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 6c320710a1..3472886cd5 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -27,5 +27,31 @@ "home_control": "The devolo Home Control Central Unit does not work with this integration.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "connected_to_router": { + "name": "Connected to router" + } + }, + "sensor": { + "connected_plc_devices": { + "name": "Connected PLC devices" + }, + "connected_wifi_clients": { + "name": "Connected Wifi clients" + }, + "neighboring_wifi_networks": { + "name": "Neighboring Wifi networks" + } + }, + "switch": { + "switch_guest_wifi": { + "name": "Enable guest Wifi" + }, + "switch_leds": { + "name": "Enable LEDs" + } + } } } diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index fa2447985d..6f387fdf05 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -42,7 +42,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { SWITCH_GUEST_WIFI: DevoloSwitchEntityDescription[WifiGuestAccessGet]( key=SWITCH_GUEST_WIFI, icon="mdi:wifi", - name="Enable guest Wifi", is_on_func=lambda data: data.enabled is True, turn_on_func=lambda device: device.device.async_set_wifi_guest_access(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_wifi_guest_access(False), # type: ignore[union-attr] @@ -51,7 +50,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { key=SWITCH_LEDS, entity_category=EntityCategory.CONFIG, icon="mdi:led-off", - name="Enable LEDs", is_on_func=bool, turn_on_func=lambda device: device.device.async_set_led_setting(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_led_setting(False), # type: ignore[union-attr] diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 74dd1f66ab..7f41d2c1d3 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -77,7 +77,7 @@ SCAN_INTERVAL = timedelta(minutes=60) _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(slots=True) class DhcpServiceInfo(BaseServiceInfo): """Prepared info from dhcp entries.""" @@ -260,7 +260,10 @@ class NetworkWatcher(WatcherBase): """Start scanning for new devices on the network.""" self._discover_hosts = DiscoverHosts() self._unsub = async_track_time_interval( - self.hass, self.async_start_discover, SCAN_INTERVAL + self.hass, + self.async_start_discover, + SCAN_INTERVAL, + name="DHCP network watcher", ) self.async_start_discover() diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index a5ee449dda..e65966fbaa 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], "quality_scale": "internal", - "requirements": ["scapy==2.5.0", "aiodiscover==1.4.14"] + "requirements": ["scapy==2.5.0", "aiodiscover==1.4.16"] } diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 0ffd6fe49e..53b2478490 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -1,7 +1,7 @@ """Starts a service to scan in intervals for new devices.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import json import logging from typing import NamedTuple @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.const import EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HassJob, HomeAssistant, callback from homeassistant.helpers import discovery_flow import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_discover, async_load_platform @@ -60,7 +60,6 @@ class ServiceDetails(NamedTuple): SERVICE_HANDLERS = { SERVICE_ENIGMA2: ServiceDetails("media_player", "enigma2"), "yamaha": ServiceDetails("media_player", "yamaha"), - "frontier_silicon": ServiceDetails("media_player", "frontier_silicon"), "openhome": ServiceDetails("media_player", "openhome"), "bluesound": ServiceDetails("media_player", "bluesound"), } @@ -203,7 +202,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, service_details.component, service_details.platform, info, config ) - async def scan_devices(now): + async def scan_devices(now: datetime) -> None: """Scan for devices.""" try: results = await hass.async_add_executor_job( @@ -216,13 +215,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: logger.error("Network is unreachable") async_track_point_in_utc_time( - hass, scan_devices, dt_util.utcnow() + SCAN_INTERVAL + hass, scan_devices_job, dt_util.utcnow() + SCAN_INTERVAL ) @callback - def schedule_first(event): + def schedule_first(event: Event) -> None: """Schedule the first discovery when Home Assistant starts up.""" - async_track_point_in_utc_time(hass, scan_devices, dt_util.utcnow()) + async_track_point_in_utc_time(hass, scan_devices_job, dt_util.utcnow()) + + scan_devices_job = HassJob(scan_devices, cancel_on_shutdown=True) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, schedule_first) diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index a866b911f3..eddb2633be 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -767,7 +767,7 @@ class DlnaDmrEntity(MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper. diff --git a/homeassistant/components/dnsip/const.py b/homeassistant/components/dnsip/const.py index a4f2c2fee2..56215d3d9a 100644 --- a/homeassistant/components/dnsip/const.py +++ b/homeassistant/components/dnsip/const.py @@ -15,4 +15,4 @@ DEFAULT_HOSTNAME = "myip.opendns.com" DEFAULT_IPV6 = False DEFAULT_NAME = "myip" DEFAULT_RESOLVER = "208.67.222.222" -DEFAULT_RESOLVER_IPV6 = "2620:0:ccc::2" +DEFAULT_RESOLVER_IPV6 = "2620:119:53::53" diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 5ffa1d4f29..79c114e2f3 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/doods", "iot_class": "local_polling", "loggers": ["pydoods"], - "requirements": ["pydoods==1.0.2", "pillow==9.4.0"] + "requirements": ["pydoods==1.0.2", "pillow==9.5.0"] } diff --git a/homeassistant/components/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py index 2f57d9802b..4903e46b8d 100644 --- a/homeassistant/components/dormakaba_dkey/__init__.py +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -5,7 +5,7 @@ from datetime import timedelta import logging from py_dormakaba_dkey import DKEYLock -from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS +from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS, NotAssociated from py_dormakaba_dkey.models import AssociationData from homeassistant.components import bluetooth @@ -13,7 +13,7 @@ from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackM from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_ASSOCIATION_DATA, DOMAIN, UPDATE_SECONDS @@ -60,6 +60,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await lock.update() await lock.disconnect() + except NotAssociated as ex: + raise ConfigEntryAuthFailed("Not associated") from ex except DKEY_EXCEPTIONS as ex: raise UpdateFailed(str(ex)) from ex diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 3da1fd841f..f03861d015 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Dormakaba dKey integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -12,6 +13,7 @@ from homeassistant import config_entries from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, + async_last_service_info, ) from homeassistant.const import CONF_ADDRESS from homeassistant.data_entry_flow import FlowResult @@ -32,12 +34,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None = None + def __init__(self) -> None: """Initialize the config flow.""" self._lock: DKEYLock | None = None # Populated by user step self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} - # Populated by bluetooth and user steps + # Populated by bluetooth, reauth_confirm and user steps self._discovery_info: BluetoothServiceInfoBleak | None = None async def async_step_user( @@ -113,6 +117,36 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_associate() + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle reauthorization request.""" + 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: + """Handle reauthorization flow.""" + errors = {} + reauth_entry = self._reauth_entry + assert reauth_entry is not None + + if user_input is not None: + if ( + discovery_info := async_last_service_info( + self.hass, reauth_entry.data[CONF_ADDRESS], True + ) + ) is None: + errors = {"base": "no_longer_in_range"} + else: + self._discovery_info = discovery_info + return await self.async_step_associate() + + return self.async_show_form( + step_id="reauth_confirm", data_schema=vol.Schema({}), errors=errors + ) + async def async_step_associate( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -143,14 +177,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: + data = { + CONF_ADDRESS: self._discovery_info.device.address, + CONF_ASSOCIATION_DATA: association_data.to_json(), + } + if reauth_entry := self._reauth_entry: + self.hass.config_entries.async_update_entry(reauth_entry, data=data) + await self.hass.config_entries.async_reload(reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=lock.device_info.device_name or lock.device_info.device_id or lock.name, - data={ - CONF_ADDRESS: self._discovery_info.device.address, - CONF_ASSOCIATION_DATA: association_data.to_json(), - }, + data=data, ) return self.async_show_form( diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index d07deaca82..efe9d3acb5 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -11,6 +11,9 @@ "bluetooth_confirm": { "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" }, + "reauth_confirm": { + "description": "The activation code is no longer valid, a new unused activation code is needed.\n\n" + }, "associate": { "description": "Provide an unused activation code.\n\nTo create an activation code, create a new key in the dKey admin app, then choose to share the key and share an activation code.\n\nMake sure to close the dKey admin app before proceeding.", "data": { @@ -19,6 +22,7 @@ } }, "error": { + "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and try again.", "invalid_code": "Invalid activation code. An activation code consist of 8 characters, separated by a dash, e.g. GBZT-HXC0.", "wrong_code": "Wrong activation code. Note that an activation code can only be used once." }, @@ -26,6 +30,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } } diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index ddf149d680..33bba375fd 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -48,49 +48,49 @@ class DSMRReaderSensorEntityDescription(SensorEntityDescription): SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_delivered_1", - name="Low tariff usage", + translation_key="low_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_returned_1", - name="Low tariff returned", + translation_key="low_tariff_returned", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_delivered_2", - name="High tariff usage", + translation_key="high_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_returned_2", - name="High tariff returned", + translation_key="high_tariff_returned", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_currently_delivered", - name="Current power usage", + translation_key="current_power_usage", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_currently_returned", - name="Current power return", + translation_key="current_power_return", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_currently_delivered_l1", - name="Current power usage L1", + translation_key="current_power_usage_l1", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -98,7 +98,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_currently_delivered_l2", - name="Current power usage L2", + translation_key="current_power_usage_l2", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -106,7 +106,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_currently_delivered_l3", - name="Current power usage L3", + translation_key="current_power_usage_l3", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -114,7 +114,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_currently_returned_l1", - name="Current power return L1", + translation_key="current_power_return_l1", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -122,7 +122,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_currently_returned_l2", - name="Current power return L2", + translation_key="current_power_return_l2", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -130,7 +130,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_currently_returned_l3", - name="Current power return L3", + translation_key="current_power_return_l3", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -138,7 +138,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/extra_device_delivered", - name="Gas meter usage", + translation_key="gas_meter_usage", entity_registry_enabled_default=False, icon="mdi:fire", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, @@ -146,7 +146,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_voltage_l1", - name="Current voltage L1", + translation_key="current_voltage_l1", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -154,7 +154,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_voltage_l2", - name="Current voltage L2", + translation_key="current_voltage_l2", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -162,7 +162,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_voltage_l3", - name="Current voltage L3", + translation_key="current_voltage_l3", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -170,7 +170,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_power_current_l1", - name="Phase power current L1", + translation_key="phase_power_current_l1", entity_registry_enabled_default=False, device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -178,7 +178,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_power_current_l2", - name="Phase power current L2", + translation_key="phase_power_current_l2", entity_registry_enabled_default=False, device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -186,7 +186,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_power_current_l3", - name="Phase power current L3", + translation_key="phase_power_current_l3", entity_registry_enabled_default=False, device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -194,384 +194,386 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( ), DSMRReaderSensorEntityDescription( key="dsmr/reading/timestamp", - name="Telegram timestamp", + translation_key="telegram_timestamp", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, state=dt_util.parse_datetime, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/delivered", - name="Gas usage", + translation_key="gas_usage", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/currently_delivered", - name="Current gas usage", + translation_key="current_gas_usage", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.MEASUREMENT, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/read_at", - name="Gas meter read", + translation_key="gas_meter_read", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, state=dt_util.parse_datetime, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity1", - name="Low tariff usage (daily)", + translation_key="daily_low_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity2", - name="High tariff usage (daily)", + translation_key="daily_high_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity1_returned", - name="Low tariff return (daily)", + translation_key="daily_low_tariff_return", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity2_returned", - name="High tariff return (daily)", + translation_key="daily_high_tariff_return", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity_merged", - name="Power usage total (daily)", + translation_key="daily_power_usage_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity_returned_merged", - name="Power return total (daily)", + translation_key="daily_power_return_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity1_cost", - name="Low tariff cost (daily)", + translation_key="daily_low_tariff_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity2_cost", - name="High tariff cost (daily)", + translation_key="daily_high_tariff_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity_cost_merged", - name="Power total cost (daily)", + translation_key="daily_power_total_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/gas", - name="Gas usage (daily)", + translation_key="daily_gas_usage", icon="mdi:counter", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/gas_cost", - name="Gas cost", + translation_key="gas_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/total_cost", - name="Total cost", + translation_key="total_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1", - name="Low tariff delivered price", + translation_key="low_tariff_delivered_price", icon="mdi:currency-eur", native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2", - name="High tariff delivered price", + translation_key="high_tariff_delivered_price", icon="mdi:currency-eur", native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1", - name="Low tariff returned price", + translation_key="low_tariff_returned_price", icon="mdi:currency-eur", native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2", - name="High tariff returned price", + translation_key="high_tariff_returned_price", icon="mdi:currency-eur", native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_gas", - name="Gas price", + translation_key="gas_price", icon="mdi:currency-eur", native_unit_of_measurement=PRICE_EUR_M3, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/fixed_cost", - name="Current day fixed cost", + translation_key="current_day_fixed_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/dsmr_version", - name="DSMR version", + translation_key="dsmr_version", entity_registry_enabled_default=False, icon="mdi:alert-circle", state=dsmr_transform, ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/electricity_tariff", - name="Electricity tariff", + translation_key="electricity_tariff", + device_class=SensorDeviceClass.ENUM, + options=["low", "high"], icon="mdi:flash", state=tariff_transform, ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/power_failure_count", - name="Power failure count", + translation_key="power_failure_count", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/long_power_failure_count", - name="Long power failure count", + translation_key="long_power_failure_count", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_sag_count_l1", - name="Voltage sag L1", + translation_key="voltage_sag_l1", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_sag_count_l2", - name="Voltage sag L2", + translation_key="voltage_sag_l2", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_sag_count_l3", - name="Voltage sag L3", + translation_key="voltage_sag_l3", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_swell_count_l1", - name="Voltage swell L1", + translation_key="voltage_swell_l1", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_swell_count_l2", - name="Voltage swell L2", + translation_key="voltage_swell_l2", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_swell_count_l3", - name="Voltage swell L3", + translation_key="voltage_swell_l3", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/rejected_telegrams", - name="Rejected telegrams", + translation_key="rejected_telegrams", entity_registry_enabled_default=False, icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity1", - name="Current month low tariff usage", + translation_key="current_month_low_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity2", - name="Current month high tariff usage", + translation_key="current_month_high_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity1_returned", - name="Current month low tariff returned", + translation_key="current_month_low_tariff_returned", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity2_returned", - name="Current month high tariff returned", + translation_key="current_month_high_tariff_returned", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity_merged", - name="Current month power usage total", + translation_key="current_month_power_usage_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity_returned_merged", - name="Current month power return total", + translation_key="current_month_power_return_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity1_cost", - name="Current month low tariff cost", + translation_key="current_month_low_tariff_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity2_cost", - name="Current month high tariff cost", + translation_key="current_month_high_tariff_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity_cost_merged", - name="Current month power total cost", + translation_key="current_month_power_total_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/gas", - name="Current month gas usage", + translation_key="current_month_gas_usage", icon="mdi:counter", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/gas_cost", - name="Current month gas cost", + translation_key="current_month_gas_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/fixed_cost", - name="Current month fixed cost", + translation_key="current_month_fixed_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/total_cost", - name="Current month total cost", + translation_key="current_month_total_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity1", - name="Current year low tariff usage", + translation_key="current_year_low_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity2", - name="Current year high tariff usage", + translation_key="current_year_high_tariff_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity1_returned", - name="Current year low tariff returned", + translation_key="current_year_low_tariff_returned", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity2_returned", - name="Current year high tariff returned", + translation_key="current_year_high_tariff_returned", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity_merged", - name="Current year power usage total", + translation_key="current_year_power_usage_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity_returned_merged", - name="Current year power returned total", + translation_key="current_year_power_returned_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity1_cost", - name="Current year low tariff cost", + translation_key="current_year_low_tariff_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity2_cost", - name="Current year high tariff cost", + translation_key="current_year_high_tariff_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity_cost_merged", - name="Current year power total cost", + translation_key="current_year_power_total_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/gas", - name="Current year gas usage", + translation_key="current_year_gas_usage", icon="mdi:counter", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/gas_cost", - name="Current year gas cost", + translation_key="current_year_gas_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/fixed_cost", - name="Current year fixed cost", + translation_key="current_year_fixed_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/total_cost", - name="Current year total cost", + translation_key="current_year_total_cost", icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/quarter-hour-peak-electricity/average_delivered", - name="Previous quarter-hour peak usage", + translation_key="previous_quarter_hour_peak_usage", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/quarter-hour-peak-electricity/read_at_start", - name="Quarter-hour peak start time", + translation_key="quarter_hour_peak_start_time", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, state=dt_util.parse_datetime, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/quarter-hour-peak-electricity/read_at_end", - name="Quarter-hour peak end time", + translation_key="quarter_hour_peak_end_time", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, state=dt_util.parse_datetime, diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py index 28dc0abb2d..c618995ed4 100644 --- a/homeassistant/components/dsmr_reader/sensor.py +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -23,6 +23,7 @@ async def async_setup_entry( class DSMRSensor(SensorEntity): """Representation of a DSMR sensor that is updated via MQTT.""" + _attr_has_entity_name = True entity_description: DSMRReaderSensorEntityDescription def __init__( diff --git a/homeassistant/components/dsmr_reader/strings.json b/homeassistant/components/dsmr_reader/strings.json index 73c4ac0440..fce274e891 100644 --- a/homeassistant/components/dsmr_reader/strings.json +++ b/homeassistant/components/dsmr_reader/strings.json @@ -8,5 +8,256 @@ "description": "Make sure to configure the 'split topic' data sources in DSMR Reader." } } + }, + "entity": { + "sensor": { + "low_tariff_usage": { + "name": "Low tariff usage" + }, + "low_tariff_returned": { + "name": "Low tariff returned" + }, + "high_tariff_usage": { + "name": "High tariff usage" + }, + "high_tariff_returned": { + "name": "High tariff returned" + }, + "current_power_usage": { + "name": "Current power usage" + }, + "current_power_return": { + "name": "Current power return" + }, + "current_power_usage_l1": { + "name": "Current power usage L1" + }, + "current_power_usage_l2": { + "name": "Current power usage L2" + }, + "current_power_usage_l3": { + "name": "Current power usage L3" + }, + "current_power_return_l1": { + "name": "Current power return L1" + }, + "current_power_return_l2": { + "name": "Current power return L2" + }, + "current_power_return_l3": { + "name": "Current power return L3" + }, + "gas_meter_usage": { + "name": "Gas meter usage" + }, + "current_voltage_l1": { + "name": "Current voltage L1" + }, + "current_voltage_l2": { + "name": "Current voltage L2" + }, + "current_voltage_l3": { + "name": "Current voltage L3" + }, + "phase_power_current_l1": { + "name": "Phase power current L1" + }, + "phase_power_current_l2": { + "name": "Phase power current L2" + }, + "phase_power_current_l3": { + "name": "Phase power current L3" + }, + "telegram_timestamp": { + "name": "Telegram timestamp" + }, + "gas_usage": { + "name": "Gas usage" + }, + "current_gas_usage": { + "name": "Current gas usage" + }, + "gas_meter_read": { + "name": "Gas meter read" + }, + "daily_low_tariff_usage": { + "name": "Low tariff usage (daily)" + }, + "daily_high_tariff_usage": { + "name": "High tariff usage (daily)" + }, + "daily_low_tariff_return": { + "name": "Low tariff return (daily)" + }, + "daily_high_tariff_return": { + "name": "High tariff return (daily)" + }, + "daily_power_usage_total": { + "name": "Power usage total (daily)" + }, + "daily_power_return_total": { + "name": "Power return total (daily)" + }, + "daily_low_tariff_cost": { + "name": "Low tariff cost (daily)" + }, + "daily_high_tariff_cost": { + "name": "High tariff cost (daily)" + }, + "daily_power_total_cost": { + "name": "Power total cost (daily)" + }, + "daily_gas_usage": { + "name": "Gas usage (daily)" + }, + "gas_cost": { + "name": "Gas cost" + }, + "total_cost": { + "name": "Total cost" + }, + "low_tariff_delivered_price": { + "name": "Low tariff delivered price" + }, + "high_tariff_delivered_price": { + "name": "High tariff delivered price" + }, + "low_tariff_returned_price": { + "name": "Low tariff returned price" + }, + "high_tariff_returned_price": { + "name": "High tariff returned price" + }, + "gas_price": { + "name": "Gas Price" + }, + "current_day_fixed_cost": { + "name": "Current day fixed cost" + }, + "dsmr_version": { + "name": "DSMR version" + }, + "electricity_tariff": { + "name": "Electricity tariff", + "state": { + "low": "Low", + "high": "High" + } + }, + "power_failure_count": { + "name": "Power failure count" + }, + "long_power_failure_count": { + "name": "Long power failure count" + }, + "voltage_sag_l1": { + "name": "Voltage sag L1" + }, + "voltage_sag_l2": { + "name": "Voltage sag L2" + }, + "voltage_sag_l3": { + "name": "Voltage sag L3" + }, + "voltage_swell_l1": { + "name": "Voltage swell L1" + }, + "voltage_swell_l2": { + "name": "Voltage swell L2" + }, + "voltage_swell_l3": { + "name": "Voltage swell L3" + }, + "rejected_telegrams": { + "name": "Rejected telegrams" + }, + "current_month_low_tariff_usage": { + "name": "Current month low tariff usage" + }, + "current_month_high_tariff_usage": { + "name": "Current month high tariff usage" + }, + "current_month_low_tariff_returned": { + "name": "Current month low tariff returned" + }, + "current_month_high_tariff_returned": { + "name": "Current month high tariff returned" + }, + "current_month_power_usage_total": { + "name": "Current month power usage total" + }, + "current_month_power_return_total": { + "name": "Current month power return total" + }, + "current_month_low_tariff_cost": { + "name": "Current month low tariff cost" + }, + "current_month_high_tariff_cost": { + "name": "Current month high tariff cost" + }, + "current_month_power_total_cost": { + "name": "Current month power total cost" + }, + "current_month_gas_usage": { + "name": "Current month gas usage" + }, + "current_month_gas_cost": { + "name": "Current month gas cost" + }, + "current_month_fixed_cost": { + "name": "Current month fixed cost" + }, + "current_month_total_cost": { + "name": "Current month total cost" + }, + "current_year_low_tariff_usage": { + "name": "Current year low tariff usage" + }, + "current_year_high_tariff_usage": { + "name": "Current year high tariff usage" + }, + "current_year_low_tariff_returned": { + "name": "Current year low tariff returned" + }, + "current_year_high_tariff_returned": { + "name": "Current year high tariff returned" + }, + "current_year_power_usage_total": { + "name": "Current year power usage total" + }, + "current_year_power_returned_total": { + "name": "Current year power returned total" + }, + "current_year_low_tariff_cost": { + "name": "Current year low tariff cost" + }, + "current_year_high_tariff_cost": { + "name": "Current year high tariff cost" + }, + "current_year_power_total_cost": { + "name": "Current year power total cost" + }, + "current_year_gas_usage": { + "name": "Current year gas usage" + }, + "current_year_gas_cost": { + "name": "Current year gas cost" + }, + "current_year_fixed_cost": { + "name": "Current year fixed cost" + }, + "current_year_total_cost": { + "name": "Current year total cost" + }, + "previous_quarter_hour_peak_usage": { + "name": "Previous quarter-hour peak usage" + }, + "quarter_hour_peak_start_time": { + "name": "Quarter-hour peak start time" + }, + "quarter_hour_peak_end_time": { + "name": "Quarter-hour peak end time" + } + } } } diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index 60d058220a..b50bd60476 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -32,7 +32,7 @@ CONF_STOP_ID = "stopid" CONF_ROUTE = "route" DEFAULT_NAME = "Next Bus" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) TIME_STR_FORMAT = "%H:%M" @@ -77,6 +77,7 @@ class DublinPublicTransportSensor(SensorEntity): """Implementation of an Dublin public transport sensor.""" _attr_attribution = "Data provided by data.dublinked.ie" + _attr_icon = "mdi:bus" def __init__(self, data, stop, route, name): """Initialize the sensor.""" @@ -118,11 +119,6 @@ class DublinPublicTransportSensor(SensorEntity): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from opendata.ch and update the states.""" self.data.update() diff --git a/homeassistant/components/dwd_weather_warnings/const.py b/homeassistant/components/dwd_weather_warnings/const.py new file mode 100644 index 0000000000..af8786f8d7 --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/const.py @@ -0,0 +1,33 @@ +"""Constants for the dwd_weather_warnings integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +LOGGER = logging.getLogger(__package__) + +CONF_REGION_NAME: Final = "region_name" + +ATTR_REGION_NAME: Final = "region_name" +ATTR_REGION_ID: Final = "region_id" +ATTR_LAST_UPDATE: Final = "last_update" +ATTR_WARNING_COUNT: Final = "warning_count" + +API_ATTR_WARNING_NAME: Final = "event" +API_ATTR_WARNING_TYPE: Final = "event_code" +API_ATTR_WARNING_LEVEL: Final = "level" +API_ATTR_WARNING_HEADLINE: Final = "headline" +API_ATTR_WARNING_DESCRIPTION: Final = "description" +API_ATTR_WARNING_INSTRUCTION: Final = "instruction" +API_ATTR_WARNING_START: Final = "start_time" +API_ATTR_WARNING_END: Final = "end_time" +API_ATTR_WARNING_PARAMETERS: Final = "parameters" +API_ATTR_WARNING_COLOR: Final = "color" + +CURRENT_WARNING_SENSOR: Final = "current_warning_level" +ADVANCE_WARNING_SENSOR: Final = "advance_warning_level" + +DEFAULT_NAME: Final = "DWD-Weather-Warnings" +DEFAULT_SCAN_INTERVAL: Final = timedelta(minutes=15) diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index a76b8eeee8..2a22d5f8fb 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -1,9 +1,9 @@ { "domain": "dwd_weather_warnings", "name": "Deutscher Wetterdienst (DWD) Weather Warnings", - "codeowners": ["@runningman84", "@stephan192", "@Hummel95"], + "codeowners": ["@runningman84", "@stephan192", "@Hummel95", "@andarotajo"], "documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings", "iot_class": "cloud_polling", "loggers": ["dwdwfsapi"], - "requirements": ["dwdwfsapi==1.0.5"] + "requirements": ["dwdwfsapi==1.0.6"] } diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 531eb1b370..054d9e5ca8 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -10,9 +10,6 @@ Wetterwarnungen (Stufe 1) """ from __future__ import annotations -from datetime import timedelta -import logging - from dwdwfsapi import DwdWeatherWarningsAPI import voluptuous as vol @@ -28,33 +25,28 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle -_LOGGER = logging.getLogger(__name__) - -ATTR_REGION_NAME = "region_name" -ATTR_REGION_ID = "region_id" -ATTR_LAST_UPDATE = "last_update" -ATTR_WARNING_COUNT = "warning_count" - -API_ATTR_WARNING_NAME = "event" -API_ATTR_WARNING_TYPE = "event_code" -API_ATTR_WARNING_LEVEL = "level" -API_ATTR_WARNING_HEADLINE = "headline" -API_ATTR_WARNING_DESCRIPTION = "description" -API_ATTR_WARNING_INSTRUCTION = "instruction" -API_ATTR_WARNING_START = "start_time" -API_ATTR_WARNING_END = "end_time" -API_ATTR_WARNING_PARAMETERS = "parameters" -API_ATTR_WARNING_COLOR = "color" - -DEFAULT_NAME = "DWD-Weather-Warnings" - -CONF_REGION_NAME = "region_name" - -CURRENT_WARNING_SENSOR = "current_warning_level" -ADVANCE_WARNING_SENSOR = "advance_warning_level" - -SCAN_INTERVAL = timedelta(minutes=15) - +from .const import ( + ADVANCE_WARNING_SENSOR, + API_ATTR_WARNING_COLOR, + API_ATTR_WARNING_DESCRIPTION, + API_ATTR_WARNING_END, + API_ATTR_WARNING_HEADLINE, + API_ATTR_WARNING_INSTRUCTION, + API_ATTR_WARNING_LEVEL, + API_ATTR_WARNING_NAME, + API_ATTR_WARNING_PARAMETERS, + API_ATTR_WARNING_START, + API_ATTR_WARNING_TYPE, + ATTR_LAST_UPDATE, + ATTR_REGION_ID, + ATTR_REGION_NAME, + ATTR_WARNING_COUNT, + CONF_REGION_NAME, + CURRENT_WARNING_SENSOR, + DEFAULT_NAME, + DEFAULT_SCAN_INTERVAL, + LOGGER, +) SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -169,7 +161,7 @@ class DwdWeatherWarningsSensor(SensorEntity): def update(self) -> None: """Get the latest data from the DWD-Weather-Warnings API.""" - _LOGGER.debug( + LOGGER.debug( "Update requested for %s (%s) by %s", self._api.api.warncell_name, self._api.api.warncell_id, @@ -185,8 +177,8 @@ class WrappedDwDWWAPI: """Initialize a DWD-Weather-Warnings wrapper.""" self.api = api - @Throttle(SCAN_INTERVAL) + @Throttle(DEFAULT_SCAN_INTERVAL) def update(self): """Get the latest data from the DWD-Weather-Warnings API.""" self.api.update() - _LOGGER.debug("Update performed") + LOGGER.debug("Update performed") diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index fc0a4fd773..5755a1b3db 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==0.2.1"] + "requirements": ["easyenergy==0.3.0"] } diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index 0df5b9bd8c..9cf5944dfa 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -13,7 +13,13 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CURRENCY_EURO, PERCENTAGE, UnitOfEnergy, UnitOfVolume +from homeassistant.const import ( + CURRENCY_EURO, + PERCENTAGE, + UnitOfEnergy, + UnitOfTime, + UnitOfVolume, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -175,6 +181,22 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( icon="mdi:percent", value_fn=lambda data: data.energy_today.pct_of_max_return, ), + EasyEnergySensorEntityDescription( + key="hours_priced_equal_or_lower", + name="Hours priced equal or lower than current - today", + service_type="today_energy_usage", + native_unit_of_measurement=UnitOfTime.HOURS, + icon="mdi:clock", + value_fn=lambda data: data.energy_today.hours_priced_equal_or_lower_usage, + ), + EasyEnergySensorEntityDescription( + key="hours_priced_equal_or_higher", + name="Hours priced equal or higher than current - today", + service_type="today_energy_return", + native_unit_of_measurement=UnitOfTime.HOURS, + icon="mdi:clock", + value_fn=lambda data: data.energy_today.hours_priced_equal_or_higher_return, + ), ) diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 10e7f06a15..3472ca231e 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/econet", "iot_class": "cloud_push", "loggers": ["paho_mqtt", "pyeconet"], - "requirements": ["pyeconet==0.1.18"] + "requirements": ["pyeconet==0.1.20"] } diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index f6363473de..034d3fcae2 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.9"] + "requirements": ["pysml==0.0.10"] } diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 68b874149a..df9606475f 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -290,7 +290,7 @@ async def async_setup_platform( hass, DOMAIN, "deprecated_yaml", - breaks_in_ha_version="2023.2.0", + breaks_in_ha_version="2023.6.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", @@ -413,12 +413,6 @@ class EDL21Entity(SensorEntity): self._telegram = telegram self._min_time = MIN_TIME_BETWEEN_UPDATES self._last_update = utcnow() - self._state_attrs = { - "status": "status", - "valTime": "val_time", - "scaler": "scaler", - "valueSignature": "value_signature", - } self._async_remove_dispatcher = None self.entity_description = entity_description self._attr_unique_id = f"{electricity_id}_{obis}" @@ -462,15 +456,6 @@ class EDL21Entity(SensorEntity): """Return the value of the last received telegram.""" return self._telegram.get("value") - @property - def extra_state_attributes(self) -> Mapping[str, Any]: - """Enumerate supported attributes.""" - return { - self._state_attrs[k]: v - for k, v in self._telegram.items() - if k in self._state_attrs - } - @property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" diff --git a/homeassistant/components/egardia/manifest.json b/homeassistant/components/egardia/manifest.json index d836e64c40..99f39c99cb 100644 --- a/homeassistant/components/egardia/manifest.json +++ b/homeassistant/components/egardia/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/egardia", "iot_class": "local_polling", "loggers": ["pythonegardia"], - "requirements": ["pythonegardia==1.0.40"] + "requirements": ["pythonegardia==1.0.52"] } diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index 0dd602f1ec..97673a79b9 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -40,14 +40,14 @@ class ElgatoButtonEntityDescription( BUTTONS = [ ElgatoButtonEntityDescription( key="identify", - name="Identify", + translation_key="identify", icon="mdi:help", entity_category=EntityCategory.CONFIG, press_fn=lambda client: client.identify(), ), ElgatoButtonEntityDescription( key="restart", - name="Restart", + translation_key="restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_fn=lambda client: client.restart(), diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index 2692cf1085..371840de01 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -45,7 +45,7 @@ class ElgatoSensorEntityDescription( SENSORS = [ ElgatoSensorEntityDescription( key="battery", - name="Battery", + translation_key="battery", device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, @@ -56,7 +56,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="voltage", - name="Battery voltage", + translation_key="voltage", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -69,7 +69,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="input_charge_current", - name="Charging current", + translation_key="input_charge_current", entity_registry_enabled_default=False, device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -82,7 +82,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="charge_power", - name="Charging power", + translation_key="charge_power", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, @@ -94,7 +94,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="input_charge_voltage", - name="Charging voltage", + translation_key="input_charge_voltage", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index fc0007ac30..c5fc016aeb 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -21,5 +21,40 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "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" + }, + "input_charge_current": { + "name": "Charging current" + }, + "input_charge_voltage": { + "name": "Charging voltage" + }, + "voltage": { + "name": "Battery voltage" + } + }, + "switch": { + "bypass": { + "name": "Studio mode" + }, + "energy_saving": { + "name": "Energy saving" + } + } } } diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index 0015909971..78af3adfa5 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -39,7 +39,7 @@ class ElgatoSwitchEntityDescription( SWITCHES = [ ElgatoSwitchEntityDescription( key="bypass", - name="Studio mode", + translation_key="bypass", icon="mdi:battery-off-outline", entity_category=EntityCategory.CONFIG, has_fn=lambda x: x.battery is not None, @@ -48,7 +48,7 @@ SWITCHES = [ ), ElgatoSwitchEntityDescription( key="energy_saving", - name="Energy saving", + translation_key="energy_saving", icon="mdi:leaf", entity_category=EntityCategory.CONFIG, has_fn=lambda x: x.battery is not None, diff --git a/homeassistant/components/elmax/alarm_control_panel.py b/homeassistant/components/elmax/alarm_control_panel.py index 33b8749cb4..40c84efc60 100644 --- a/homeassistant/components/elmax/alarm_control_panel.py +++ b/homeassistant/components/elmax/alarm_control_panel.py @@ -83,6 +83,9 @@ class ElmaxArea(ElmaxEntity, AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" + # Elmax alarm panels do always require a code to be passed for disarm operations + if code is None or code == "": + raise ValueError("Please input the disarm code.") await self.coordinator.http_client.execute_command( endpoint_id=self._device.endpoint_id, command=AreaCommand.DISARM, diff --git a/homeassistant/components/elmax/binary_sensor.py b/homeassistant/components/elmax/binary_sensor.py index 71588b4687..6eb4cd654c 100644 --- a/homeassistant/components/elmax/binary_sensor.py +++ b/homeassistant/components/elmax/binary_sensor.py @@ -44,11 +44,14 @@ async def async_setup_entry( coordinator=coordinator, ) entities.append(entity) - async_add_entities(entities, True) - known_devices.update([e.unique_id for e in entities]) + + if entities: + async_add_entities(entities) + known_devices.update([e.unique_id for e in entities]) # Register a listener for the discovery of new devices - coordinator.async_add_listener(_discover_new_devices) + remove_handle = coordinator.async_add_listener(_discover_new_devices) + config_entry.async_on_unload(remove_handle) # Immediately run a discovery, so we don't need to wait for the next update _discover_new_devices() diff --git a/homeassistant/components/elmax/common.py b/homeassistant/components/elmax/common.py index f1ffe87fde..5334da2312 100644 --- a/homeassistant/components/elmax/common.py +++ b/homeassistant/components/elmax/common.py @@ -11,6 +11,7 @@ from elmax_api.exceptions import ( ElmaxBadLoginError, ElmaxBadPinError, ElmaxNetworkError, + ElmaxPanelBusyError, ) from elmax_api.http import Elmax from elmax_api.model.actuator import Actuator @@ -124,6 +125,10 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): raise ConfigEntryAuthFailed("Refused username/password") from err except ElmaxApiError as err: raise UpdateFailed(f"Error communicating with ELMAX API: {err}") from err + except ElmaxPanelBusyError as err: + raise UpdateFailed( + "Communication with the panel failed, as it is currently busy" + ) from err except ElmaxNetworkError as err: raise UpdateFailed( "A network error occurred while communicating with Elmax cloud." diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py index 0c1a014820..5b9bb3b108 100644 --- a/homeassistant/components/elmax/config_flow.py +++ b/homeassistant/components/elmax/config_flow.py @@ -32,6 +32,14 @@ LOGIN_FORM_SCHEMA = vol.Schema( } ) +REAUTH_FORM_SCHEMA = vol.Schema( + { + vol.Required(CONF_ELMAX_USERNAME): str, + vol.Required(CONF_ELMAX_PASSWORD): str, + vol.Required(CONF_ELMAX_PANEL_PIN): str, + } +) + def _store_panel_by_name( panel: PanelEntry, username: str, panel_names: dict[str, str] @@ -56,8 +64,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _password: str _panels_schema: vol.Schema _panel_names: dict - _reauth_username: str | None - _reauth_panelid: str | None + _entry: config_entries.ConfigEntry | None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -170,82 +177,64 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - self._reauth_username = entry_data.get(CONF_ELMAX_USERNAME) - self._reauth_panelid = entry_data.get(CONF_ELMAX_PANEL_ID) + self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle reauthorization flow.""" errors = {} if user_input is not None: - panel_pin = user_input.get(CONF_ELMAX_PANEL_PIN) - password = user_input.get(CONF_ELMAX_PASSWORD) - entry = await self.async_set_unique_id(self._reauth_panelid) + username = user_input[CONF_ELMAX_USERNAME] + password = user_input[CONF_ELMAX_PASSWORD] + panel_pin = user_input[CONF_ELMAX_PANEL_PIN] # Handle authentication, make sure the panel we are re-authenticating against is listed among results # and verify its pin is correct. + assert self._entry is not None try: # Test login. - client = await self._async_login( - username=self._reauth_username, password=password - ) - + client = await self._async_login(username=username, password=password) # Make sure the panel we are authenticating to is still available. panels = [ p for p in await client.list_control_panels() - if p.hash == self._reauth_panelid + if p.hash == self._entry.data[CONF_ELMAX_PANEL_ID] ] if len(panels) < 1: raise NoOnlinePanelsError() - # Verify the pin is still valid.from + # Verify the pin is still valid. await client.get_panel_status( - control_panel_id=self._reauth_panelid, pin=panel_pin + control_panel_id=self._entry.data[CONF_ELMAX_PANEL_ID], + pin=panel_pin, ) - # If it is, proceed with configuration update. - self.hass.config_entries.async_update_entry( - entry, - data={ - CONF_ELMAX_PANEL_ID: self._reauth_panelid, - CONF_ELMAX_PANEL_PIN: panel_pin, - CONF_ELMAX_USERNAME: self._reauth_username, - CONF_ELMAX_PASSWORD: password, - }, - ) - await self.hass.config_entries.async_reload(entry.entry_id) - self._reauth_username = None - self._reauth_panelid = None - return self.async_abort(reason="reauth_successful") - except ElmaxBadLoginError: - _LOGGER.error( - "Wrong credentials or failed login while re-authenticating" - ) errors["base"] = "invalid_auth" except NoOnlinePanelsError: - _LOGGER.warning( - "Panel ID %s is no longer associated to this user", - self._reauth_panelid, - ) errors["base"] = "reauth_panel_disappeared" except ElmaxBadPinError: errors["base"] = "invalid_pin" - # We want the user to re-authenticate only for the given panel id using the same login. - # We pin them to the UI, so the user realizes she must log in with the appropriate credentials - # for the that specific panel. - schema = vol.Schema( - { - vol.Required(CONF_ELMAX_USERNAME): self._reauth_username, - vol.Required(CONF_ELMAX_PASSWORD): str, - vol.Required(CONF_ELMAX_PANEL_ID): self._reauth_panelid, - vol.Required(CONF_ELMAX_PANEL_PIN): str, - } - ) + # If all went right, update the config entry + if not errors: + self.hass.config_entries.async_update_entry( + self._entry, + data={ + CONF_ELMAX_PANEL_ID: self._entry.data[CONF_ELMAX_PANEL_ID], + CONF_ELMAX_PANEL_PIN: panel_pin, + CONF_ELMAX_USERNAME: username, + CONF_ELMAX_PASSWORD: password, + }, + ) + await self.hass.config_entries.async_reload(self._entry.entry_id) + return self.async_abort(reason="reauth_successful") + + # Otherwise start over and show the relative error message return self.async_show_form( - step_id="reauth_confirm", data_schema=schema, errors=errors + step_id="reauth_confirm", data_schema=REAUTH_FORM_SCHEMA, errors=errors ) @staticmethod diff --git a/homeassistant/components/elmax/manifest.json b/homeassistant/components/elmax/manifest.json index 6c77277634..e6e8d76be9 100644 --- a/homeassistant/components/elmax/manifest.json +++ b/homeassistant/components/elmax/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/elmax", "iot_class": "cloud_polling", "loggers": ["elmax_api"], - "requirements": ["elmax_api==0.0.2"] + "requirements": ["elmax_api==0.0.4"] } diff --git a/homeassistant/components/elmax/strings.json b/homeassistant/components/elmax/strings.json index a9c823f3a1..e8cdbe23a5 100644 --- a/homeassistant/components/elmax/strings.json +++ b/homeassistant/components/elmax/strings.json @@ -15,6 +15,14 @@ "panel_id": "Panel ID", "panel_pin": "PIN Code" } + }, + "reauth_confirm": { + "description": "Please re-authenticate with the panel.", + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]", + "panel_pin": "Panel Pin" + } } }, "error": { @@ -22,10 +30,12 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "network_error": "A network error occurred", "invalid_pin": "The provided pin is invalid", + "reauth_panel_disappeared": "The given panel is no longer associated to this user. Please log in using an account associated to this panel.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/homeassistant/components/elmax/switch.py b/homeassistant/components/elmax/switch.py index e8de2986b9..431e75a088 100644 --- a/homeassistant/components/elmax/switch.py +++ b/homeassistant/components/elmax/switch.py @@ -36,19 +36,24 @@ async def async_setup_entry( # Otherwise, add all the entities we found entities = [] for actuator in panel_status.actuators: + # Skip already handled devices + if actuator.endpoint_id in known_devices: + continue entity = ElmaxSwitch( panel=coordinator.panel_entry, elmax_device=actuator, panel_version=panel_status.release, coordinator=coordinator, ) - if entity.unique_id not in known_devices: - entities.append(entity) - async_add_entities(entities, True) - known_devices.update([entity.unique_id for entity in entities]) + entities.append(entity) + + if entities: + async_add_entities(entities) + known_devices.update([entity.unique_id for entity in entities]) # Register a listener for the discovery of new devices - coordinator.async_add_listener(_discover_new_devices) + remove_handle = coordinator.async_add_listener(_discover_new_devices) + config_entry.async_on_unload(remove_handle) # Immediately run a discovery, so we don't need to wait for the next update _discover_new_devices() diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 41c25943a7..f779f5d8e9 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -64,6 +64,7 @@ from homeassistant.const import ( ) from homeassistant.core import State from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.util.json import json_loads from homeassistant.util.network import is_local from .config import Config @@ -138,7 +139,7 @@ class HueUsernameView(HomeAssistantView): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) try: - data = await request.json() + data = await request.json(loads=json_loads) except ValueError: return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 10ddf5bf67..eea3f18adc 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense_energy==0.11.1"] + "requirements": ["sense_energy==0.11.2"] } diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 6f16d2dc83..b2b29760e5 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -74,7 +74,7 @@ async def async_setup_platform( await sensor_manager.async_start() -@dataclass +@dataclass(slots=True) class SourceAdapter: """Adapter to allow sources and their flows to be used as sensors.""" diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 0a89c3d927..f1eb7591e8 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -107,7 +107,7 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] | return None -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class ValidationIssue: """Error or warning message.""" @@ -118,7 +118,7 @@ class ValidationIssue: translation_placeholders: dict[str, str] | None = None -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class ValidationIssues: """Container for validation issues.""" @@ -142,7 +142,7 @@ class ValidationIssues: issue.affected_entities.add((affected_entity, detail)) -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class EnergyPreferencesValidation: """Dictionary holding validation information.""" diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 8bdfb36ad7..05d23ca446 100644 --- a/homeassistant/components/energyzero/manifest.json +++ b/homeassistant/components/energyzero/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/energyzero", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["energyzero==0.3.1"] + "requirements": ["energyzero==0.4.1"] } diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index cd3235f1be..4a105e5a06 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -33,7 +33,6 @@ SENSORS = ( key="seven_days_production", name="Last Seven Days Energy Production", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( @@ -61,7 +60,6 @@ SENSORS = ( key="seven_days_consumption", name="Last Seven Days Energy Consumption", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index c2c2485d94..17e0ed6e2a 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.29"] + "requirements": ["env_canada==0.5.33"] } diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 192a19e480..3bec7f883d 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -5,12 +5,11 @@ from collections.abc import Callable import functools import logging import math -from typing import Any, Generic, NamedTuple, TypeVar, cast, overload +from typing import Any, Generic, NamedTuple, TypeVar, cast from aioesphomeapi import ( APIClient, APIConnectionError, - APIIntEnum, APIVersion, DeviceInfo as EsphomeDeviceInfo, EntityCategory as EsphomeEntityCategory, @@ -23,6 +22,7 @@ from aioesphomeapi import ( RequiresEncryptionAPIError, UserService, UserServiceArgType, + VoiceAssistantEventType, ) from awesomeversion import AwesomeVersion import voluptuous as vol @@ -64,13 +64,15 @@ from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import RuntimeEntryData +from .enum_mapper import EsphomeEnumMapper +from .voice_assistant import VoiceAssistantUDPServer CONF_DEVICE_NAME = "device_name" CONF_NOISE_PSK = "noise_psk" _LOGGER = logging.getLogger(__name__) _R = TypeVar("_R") -STABLE_BLE_VERSION_STR = "2022.12.4" +STABLE_BLE_VERSION_STR = "2023.4.0" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", @@ -284,6 +286,42 @@ async def async_setup_entry( # noqa: C901 _send_home_assistant_state(entity_id, attribute, hass.states.get(entity_id)) ) + voice_assistant_udp_server: VoiceAssistantUDPServer | None = None + + def handle_pipeline_event( + event_type: VoiceAssistantEventType, data: dict[str, str] | None + ) -> None: + """Handle a voice assistant pipeline event.""" + cli.send_voice_assistant_event(event_type, data) + + 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) + port = await voice_assistant_udp_server.start_server() + + hass.async_create_background_task( + voice_assistant_udp_server.run_pipeline(handle_pipeline_event), + "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 + + entry_data.async_set_assist_pipeline_state(False) + + if voice_assistant_udp_server is not None: + voice_assistant_udp_server.stop() + voice_assistant_udp_server = None + async def on_connect() -> None: """Subscribe to states and list entities on successful API login.""" nonlocal device_id @@ -328,6 +366,14 @@ async def async_setup_entry( # noqa: C901 await cli.subscribe_service_calls(async_on_service_call) await cli.subscribe_home_assistant_states(async_on_state_subscription) + if device_info.voice_assistant_version: + entry_data.disconnect_callbacks.append( + await cli.subscribe_voice_assistant( + handle_pipeline_start, + handle_pipeline_stop, + ) + ) + hass.async_create_task(entry_data.async_save_to_store()) except APIConnectionError as err: _LOGGER.warning("Error getting initial data for %s: %s", host, err) @@ -345,11 +391,19 @@ async def async_setup_entry( # noqa: C901 disconnect_cb() entry_data.disconnect_callbacks = [] entry_data.available = False - # Clear out the states so that we will always dispatch + # Mark state as stale so that we will always dispatch # the next state update of that type when the device reconnects - for state_keys in entry_data.state.values(): - state_keys.clear() - entry_data.async_update_device_state(hass) + 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.""" @@ -682,41 +736,6 @@ def esphome_state_property( return _wrapper -_EnumT = TypeVar("_EnumT", bound=APIIntEnum) -_ValT = TypeVar("_ValT") - - -class EsphomeEnumMapper(Generic[_EnumT, _ValT]): - """Helper class to convert between hass and esphome enum values.""" - - def __init__(self, mapping: dict[_EnumT, _ValT]) -> None: - """Construct a EsphomeEnumMapper.""" - # Add none mapping - augmented_mapping: dict[ - _EnumT | None, _ValT | None - ] = mapping # type: ignore[assignment] - augmented_mapping[None] = None - - self._mapping = augmented_mapping - self._inverse: dict[_ValT, _EnumT] = {v: k for k, v in mapping.items()} - - @overload - def from_esphome(self, value: _EnumT) -> _ValT: - ... - - @overload - def from_esphome(self, value: _EnumT | None) -> _ValT | None: - ... - - def from_esphome(self, value: _EnumT | None) -> _ValT | None: - """Convert from an esphome int representation to a hass string.""" - return self._mapping[value] - - def from_hass(self, value: _ValT) -> _EnumT: - """Convert from a hass string to a esphome int representation.""" - return self._inverse[value] - - ICON_SCHEMA = vol.Schema(cv.icon) @@ -878,3 +897,40 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): if not self._static_info.entity_category: return None return ENTITY_CATEGORIES.from_esphome(self._static_info.entity_category) + + +class EsphomeAssistEntity(Entity): + """Define a base entity for Assist Pipeline entities.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, entry_data: RuntimeEntryData) -> None: + """Initialize the binary sensor.""" + self._entry_data: RuntimeEntryData = entry_data + self._attr_unique_id = ( + f"{self._device_info.mac_address}-{self.entity_description.key}" + ) + + @property + def _device_info(self) -> EsphomeDeviceInfo: + assert self._entry_data.device_info is not None + return self._entry_data.device_info + + @property + def device_info(self) -> DeviceInfo: + """Return device registry information for this entity.""" + return DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)} + ) + + @callback + def _update(self) -> None: + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register update callback.""" + await super().async_added_to_hass() + self.async_on_remove( + self._entry_data.async_subscribe_assist_pipeline_update(self._update) + ) diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 1a930435e6..ccfa430688 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -6,13 +6,15 @@ from aioesphomeapi import BinarySensorInfo, BinarySensorState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from . import EsphomeEntity, platform_async_setup_entry +from . import EsphomeAssistEntity, EsphomeEntity, platform_async_setup_entry +from .domain_data import DomainData async def async_setup_entry( @@ -29,6 +31,11 @@ async def async_setup_entry( state_type=BinarySensorState, ) + entry_data = DomainData.get(hass).get_entry_data(entry) + assert entry_data.device_info is not None + if entry_data.device_info.voice_assistant_version: + async_add_entities([EsphomeCallActiveBinarySensor(entry_data)]) + class EsphomeBinarySensor( EsphomeEntity[BinarySensorInfo, BinarySensorState], BinarySensorEntity @@ -59,3 +66,17 @@ class EsphomeBinarySensor( if self._static_info.is_status_binary_sensor: return True return super().available + + +class EsphomeCallActiveBinarySensor(EsphomeAssistEntity, BinarySensorEntity): + """A binary sensor implementation for ESPHome for use with assist_pipeline.""" + + entity_description = BinarySensorEntityDescription( + key="call_active", + translation_key="call_active", + ) + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self._entry_data.assist_pipeline_state diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index 4a70b906b1..e62b54655c 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -78,15 +78,18 @@ async def async_connect_scanner( scanner = ESPHomeScanner( hass, source, entry.title, new_info_callback, connector, connectable ) + if connectable: + # If its connectable be sure not to register the scanner + # until we know the connection is fully setup since otherwise + # there is a race condition where the connection can fail + await cli.subscribe_bluetooth_connections_free( + entry_data.async_update_ble_connection_limits + ) unload_callbacks = [ async_register_scanner(hass, scanner, connectable), scanner.async_setup(), ] await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) - if connectable: - await cli.subscribe_bluetooth_connections_free( - entry_data.async_update_ble_connection_limits - ) @hass_callback def _async_unload() -> None: diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 71d081ff6a..914021b467 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -44,6 +44,7 @@ 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__) @@ -322,15 +323,24 @@ class ESPHomeClient(BaseBleakClient): address_type=self._address_type, ) ) + except asyncio.CancelledError: + if connected_future.done(): + with contextlib.suppress(BleakError): + # If we are cancelled while connecting, + # we need to make sure we await the future + # to avoid a warning about an un-retrieved + # exception. + await connected_future + raise except Exception: - with contextlib.suppress(BleakError): - # If the connect call throws an exception, - # we need to make sure we await the future - # to avoid a warning about an un-retrieved - # exception since we prefer to raise the - # exception from the connect call as it - # will be more descriptive. - if connected_future.done(): + if connected_future.done(): + with contextlib.suppress(BleakError): + # If the connect call throws an exception, + # we need to make sure we await the future + # to avoid a warning about an un-retrieved + # exception since we prefer to raise the + # exception from the connect call as it + # will be more descriptive. await connected_future connected_future.cancel() raise @@ -509,10 +519,28 @@ class ESPHomeClient(BaseBleakClient): raise BleakError(f"Characteristic {char_specifier} was not found!") return characteristic - async def clear_cache(self) -> None: + @api_error_as_bleak_error + 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: + _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, + ) + return True + response = await self._client.bluetooth_device_clear_cache(self._address_as_int) + if response.success: + return True + _LOGGER.error( + "Clear cache failed with %s failed due to error: %s", + self.address, + response.error, + ) + return False @verify_connected @api_error_as_bleak_error diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py index 19d5984374..6151ed3042 100644 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -2,6 +2,7 @@ from __future__ import annotations from aioesphomeapi import BluetoothLEAdvertisement +from bluetooth_data_tools import int_to_bluetooth_address from homeassistant.components.bluetooth import BaseHaRemoteScanner from homeassistant.core import callback @@ -14,9 +15,8 @@ class ESPHomeScanner(BaseHaRemoteScanner): def async_on_advertisement(self, adv: BluetoothLEAdvertisement) -> None: """Call the registered callback.""" # The mac address is a uint64, but we need a string - mac_hex = f"{adv.address:012X}" self._async_on_advertisement( - f"{mac_hex[0:2]}:{mac_hex[2:4]}:{mac_hex[4:6]}:{mac_hex[6:8]}:{mac_hex[8:10]}:{mac_hex[10:12]}", + int_to_bluetooth_address(adv.address), adv.rssi, adv.name, adv.service_uuids, diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index bfb3dbb866..e40df234d5 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -54,12 +54,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper FAN_QUIET = "quiet" diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 99d5f16b27..9d82b28529 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from aioesphomeapi import CoverInfo, CoverOperation, CoverState +from aioesphomeapi import APIVersion, CoverInfo, CoverOperation, CoverState from homeassistant.components.cover import ( ATTR_POSITION, @@ -41,9 +41,10 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" - flags = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP - ) + flags = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + if self._api_version < APIVersion(1, 8) or self._static_info.supports_stop: + flags |= CoverEntityFeature.STOP if self._static_info.supports_position: flags |= CoverEntityFeature.SET_POSITION if self._static_info.supports_tilt: diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index d7f25f319a..61d6262250 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -70,6 +70,10 @@ class RuntimeEntryData: client: APIClient store: Store 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 @@ -95,6 +99,10 @@ class RuntimeEntryData: _ble_connection_free_futures: list[asyncio.Future[int]] = field( default_factory=list ) + assist_pipeline_update_callbacks: list[Callable[[], None]] = field( + default_factory=list + ) + assist_pipeline_state: bool = False @property def name(self) -> str: @@ -149,6 +157,24 @@ class RuntimeEntryData: self._ble_connection_free_futures.append(fut) return await fut + @callback + def async_set_assist_pipeline_state(self, state: bool) -> None: + """Set the assist pipeline state.""" + self.assist_pipeline_state = state + for update_callback in self.assist_pipeline_update_callbacks: + update_callback() + + def async_subscribe_assist_pipeline_update( + self, update_callback: Callable[[], None] + ) -> Callable[[], None]: + """Subscribe to assist pipeline updates.""" + + def _unsubscribe() -> None: + self.assist_pipeline_update_callbacks.remove(update_callback) + + self.assist_pipeline_update_callbacks.append(update_callback) + return _unsubscribe + @callback def async_remove_entity( self, hass: HomeAssistant, component_key: str, key: int @@ -176,6 +202,10 @@ class RuntimeEntryData: if async_get_dashboard(hass): needed_platforms.add(Platform.UPDATE) + if self.device_info is not None and self.device_info.voice_assistant_version: + needed_platforms.add(Platform.BINARY_SENSOR) + needed_platforms.add(Platform.SELECT) + for info in infos: for info_type, platform in INFO_TYPE_TO_PLATFORM.items(): if isinstance(info, info_type): @@ -206,9 +236,11 @@ class RuntimeEntryData: """Distribute an update of state information to the target.""" key = state.key state_type = type(state) + stale_state = self.stale_state current_state_by_type = self.state[state_type] current_state = current_state_by_type.get(key, _SENTINEL) - if current_state == state: + subscription_key = (state_type, key) + if current_state == state and subscription_key not in stale_state: _LOGGER.debug( "%s: ignoring duplicate update with and key %s: %s", self.name, @@ -222,8 +254,8 @@ class RuntimeEntryData: key, state, ) + stale_state.discard(subscription_key) current_state_by_type[key] = state - subscription_key = (state_type, key) if subscription_key in self.state_subscriptions: self.state_subscriptions[subscription_key]() diff --git a/homeassistant/components/esphome/enum_mapper.py b/homeassistant/components/esphome/enum_mapper.py new file mode 100644 index 0000000000..566f0bc503 --- /dev/null +++ b/homeassistant/components/esphome/enum_mapper.py @@ -0,0 +1,39 @@ +"""Helper class to convert between Home Assistant and ESPHome enum values.""" + +from typing import Generic, TypeVar, overload + +from aioesphomeapi import APIIntEnum + +_EnumT = TypeVar("_EnumT", bound=APIIntEnum) +_ValT = TypeVar("_ValT") + + +class EsphomeEnumMapper(Generic[_EnumT, _ValT]): + """Helper class to convert between hass and esphome enum values.""" + + def __init__(self, mapping: dict[_EnumT, _ValT]) -> None: + """Construct a EsphomeEnumMapper.""" + # Add none mapping + augmented_mapping: dict[ + _EnumT | None, _ValT | None + ] = mapping # type: ignore[assignment] + augmented_mapping[None] = None + + self._mapping = augmented_mapping + self._inverse: dict[_ValT, _EnumT] = {v: k for k, v in mapping.items()} + + @overload + def from_esphome(self, value: _EnumT) -> _ValT: + ... + + @overload + def from_esphome(self, value: _EnumT | None) -> _ValT | None: + ... + + def from_esphome(self, value: _EnumT | None) -> _ValT | None: + """Convert from an esphome int representation to a hass string.""" + return self._mapping[value] + + def from_hass(self, value: _ValT) -> _EnumT: + """Convert from a hass string to a esphome int representation.""" + return self._inverse[value] diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 27952d36c6..0106063096 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -22,12 +22,8 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper ORDERED_NAMED_FAN_SPEEDS = [FanSpeed.LOW, FanSpeed.MEDIUM, FanSpeed.HIGH] diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 95b6c091d5..3576dadd1c 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -4,7 +4,7 @@ "after_dependencies": ["zeroconf", "tag"], "codeowners": ["@OttoWinter", "@jesserockz"], "config_flow": true, - "dependencies": ["bluetooth"], + "dependencies": ["assist_pipeline", "bluetooth"], "dhcp": [ { "registered_devices": true @@ -14,6 +14,10 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.5.1", "esphome-dashboard-api==1.2.3"], + "requirements": [ + "aioesphomeapi==13.7.2", + "bluetooth-data-tools==0.4.0", + "esphome-dashboard-api==1.2.3" + ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index f8566e863c..d818e04096 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -24,12 +24,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper async def async_setup_entry( @@ -115,7 +111,9 @@ class EsphomeMediaPlayer( ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 7379be33da..3ca8e0b972 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -11,12 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper async def async_setup_entry( diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index 79af045534..e4cac21dbc 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -3,12 +3,20 @@ from __future__ import annotations from aioesphomeapi import SelectInfo, SelectState +from homeassistant.components.assist_pipeline.select import AssistPipelineSelect from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from . import ( + EsphomeAssistEntity, + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) +from .domain_data import DomainData +from .entry_data import RuntimeEntryData async def async_setup_entry( @@ -27,6 +35,11 @@ async def async_setup_entry( state_type=SelectState, ) + entry_data = DomainData.get(hass).get_entry_data(entry) + assert entry_data.device_info is not None + if entry_data.device_info.voice_assistant_version: + async_add_entities([EsphomeAssistPipelineSelect(hass, entry_data)]) + class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): """A select implementation for esphome.""" @@ -47,3 +60,12 @@ class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self._client.select_command(self._static_info.key, option) + + +class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect): + """Pipeline selector for esphome devices.""" + + def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None: + """Initialize a pipeline selector.""" + EsphomeAssistEntity.__init__(self, entry_data) + AssistPipelineSelect.__init__(self, hass, self._device_info.mac_address) diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 863096cb3b..25a0bfaff7 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -24,12 +24,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt from homeassistant.util.enum import try_parse_enum -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper async def async_setup_entry( diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index ebbc97374c..7171339ac0 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -46,10 +46,25 @@ }, "flow_title": "{name}" }, + "entity": { + "binary_sensor": { + "call_active": { + "name": "Call Active" + } + }, + "select": { + "pipeline": { + "name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + } + } + }, "issues": { "ble_firmware_outdated": { "title": "Update {name} with ESPHome {version} or later", - "description": "To improve Bluetooth reliability and performance, we highly recommend updating {name} with ESPHome {version} or later. When updating the device to ESPHome {version}, it is recommended to use a serial cable instead of an over-the-air update to take advantage of the new partition scheme." + "description": "To improve Bluetooth reliability and performance, we highly recommend updating {name} with ESPHome {version} or later. When updating the device from ESPHome earlier than 2022.12.0, it is recommended to use a serial cable instead of an over-the-air update to take advantage of the new partition scheme." }, "api_password_deprecated": { "title": "API Password deprecated on {name}", diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index a0d7b03133..c71ba60882 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -59,8 +59,9 @@ async def async_setup_entry( await setup_update_entity() return - signal = f"esphome_{entry_data.entry_id}_on_device_update" - unsub = async_dispatcher_connect(hass, signal, setup_update_entity) + unsub = async_dispatcher_connect( + hass, entry_data.signal_device_updated, setup_update_entity + ) class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py new file mode 100644 index 0000000000..9b35fc7972 --- /dev/null +++ b/homeassistant/components/esphome/voice_assistant.py @@ -0,0 +1,166 @@ +"""ESPHome voice assistant support.""" +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterable, Callable +import logging +import socket +from typing import cast + +from aioesphomeapi import VoiceAssistantEventType + +from homeassistant.components import stt +from homeassistant.components.assist_pipeline import ( + PipelineEvent, + PipelineEventType, + async_pipeline_from_audio_stream, +) +from homeassistant.components.media_player import async_process_play_media_url +from homeassistant.core import Context, HomeAssistant, callback + +from .enum_mapper import EsphomeEnumMapper + +_LOGGER = logging.getLogger(__name__) + +UDP_PORT = 0 # Set to 0 to let the OS pick a free random port + +_VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[ + VoiceAssistantEventType, PipelineEventType +] = EsphomeEnumMapper( + { + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: PipelineEventType.ERROR, + VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: PipelineEventType.RUN_START, + VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: PipelineEventType.RUN_END, + VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: PipelineEventType.STT_START, + VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: PipelineEventType.STT_END, + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START: PipelineEventType.INTENT_START, + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: PipelineEventType.INTENT_END, + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: PipelineEventType.TTS_START, + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: PipelineEventType.TTS_END, + } +) + + +class VoiceAssistantUDPServer(asyncio.DatagramProtocol): + """Receive UDP packets and forward them to the voice assistant.""" + + started = False + queue: asyncio.Queue[bytes] | None = None + transport: asyncio.DatagramTransport | None = None + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize UDP receiver.""" + self.context = Context() + self.hass = hass + self.queue = asyncio.Queue() + + async def start_server(self) -> int: + """Start accepting connections.""" + + def accept_connection() -> VoiceAssistantUDPServer: + """Accept connection.""" + if self.started: + raise RuntimeError("Can only start once") + if self.queue is None: + raise RuntimeError("No longer accepting connections") + + self.started = True + return self + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + + sock.bind(("", UDP_PORT)) + + await asyncio.get_running_loop().create_datagram_endpoint( + accept_connection, sock=sock + ) + + return cast(int, sock.getsockname()[1]) + + @callback + def connection_made(self, transport: asyncio.BaseTransport) -> None: + """Store transport for later use.""" + self.transport = cast(asyncio.DatagramTransport, transport) + + @callback + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + """Handle incoming UDP packet.""" + if self.queue is not None: + self.queue.put_nowait(data) + + def error_received(self, exc: Exception) -> None: + """Handle when a send or receive operation raises an OSError. + + (Other than BlockingIOError or InterruptedError.) + """ + _LOGGER.error("ESPHome Voice Assistant UDP server error received: %s", exc) + + @callback + def stop(self) -> None: + """Stop the receiver.""" + if self.queue is not None: + self.queue.put_nowait(b"") + self.queue = None + if self.transport is not None: + self.transport.close() + + async def _iterate_packets(self) -> AsyncIterable[bytes]: + """Iterate over incoming packets.""" + if self.queue is None: + raise RuntimeError("Already stopped") + + while data := await self.queue.get(): + yield data + + async def run_pipeline( + self, + handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None], + ) -> None: + """Run the Voice Assistant pipeline.""" + + @callback + def handle_pipeline_event(event: PipelineEvent) -> None: + """Handle pipeline events.""" + + try: + event_type = _VOICE_ASSISTANT_EVENT_TYPES.from_hass(event.type) + except KeyError: + _LOGGER.warning("Received unknown pipeline event type: %s", event.type) + return + + data_to_send = None + if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: + assert event.data is not None + data_to_send = {"text": event.data["stt_output"]["text"]} + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: + assert event.data is not None + data_to_send = {"text": event.data["tts_input"]} + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: + assert event.data is not None + path = event.data["tts_output"]["url"] + url = async_process_play_media_url(self.hass, path) + data_to_send = {"url": url} + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: + assert event.data is not None + data_to_send = { + "code": event.data["code"], + "message": event.data["message"], + } + + handle_event(event_type, data_to_send) + + await async_pipeline_from_audio_stream( + self.hass, + context=self.context, + event_callback=handle_pipeline_event, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=self._iterate_packets(), + ) diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index fbd49102f3..489ff97eb4 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -2,26 +2,26 @@ import logging from pyezviz.client import EzvizClient -from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_TYPE, - CONF_URL, - CONF_USERNAME, - Platform, -) +from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import ( ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DATA_COORDINATOR, - DATA_UNDO_UPDATE_LISTENER, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, @@ -30,17 +30,22 @@ from .coordinator import EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.CAMERA, - Platform.SENSOR, - Platform.SWITCH, -] +PLATFORMS_BY_TYPE: dict[str, list] = { + ATTR_TYPE_CAMERA: [], + ATTR_TYPE_CLOUD: [ + Platform.BINARY_SENSOR, + Platform.CAMERA, + Platform.SENSOR, + Platform.SWITCH, + ], +} async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up EZVIZ from a config entry.""" hass.data.setdefault(DOMAIN, {}) + sensor_type: str = entry.data[CONF_TYPE] + ezviz_client = None if not entry.options: options = { @@ -50,69 +55,71 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, options=options) - if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: - if hass.data.get(DOMAIN): - # Should only execute on addition of new camera entry. - # Fetch Entry id of main account and reload it. - for item in hass.config_entries.async_entries(): - if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: - _LOGGER.info("Reload EZVIZ integration with new camera rtsp entry") - await hass.config_entries.async_reload(item.entry_id) + # Initialize EZVIZ cloud entities + if PLATFORMS_BY_TYPE[sensor_type]: + # Initiate reauth config flow if account token if not present. + if not entry.data.get(CONF_SESSION_ID): + raise ConfigEntryAuthFailed - return True - - try: - ezviz_client = await hass.async_add_executor_job( - _get_ezviz_client_instance, entry + ezviz_client = EzvizClient( + token={ + CONF_SESSION_ID: entry.data.get(CONF_SESSION_ID), + CONF_RFSESSION_ID: entry.data.get(CONF_RFSESSION_ID), + "api_url": entry.data.get(CONF_URL), + }, + timeout=entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) - except (InvalidURL, HTTPError, PyEzvizError) as error: - _LOGGER.error("Unable to connect to EZVIZ service: %s", str(error)) - raise ConfigEntryNotReady from error - coordinator = EzvizDataUpdateCoordinator( - hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + try: + await hass.async_add_executor_job(ezviz_client.login) + + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error: + raise ConfigEntryAuthFailed from error + + except (InvalidURL, HTTPError, PyEzvizError) as error: + _LOGGER.error("Unable to connect to Ezviz service: %s", str(error)) + raise ConfigEntryNotReady from error + + coordinator = EzvizDataUpdateCoordinator( + hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. + # Cameras are accessed via local RTSP stream with unique credentials per camera. + # Separate camera entities allow for credential changes per camera. + if sensor_type == ATTR_TYPE_CAMERA and hass.data[DOMAIN]: + for item in hass.config_entries.async_entries(domain=DOMAIN): + if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: + _LOGGER.info("Reload Ezviz main account with camera entry") + await hass.config_entries.async_reload(item.entry_id) + return True + + await hass.config_entries.async_forward_entry_setups( + entry, PLATFORMS_BY_TYPE[sensor_type] ) - await coordinator.async_refresh() - - if not coordinator.last_update_success: - raise ConfigEntryNotReady - - undo_listener = entry.add_update_listener(_async_update_listener) - - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - DATA_UNDO_UPDATE_LISTENER: undo_listener, - } - 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.""" + sensor_type = entry.data[CONF_TYPE] - if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: - return True - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]() + unload_ok = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS_BY_TYPE[sensor_type] + ) + if sensor_type == ATTR_TYPE_CLOUD and unload_ok: hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -def _get_ezviz_client_instance(entry: ConfigEntry) -> EzvizClient: - """Initialize a new instance of EzvizClientApi.""" - ezviz_client = EzvizClient( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_URL], - entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ) - ezviz_client.login() - return ezviz_client diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 7901061c02..0456e7ade9 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -34,7 +34,6 @@ from .const import ( DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_RTSP_PORT, DIR_DOWN, DIR_LEFT, DIR_RIGHT, @@ -70,24 +69,17 @@ async def async_setup_entry( if item.unique_id == camera and item.source != SOURCE_IGNORE ] - # There seem to be a bug related to localRtspPort in EZVIZ API. - local_rtsp_port = ( - value["local_rtsp_port"] - if value["local_rtsp_port"] != 0 - else DEFAULT_RTSP_PORT - ) - if camera_rtsp_entry: ffmpeg_arguments = camera_rtsp_entry[0].options[CONF_FFMPEG_ARGUMENTS] camera_username = camera_rtsp_entry[0].data[CONF_USERNAME] camera_password = camera_rtsp_entry[0].data[CONF_PASSWORD] - camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{local_rtsp_port}{ffmpeg_arguments}" + camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{value['local_rtsp_port']}{ffmpeg_arguments}" _LOGGER.debug( "Configuring Camera %s with ip: %s rtsp port: %s ffmpeg arguments: %s", camera, value["local_ip"], - local_rtsp_port, + value["local_rtsp_port"], ffmpeg_arguments, ) @@ -123,7 +115,7 @@ async def async_setup_entry( camera_username, camera_password, camera_rtsp_stream, - local_rtsp_port, + value["local_rtsp_port"], ffmpeg_arguments, ) ) diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 4c8b1418fa..77598ad6a1 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -1,12 +1,14 @@ -"""Config flow for ezviz.""" +"""Config flow for EZVIZ.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any from pyezviz.client import EzvizClient from pyezviz.exceptions import ( AuthTestResultFailed, - HTTPError, + EzvizAuthVerificationCode, InvalidHost, InvalidURL, PyEzvizError, @@ -25,12 +27,15 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( ATTR_SERIAL, ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, @@ -40,23 +45,37 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) +DEFAULT_OPTIONS = { + CONF_FFMPEG_ARGUMENTS: DEFAULT_FFMPEG_ARGUMENTS, + CONF_TIMEOUT: DEFAULT_TIMEOUT, +} -def _get_ezviz_client_instance(data): - """Initialize a new instance of EzvizClientApi.""" +def _validate_and_create_auth(data: dict) -> dict[str, Any]: + """Try to login to EZVIZ cloud account and return token.""" + # Verify cloud credentials by attempting a login request with username and password. + # Return login token. ezviz_client = EzvizClient( data[CONF_USERNAME], data[CONF_PASSWORD], - data.get(CONF_URL, EU_URL), + data[CONF_URL], data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) - ezviz_client.login() - return ezviz_client + ezviz_token = ezviz_client.login() + + auth_data = { + CONF_SESSION_ID: ezviz_token[CONF_SESSION_ID], + CONF_RFSESSION_ID: ezviz_token[CONF_RFSESSION_ID], + CONF_URL: ezviz_token["api_url"], + CONF_TYPE: ATTR_TYPE_CLOUD, + } + + return auth_data -def _test_camera_rtsp_creds(data): +def _test_camera_rtsp_creds(data: dict) -> None: """Try DESCRIBE on RTSP camera with credentials.""" test_rtsp = TestRTSPAuth( @@ -71,89 +90,43 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _validate_and_create_auth(self, data): - """Try to login to ezviz cloud account and create entry if successful.""" - await self.async_set_unique_id(data[CONF_USERNAME]) - self._abort_if_unique_id_configured() - - # Verify cloud credentials by attempting a login request. - try: - await self.hass.async_add_executor_job(_get_ezviz_client_instance, data) - - except InvalidURL as err: - raise InvalidURL from err - - except HTTPError as err: - raise InvalidHost from err - - except PyEzvizError as err: - raise PyEzvizError from err - - auth_data = { - CONF_USERNAME: data[CONF_USERNAME], - CONF_PASSWORD: data[CONF_PASSWORD], - CONF_URL: data.get(CONF_URL, EU_URL), - CONF_TYPE: ATTR_TYPE_CLOUD, - } - - return self.async_create_entry(title=data[CONF_USERNAME], data=auth_data) - - async def _validate_and_create_camera_rtsp(self, data): + async def _validate_and_create_camera_rtsp(self, data: dict) -> FlowResult: """Try DESCRIBE on RTSP camera with credentials.""" # Get EZVIZ cloud credentials from config entry - ezviz_client_creds = { - CONF_USERNAME: None, - CONF_PASSWORD: None, - CONF_URL: None, + ezviz_token = { + CONF_SESSION_ID: None, + CONF_RFSESSION_ID: None, + "api_url": None, } + ezviz_timeout = DEFAULT_TIMEOUT for item in self._async_current_entries(): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: - ezviz_client_creds = { - CONF_USERNAME: item.data.get(CONF_USERNAME), - CONF_PASSWORD: item.data.get(CONF_PASSWORD), - CONF_URL: item.data.get(CONF_URL), + ezviz_token = { + CONF_SESSION_ID: item.data.get(CONF_SESSION_ID), + CONF_RFSESSION_ID: item.data.get(CONF_RFSESSION_ID), + "api_url": item.data.get(CONF_URL), } + ezviz_timeout = item.data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) # Abort flow if user removed cloud account before adding camera. - if ezviz_client_creds[CONF_USERNAME] is None: + if ezviz_token.get(CONF_SESSION_ID) is None: return self.async_abort(reason="ezviz_cloud_account_missing") + ezviz_client = EzvizClient(token=ezviz_token, timeout=ezviz_timeout) + # We need to wake hibernating cameras. # First create EZVIZ API instance. - try: - ezviz_client = await self.hass.async_add_executor_job( - _get_ezviz_client_instance, ezviz_client_creds - ) + await self.hass.async_add_executor_job(ezviz_client.login) - except InvalidURL as err: - raise InvalidURL from err - - except HTTPError as err: - raise InvalidHost from err - - except PyEzvizError as err: - raise PyEzvizError from err - - # Secondly try to wake hibernating camera. - try: - await self.hass.async_add_executor_job( - ezviz_client.get_detection_sensibility, data[ATTR_SERIAL] - ) - - except HTTPError as err: - raise InvalidHost from err + # Secondly try to wake hybernating camera. + await self.hass.async_add_executor_job( + ezviz_client.get_detection_sensibility, data[ATTR_SERIAL] + ) # Thirdly attempts an authenticated RTSP DESCRIBE request. - try: - await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data) - - except InvalidHost as err: - raise InvalidHost from err - - except AuthTestResultFailed as err: - raise AuthTestResultFailed from err + await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data) return self.async_create_entry( title=data[ATTR_SERIAL], @@ -162,6 +135,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): CONF_PASSWORD: data[CONF_PASSWORD], CONF_TYPE: ATTR_TYPE_CAMERA, }, + options=DEFAULT_OPTIONS, ) @staticmethod @@ -170,18 +144,24 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return EzvizOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" - # Check if ezviz cloud account is present in entry config, + # Check if EZVIZ cloud account is present in entry config, # abort if already configured. for item in self._async_current_entries(): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: return self.async_abort(reason="already_configured_account") errors = {} + auth_data = {} if user_input is not None: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + if user_input[CONF_URL] == CONF_CUSTOMIZE: self.context["data"] = { CONF_USERNAME: user_input[CONF_USERNAME], @@ -189,11 +169,10 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): } return await self.async_step_user_custom_url() - if CONF_TIMEOUT not in user_input: - user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT - try: - return await self._validate_and_create_auth(user_input) + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) except InvalidURL: errors["base"] = "invalid_host" @@ -201,6 +180,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except InvalidHost: errors["base"] = "cannot_connect" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except PyEzvizError: errors["base"] = "invalid_auth" @@ -208,6 +190,13 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=auth_data, + options=DEFAULT_OPTIONS, + ) + data_schema = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -222,20 +211,21 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=data_schema, errors=errors ) - async def async_step_user_custom_url(self, user_input=None): + async def async_step_user_custom_url( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user for custom region url.""" - errors = {} + auth_data = {} if user_input is not None: user_input[CONF_USERNAME] = self.context["data"][CONF_USERNAME] user_input[CONF_PASSWORD] = self.context["data"][CONF_PASSWORD] - if CONF_TIMEOUT not in user_input: - user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT - try: - return await self._validate_and_create_auth(user_input) + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) except InvalidURL: errors["base"] = "invalid_host" @@ -243,6 +233,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except InvalidHost: errors["base"] = "cannot_connect" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except PyEzvizError: errors["base"] = "invalid_auth" @@ -250,6 +243,13 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=auth_data, + options=DEFAULT_OPTIONS, + ) + data_schema_custom_url = vol.Schema( { vol.Required(CONF_URL, default=EU_URL): str, @@ -260,18 +260,22 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user_custom_url", data_schema=data_schema_custom_url, errors=errors ) - async def async_step_integration_discovery(self, discovery_info): + async def async_step_integration_discovery( + self, discovery_info: dict[str, Any] + ) -> FlowResult: """Handle a flow for discovered camera without rtsp config entry.""" await self.async_set_unique_id(discovery_info[ATTR_SERIAL]) self._abort_if_unique_id_configured() - self.context["title_placeholders"] = {"serial": self.unique_id} + self.context["title_placeholders"] = {ATTR_SERIAL: self.unique_id} self.context["data"] = {CONF_IP_ADDRESS: discovery_info[CONF_IP_ADDRESS]} return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Confirm and create entry from discovery step.""" errors = {} @@ -284,6 +288,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except (InvalidHost, InvalidURL): errors["base"] = "invalid_host" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except (PyEzvizError, AuthTestResultFailed): errors["base"] = "invalid_auth" @@ -303,11 +310,76 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=discovered_camera_schema, errors=errors, description_placeholders={ - "serial": self.unique_id, + ATTR_SERIAL: self.unique_id, CONF_IP_ADDRESS: self.context["data"][CONF_IP_ADDRESS], }, ) + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Handle a flow for reauthentication with password.""" + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a Confirm flow for reauthentication with password.""" + auth_data = {} + errors = {} + entry = None + + for item in self._async_current_entries(): + if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: + self.context["title_placeholders"] = {ATTR_SERIAL: item.title} + entry = await self.async_set_unique_id(item.title) + + if not entry: + return self.async_abort(reason="ezviz_cloud_account_missing") + + if user_input is not None: + user_input[CONF_URL] = entry.data[CONF_URL] + + try: + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) + + except (InvalidHost, InvalidURL): + errors["base"] = "invalid_host" + + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + + except (PyEzvizError, AuthTestResultFailed): + errors["base"] = "invalid_auth" + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + else: + self.hass.config_entries.async_update_entry( + entry, + data=auth_data, + ) + + await self.hass.config_entries.async_reload(entry.entry_id) + + return self.async_abort(reason="reauth_successful") + + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME, default=entry.title): vol.In([entry.title]), + vol.Required(CONF_PASSWORD): str, + } + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=data_schema, + errors=errors, + ) + class EzvizOptionsFlowHandler(OptionsFlow): """Handle EZVIZ client options.""" @@ -316,22 +388,28 @@ class EzvizOptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage EZVIZ options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) - options = { - vol.Optional( - CONF_TIMEOUT, - default=self.config_entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ): int, - vol.Optional( - CONF_FFMPEG_ARGUMENTS, - default=self.config_entry.options.get( - CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS - ), - ): str, - } + options = vol.Schema( + { + vol.Optional( + CONF_TIMEOUT, + default=self.config_entry.options.get( + CONF_TIMEOUT, DEFAULT_TIMEOUT + ), + ): int, + vol.Optional( + CONF_FFMPEG_ARGUMENTS, + default=self.config_entry.options.get( + CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS + ), + ): str, + } + ) - return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) + return self.async_show_form(step_id="init", data_schema=options) diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index b9183772b6..d052a4b821 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -10,6 +10,9 @@ ATTR_HOME = "HOME_MODE" ATTR_AWAY = "AWAY_MODE" ATTR_TYPE_CLOUD = "EZVIZ_CLOUD_ACCOUNT" ATTR_TYPE_CAMERA = "CAMERA_ACCOUNT" +CONF_SESSION_ID = "session_id" +CONF_RFSESSION_ID = "rf_session_id" +CONF_EZVIZ_ACCOUNT = "ezviz_account" # Services data DIR_UP = "up" @@ -33,10 +36,8 @@ SERVICE_DETECTION_SENSITIVITY = "set_alarm_detection_sensibility" EU_URL = "apiieu.ezvizlife.com" RUSSIA_URL = "apirus.ezvizru.com" DEFAULT_CAMERA_USERNAME = "admin" -DEFAULT_RTSP_PORT = 554 DEFAULT_TIMEOUT = 25 DEFAULT_FFMPEG_ARGUMENTS = "" # Data DATA_COORDINATOR = "coordinator" -DATA_UNDO_UPDATE_LISTENER = "undo_update_listener" diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index cc4537bb9b..ba8ed336a5 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -4,9 +4,16 @@ import logging from async_timeout import timeout from pyezviz.client import EzvizClient -from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -27,15 +34,16 @@ class EzvizDataUpdateCoordinator(DataUpdateCoordinator): super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - def _update_data(self) -> dict: - """Fetch data from EZVIZ via camera load function.""" - return self.ezviz_client.load_cameras() - async def _async_update_data(self) -> dict: """Fetch data from EZVIZ.""" try: async with timeout(self._api_timeout): - return await self.hass.async_add_executor_job(self._update_data) + return await self.hass.async_add_executor_job( + self.ezviz_client.load_cameras + ) + + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error: + raise ConfigEntryAuthFailed from error except (InvalidURL, HTTPError, PyEzvizError) as error: raise UpdateFailed(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 91fa32ad9b..5e258e4270 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -26,17 +26,27 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Enter credentials to reauthenticate to ezviz cloud account", + "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%]", - "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "mfa_required": "2FA enabled on account, please disable and retry" }, "abort": { "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "ezviz_cloud_account_missing": "EZVIZ cloud account missing. Please reconfigure EZVIZ cloud account" + "ezviz_cloud_account_missing": "Ezviz cloud account missing. Please reconfigure Ezviz cloud account", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index 5f34e0d67d..f4b1cd0c1f 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -95,6 +95,7 @@ HA_OPMODES_HVAC = { HVACMode.COOL: 2, HVACMode.AUTO: 3, HVACMode.FAN_ONLY: 6, + HVACMode.DRY: 8, } TARGET_TEMP_ACTIONS = ( diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index 6dd2104bd9..866be3fba5 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyfibaro"], - "requirements": ["pyfibaro==0.6.9"] + "requirements": ["pyfibaro==0.7.0"] } diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py index 9e08615d4a..73f060e79b 100644 --- a/homeassistant/components/filesize/__init__.py +++ b/homeassistant/components/filesize/__init__.py @@ -11,24 +11,19 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import PLATFORMS -def check_path(path: pathlib.Path) -> bool: - """Check path.""" - return path.exists() and path.is_file() - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up from a config entry.""" - - path = entry.data[CONF_FILE_PATH] +def _check_path(hass: HomeAssistant, path: str) -> None: + """Check if path is valid and allowed.""" get_path = pathlib.Path(path) - - check_file = await hass.async_add_executor_job(check_path, get_path) - if not check_file: + if not get_path.exists() or not get_path.is_file(): raise ConfigEntryNotReady(f"Can not access file {path}") if not hass.config.is_allowed_path(path): raise ConfigEntryNotReady(f"Filepath {path} is not valid or allowed") + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + await hass.async_add_executor_job(_check_path, hass, entry.data[CONF_FILE_PATH]) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/filesize/config_flow.py b/homeassistant/components/filesize/config_flow.py index 3f58e636b0..8633e6ec46 100644 --- a/homeassistant/components/filesize/config_flow.py +++ b/homeassistant/components/filesize/config_flow.py @@ -49,7 +49,9 @@ class FilesizeConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - full_path = validate_path(self.hass, user_input[CONF_FILE_PATH]) + full_path = await self.hass.async_add_executor_job( + validate_path, self.hass, user_input[CONF_FILE_PATH] + ) except NotValidError: errors["base"] = "not_valid" except NotAllowedError: diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 6f290ccb29..7b2321e172 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -80,9 +80,7 @@ DEFAULT_FILTER_TIME_CONSTANT = 10 NAME_TEMPLATE = "{} filter" ICON = "mdi:chart-line-variant" -FILTER_SCHEMA = vol.Schema( - {vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int)} -) +FILTER_SCHEMA = vol.Schema({vol.Optional(CONF_FILTER_PRECISION): vol.Coerce(int)}) FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend( { @@ -383,9 +381,9 @@ class FilterState: except ValueError: self.state = state.state - def set_precision(self, precision: int) -> None: + def set_precision(self, precision: int | None) -> None: """Set precision of Number based states.""" - if isinstance(self.state, Number): + if precision is not None and isinstance(self.state, Number): value = round(float(self.state), precision) self.state = int(value) if precision == 0 else value @@ -417,8 +415,8 @@ class Filter: self, name: str, window_size: int | timedelta, - precision: int, entity: str, + precision: int | None, ) -> None: """Initialize common attributes. @@ -467,6 +465,7 @@ class Filter: filtered = self._filter_state(fstate) filtered.set_precision(self.filter_precision) + if self._store_raw: self.states.append(copy(FilterState(new_state))) else: @@ -485,8 +484,9 @@ class RangeFilter(Filter, SensorEntity): def __init__( self, + *, entity: str, - precision: int, + precision: int | None = None, lower_bound: float | None = None, upper_bound: float | None = None, ) -> None: @@ -495,7 +495,9 @@ class RangeFilter(Filter, SensorEntity): :param upper_bound: band upper bound :param lower_bound: band lower bound """ - super().__init__(FILTER_NAME_RANGE, DEFAULT_WINDOW_SIZE, precision, entity) + super().__init__( + FILTER_NAME_RANGE, DEFAULT_WINDOW_SIZE, precision=precision, entity=entity + ) self._lower_bound = lower_bound self._upper_bound = upper_bound self._stats_internal: Counter = Counter() @@ -539,13 +541,20 @@ class OutlierFilter(Filter, SensorEntity): """ def __init__( - self, window_size: int, precision: int, entity: str, radius: float + self, + *, + window_size: int, + entity: str, + radius: float, + precision: int | None = None, ) -> None: """Initialize Filter. :param radius: band radius """ - super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity) + super().__init__( + FILTER_NAME_OUTLIER, window_size, precision=precision, entity=entity + ) self._radius = radius self._stats_internal: Counter = Counter() self._store_raw = True @@ -579,10 +588,17 @@ class LowPassFilter(Filter, SensorEntity): """BASIC Low Pass Filter.""" def __init__( - self, window_size: int, precision: int, entity: str, time_constant: int + self, + *, + window_size: int, + entity: str, + time_constant: int, + precision: int = DEFAULT_PRECISION, ) -> None: """Initialize Filter.""" - super().__init__(FILTER_NAME_LOWPASS, window_size, precision, entity) + super().__init__( + FILTER_NAME_LOWPASS, window_size, precision=precision, entity=entity + ) self._time_constant = time_constant def _filter_state(self, new_state: FilterState) -> FilterState: @@ -610,16 +626,19 @@ class TimeSMAFilter(Filter, SensorEntity): def __init__( self, + *, window_size: timedelta, - precision: int, entity: str, type: str, # pylint: disable=redefined-builtin + precision: int = DEFAULT_PRECISION, ) -> None: """Initialize Filter. :param type: type of algorithm used to connect discrete values """ - super().__init__(FILTER_NAME_TIME_SMA, window_size, precision, entity) + super().__init__( + FILTER_NAME_TIME_SMA, window_size, precision=precision, entity=entity + ) self._time_window = window_size self.last_leak: FilterState | None = None self.queue = deque[FilterState]() @@ -660,9 +679,13 @@ class ThrottleFilter(Filter, SensorEntity): One sample per window. """ - def __init__(self, window_size: int, precision: int, entity: str) -> None: + def __init__( + self, *, window_size: int, entity: str, precision: None = None + ) -> None: """Initialize Filter.""" - super().__init__(FILTER_NAME_THROTTLE, window_size, precision, entity) + super().__init__( + FILTER_NAME_THROTTLE, window_size, precision=precision, entity=entity + ) self._only_numbers = False def _filter_state(self, new_state: FilterState) -> FilterState: @@ -683,9 +706,13 @@ class TimeThrottleFilter(Filter, SensorEntity): One sample per time period. """ - def __init__(self, window_size: timedelta, precision: int, entity: str) -> None: + def __init__( + self, *, window_size: timedelta, entity: str, precision: int | None = None + ) -> None: """Initialize Filter.""" - super().__init__(FILTER_NAME_TIME_THROTTLE, window_size, precision, entity) + super().__init__( + FILTER_NAME_TIME_THROTTLE, window_size, precision=precision, entity=entity + ) self._time_window = window_size self._last_emitted_at: datetime | None = None self._only_numbers = False diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index 234f03812f..8091f8981e 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -19,12 +19,10 @@ _LOGGER = logging.getLogger(__name__) ATTR_EXCHANGE_RATE = "Exchange rate" ATTR_TARGET = "Target currency" -ATTRIBUTION = "Data provided by the European Central Bank (ECB)" DEFAULT_BASE = "USD" DEFAULT_NAME = "Exchange rate" -ICON = "mdi:currency-usd" SCAN_INTERVAL = timedelta(days=1) @@ -61,7 +59,8 @@ def setup_platform( class ExchangeRateSensor(SensorEntity): """Representation of a Exchange sensor.""" - _attr_attribution = ATTRIBUTION + _attr_attribution = "Data provided by the European Central Bank (ECB)" + _attr_icon = "mdi:currency-usd" def __init__(self, data, name, target): """Initialize the sensor.""" @@ -94,11 +93,6 @@ class ExchangeRateSensor(SensorEntity): ATTR_TARGET: self._target, } - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the states.""" self.data.update() diff --git a/homeassistant/components/flo/config_flow.py b/homeassistant/components/flo/config_flow.py index 306ec945a3..c34753c329 100644 --- a/homeassistant/components/flo/config_flow.py +++ b/homeassistant/components/flo/config_flow.py @@ -9,7 +9,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER -DATA_SCHEMA = vol.Schema({vol.Required("username"): str, vol.Required("password"): str}) +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) async def validate_input(hass: core.HomeAssistant, data): @@ -20,18 +22,11 @@ async def validate_input(hass: core.HomeAssistant, data): session = async_get_clientsession(hass) try: - api = await async_get_api( - data[CONF_USERNAME], data[CONF_PASSWORD], session=session - ) + await async_get_api(data[CONF_USERNAME], data[CONF_PASSWORD], session=session) except RequestError as request_error: LOGGER.error("Error connecting to the Flo API: %s", request_error) raise CannotConnect from request_error - user_info = await api.user.get_info() - a_location_id = user_info["locations"][0]["id"] - location_info = await api.location.get_info(a_location_id) - return {"title": location_info["nickname"]} - class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for flo.""" @@ -45,8 +40,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(user_input[CONF_USERNAME]) self._abort_if_unique_id_configured() try: - info = await validate_input(self.hass, user_input) - return self.async_create_entry(title=info["title"], data=user_input) + await validate_input(self.hass, user_input) + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) except CannotConnect: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index e6f89536ba..94f50caa1a 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -102,7 +102,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: EVENT_HOMEASSISTANT_STARTED, _async_start_background_discovery ) async_track_time_interval( - hass, _async_start_background_discovery, DISCOVERY_INTERVAL + hass, + _async_start_background_discovery, + DISCOVERY_INTERVAL, + cancel_on_shutdown=True, ) return True diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index 5bb47fbe79..a6e8183bcd 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.35"] + "requirements": ["flux_led==0.28.37"] } diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py index c7663d6cf3..0e47fa9701 100644 --- a/homeassistant/components/forecast_solar/const.py +++ b/homeassistant/components/forecast_solar/const.py @@ -22,16 +22,29 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( ForecastSolarSensorEntityDescription( key="energy_production_today", name="Estimated energy production - today", - state=lambda estimate: estimate.energy_production_today / 1000, + state=lambda estimate: estimate.energy_production_today, device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + ForecastSolarSensorEntityDescription( + key="energy_production_today_remaining", + name="Estimated energy production - remaining today", + state=lambda estimate: estimate.energy_production_today_remaining, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, ), ForecastSolarSensorEntityDescription( key="energy_production_tomorrow", name="Estimated energy production - tomorrow", - state=lambda estimate: estimate.energy_production_tomorrow / 1000, + state=lambda estimate: estimate.energy_production_tomorrow, device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, ), ForecastSolarSensorEntityDescription( key="power_highest_peak_time_today", @@ -84,15 +97,19 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( ForecastSolarSensorEntityDescription( key="energy_current_hour", name="Estimated energy production - this hour", - state=lambda estimate: estimate.energy_current_hour / 1000, + state=lambda estimate: estimate.energy_current_hour, device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, ), ForecastSolarSensorEntityDescription( key="energy_next_hour", - state=lambda estimate: estimate.sum_energy_production(1) / 1000, + state=lambda estimate: estimate.sum_energy_production(1), name="Estimated energy production - next hour", device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, ), ) diff --git a/homeassistant/components/forecast_solar/diagnostics.py b/homeassistant/components/forecast_solar/diagnostics.py index 7fdcd22d0f..970747253d 100644 --- a/homeassistant/components/forecast_solar/diagnostics.py +++ b/homeassistant/components/forecast_solar/diagnostics.py @@ -34,6 +34,7 @@ async def async_get_config_entry_diagnostics( }, "data": { "energy_production_today": coordinator.data.energy_production_today, + "energy_production_today_remaining": coordinator.data.energy_production_today_remaining, "energy_production_tomorrow": coordinator.data.energy_production_tomorrow, "energy_current_hour": coordinator.data.energy_current_hour, "power_production_now": coordinator.data.power_production_now, @@ -45,9 +46,9 @@ async def async_get_config_entry_diagnostics( wh_datetime.isoformat(): wh_value for wh_datetime, wh_value in coordinator.data.wh_days.items() }, - "wh_hours": { + "wh_period": { wh_datetime.isoformat(): wh_value - for wh_datetime, wh_value in coordinator.data.wh_hours.items() + for wh_datetime, wh_value in coordinator.data.wh_period.items() }, }, "account": { diff --git a/homeassistant/components/forecast_solar/energy.py b/homeassistant/components/forecast_solar/energy.py index 3353739633..b2e9b51473 100644 --- a/homeassistant/components/forecast_solar/energy.py +++ b/homeassistant/components/forecast_solar/energy.py @@ -16,6 +16,6 @@ async def async_get_solar_forecast( return { "wh_hours": { timestamp.isoformat(): val - for timestamp, val in coordinator.data.wh_hours.items() + for timestamp, val in coordinator.data.wh_period.items() } } diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index 0b9abb5f45..ac6a3f7c30 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["forecast_solar==2.2.0"] + "requirements": ["forecast_solar==3.0.0"] } diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index ca7e0cce27..e1f1ece055 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -836,7 +836,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" @@ -873,7 +873,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py new file mode 100644 index 0000000000..9e833aca18 --- /dev/null +++ b/homeassistant/components/freebox/camera.py @@ -0,0 +1,122 @@ +"""Support for Freebox cameras.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.camera import CameraEntityFeature +from homeassistant.components.ffmpeg.camera import ( + CONF_EXTRA_ARGUMENTS, + CONF_INPUT, + DEFAULT_ARGUMENTS, + FFmpegCamera, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_platform +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTR_DETECTION, DOMAIN +from .home_base import FreeboxHomeEntity +from .router import FreeboxRouter + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up cameras.""" + router = hass.data[DOMAIN][entry.unique_id] + tracked: set = set() + + @callback + def update_callback(): + add_entities(hass, router, async_add_entities, tracked) + + router.listeners.append( + async_dispatcher_connect(hass, router.signal_home_device_new, update_callback) + ) + update_callback() + + entity_platform.async_get_current_platform() + + +@callback +def add_entities(hass: HomeAssistant, router, async_add_entities, tracked): + """Add new cameras from the router.""" + new_tracked = [] + + for nodeid, node in router.home_devices.items(): + if (node["category"] != Platform.CAMERA) or (nodeid in tracked): + continue + new_tracked.append(FreeboxCamera(hass, router, node)) + tracked.add(nodeid) + + if new_tracked: + async_add_entities(new_tracked, True) + + +class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera): + """Representation of a Freebox camera.""" + + def __init__( + self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any] + ) -> None: + """Initialize a camera.""" + + super().__init__(hass, router, node) + device_info = { + CONF_NAME: node["label"].strip(), + CONF_INPUT: node["props"]["Stream"], + CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS, + } + FFmpegCamera.__init__(self, hass, device_info) + + self._supported_features = ( + CameraEntityFeature.ON_OFF | CameraEntityFeature.STREAM + ) + + self._command_motion_detection = self.get_command_id( + node["type"]["endpoints"], ATTR_DETECTION + ) + self._attr_extra_state_attributes = {} + self.update_node(node) + + async def async_enable_motion_detection(self) -> None: + """Enable motion detection in the camera.""" + await self.set_home_endpoint_value(self._command_motion_detection, True) + self._attr_motion_detection_enabled = True + + async def async_disable_motion_detection(self) -> None: + """Disable motion detection in camera.""" + await self.set_home_endpoint_value(self._command_motion_detection, False) + self._attr_motion_detection_enabled = False + + async def async_update_signal(self) -> None: + """Update the camera node.""" + self.update_node(self._router.home_devices[self._id]) + self.async_write_ha_state() + + def update_node(self, node): + """Update params.""" + self._name = node["label"].strip() + + # Get status + if self._node["status"] == "active": + self._attr_is_streaming = True + else: + self._attr_is_streaming = False + + # Parse all endpoints values + for endpoint in filter( + lambda x: (x["ep_type"] == "signal"), node["show_endpoints"] + ): + self._attr_extra_state_attributes[endpoint["name"]] = endpoint["value"] + + # Get motion detection status + self._attr_motion_detection_enabled = self._attr_extra_state_attributes[ + ATTR_DETECTION + ] diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index dbee01c4e7..af641b5430 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -22,7 +22,7 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize Freebox config flow.""" - self._host = None + self._host: str self._port = None def _show_setup_form(self, user_input=None, errors=None): @@ -42,9 +42,9 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> FlowResult: """Handle a flow initiated by the user.""" - errors = {} + errors: dict[str, str] = {} if user_input is None: return self._show_setup_form(user_input, errors) @@ -58,7 +58,7 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_link() - async def async_step_link(self, user_input=None): + async def async_step_link(self, user_input=None) -> FlowResult: """Attempt to link with the Freebox router. Given a configured host, will ask the user to press the button @@ -102,7 +102,7 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="link", errors=errors) - async def async_step_import(self, user_input=None): + async def async_step_import(self, user_input=None) -> FlowResult: """Import a config entry.""" return await self.async_step_user(user_input) diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 32cf407f2a..767cb94de4 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -16,7 +16,13 @@ APP_DESC = { } API_VERSION = "v6" -PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BUTTON, + Platform.DEVICE_TRACKER, + Platform.SENSOR, + Platform.SWITCH, + Platform.CAMERA, +] DEFAULT_DEVICE_NAME = "Unknown device" @@ -27,7 +33,6 @@ STORAGE_VERSION = 1 CONNECTION_SENSORS_KEYS = {"rate_down", "rate_up"} - # Icons DEVICE_ICONS = { "freebox_delta": "mdi:television-guide", @@ -48,3 +53,20 @@ DEVICE_ICONS = { "vg_console": "mdi:gamepad-variant", "workstation": "mdi:desktop-tower-monitor", } + +ATTR_DETECTION = "detection" + + +CATEGORY_TO_MODEL = { + "pir": "F-HAPIR01A", + "camera": "F-HACAM01A", + "dws": "F-HADWS01A", + "kfb": "F-HAKFB01A", + "alarm": "F-MSEC07A", + "rts": "RTS", + "iohome": "IOHome", +} + +HOME_COMPATIBLE_PLATFORMS = [ + Platform.CAMERA, +] diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/home_base.py new file mode 100644 index 0000000000..c74f072a5b --- /dev/null +++ b/homeassistant/components/freebox/home_base.py @@ -0,0 +1,131 @@ +"""Support for Freebox base features.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import CATEGORY_TO_MODEL, DOMAIN +from .router import FreeboxRouter + +_LOGGER = logging.getLogger(__name__) + + +class FreeboxHomeEntity(Entity): + """Representation of a Freebox base entity.""" + + def __init__( + self, + hass: HomeAssistant, + router: FreeboxRouter, + node: dict[str, Any], + sub_node: dict[str, Any] | None = None, + ) -> None: + """Initialize a Freebox Home entity.""" + self._hass = hass + self._router = router + self._node = node + self._sub_node = sub_node + self._id = node["id"] + self._attr_name = node["label"].strip() + self._device_name = self._attr_name + self._attr_unique_id = f"{self._router.mac}-node_{self._id}" + + if sub_node is not None: + self._attr_name += " " + sub_node["label"].strip() + self._attr_unique_id += "-" + sub_node["name"].strip() + + self._available = True + self._firmware = node["props"].get("FwVersion") + self._manufacturer = "Freebox SAS" + self._remove_signal_update: Any + + self._model = CATEGORY_TO_MODEL.get(node["category"]) + if self._model is None: + if node["type"].get("inherit") == "node::rts": + self._manufacturer = "Somfy" + self._model = CATEGORY_TO_MODEL.get("rts") + elif node["type"].get("inherit") == "node::ios": + self._manufacturer = "Somfy" + self._model = CATEGORY_TO_MODEL.get("iohome") + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._id)}, + manufacturer=self._manufacturer, + model=self._model, + name=self._device_name, + sw_version=self._firmware, + via_device=( + DOMAIN, + router.mac, + ), + ) + + async def async_update_signal(self): + """Update signal.""" + self._node = self._router.home_devices[self._id] + # Update name + if self._sub_node is None: + self._attr_name = self._node["label"].strip() + else: + self._attr_name = ( + self._node["label"].strip() + " " + self._sub_node["label"].strip() + ) + self.async_write_ha_state() + + async def set_home_endpoint_value(self, command_id: Any, value=None) -> None: + """Set Home endpoint value.""" + if command_id is None: + _LOGGER.error("Unable to SET a value through the API. Command is None") + return + await self._router.home.set_home_endpoint_value( + self._id, command_id, {"value": value} + ) + + def get_command_id(self, nodes, name) -> int | None: + """Get the command id.""" + node = next( + filter(lambda x: (x["name"] == name), nodes), + None, + ) + if not node: + _LOGGER.warning("The Freebox Home device has no value for: %s", name) + return None + return node["id"] + + async def async_added_to_hass(self): + """Register state update callback.""" + self.remove_signal_update( + async_dispatcher_connect( + self._hass, + self._router.signal_home_device_update, + self.async_update_signal, + ) + ) + + async def async_will_remove_from_hass(self): + """When entity will be removed from hass.""" + self._remove_signal_update() + + def remove_signal_update(self, dispacher: Any): + """Register state update callback.""" + self._remove_signal_update = dispacher + + def get_value(self, ep_type, name): + """Get the value.""" + node = next( + filter( + lambda x: (x["name"] == name and x["ep_type"] == ep_type), + self._node["show_endpoints"], + ), + None, + ) + if not node: + _LOGGER.warning( + "The Freebox Home device has no node for: " + ep_type + "/" + name + ) + return None + return node.get("value") diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 637f7050bf..ad7da1703b 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -3,6 +3,7 @@ "name": "Freebox", "codeowners": ["@hacf-fr", "@Quentame"], "config_flow": true, + "dependencies": ["ffmpeg"], "documentation": "https://www.home-assistant.io/integrations/freebox", "iot_class": "local_polling", "loggers": ["freebox_api"], diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 0fb0f10a27..5622da48e6 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -4,14 +4,16 @@ from __future__ import annotations from collections.abc import Mapping from contextlib import suppress from datetime import datetime +import logging import os from pathlib import Path from typing import Any from freebox_api import Freepybox from freebox_api.api.call import Call +from freebox_api.api.home import Home from freebox_api.api.wifi import Wifi -from freebox_api.exceptions import NotOpenError +from freebox_api.exceptions import HttpRequestError, NotOpenError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT @@ -27,10 +29,13 @@ from .const import ( APP_DESC, CONNECTION_SENSORS_KEYS, DOMAIN, + HOME_COMPATIBLE_PLATFORMS, STORAGE_KEY, STORAGE_VERSION, ) +_LOGGER = logging.getLogger(__name__) + async def get_api(hass: HomeAssistant, host: str) -> Freepybox: """Get the Freebox API.""" @@ -70,11 +75,15 @@ class FreeboxRouter: self.sensors_temperature: dict[str, int] = {} self.sensors_connection: dict[str, float] = {} self.call_list: list[dict[str, Any]] = [] + self.home_granted = True + self.home_devices: dict[str, Any] = {} + self.listeners: list[dict[str, Any]] = [] async def update_all(self, now: datetime | None = None) -> None: """Update all Freebox platforms.""" await self.update_device_trackers() await self.update_sensors() + await self.update_home_devices() async def update_device_trackers(self) -> None: """Update Freebox devices.""" @@ -146,6 +155,30 @@ class FreeboxRouter: for fbx_disk in fbx_disks: self.disks[fbx_disk["id"]] = fbx_disk + async def update_home_devices(self) -> None: + """Update Home devices (alarm, light, sensor, switch, remote ...).""" + if not self.home_granted: + return + + try: + home_nodes: list[Any] = await self.home.get_home_nodes() or [] + except HttpRequestError: + self.home_granted = False + _LOGGER.warning("Home access is not granted") + return + + new_device = False + for home_node in home_nodes: + if home_node["category"] in HOME_COMPATIBLE_PLATFORMS: + if self.home_devices.get(home_node["id"]) is None: + new_device = True + self.home_devices[home_node["id"]] = home_node + + async_dispatcher_send(self.hass, self.signal_home_device_update) + + if new_device: + async_dispatcher_send(self.hass, self.signal_home_device_new) + async def reboot(self) -> None: """Reboot the Freebox.""" await self._api.system.reboot() @@ -172,6 +205,11 @@ class FreeboxRouter: """Event specific per Freebox entry to signal new device.""" return f"{DOMAIN}-{self._host}-device-new" + @property + def signal_home_device_new(self) -> str: + """Event specific per Freebox entry to signal new home device.""" + return f"{DOMAIN}-{self._host}-home-device-new" + @property def signal_device_update(self) -> str: """Event specific per Freebox entry to signal updates in devices.""" @@ -182,6 +220,11 @@ class FreeboxRouter: """Event specific per Freebox entry to signal updates in sensors.""" return f"{DOMAIN}-{self._host}-sensor-update" + @property + def signal_home_device_update(self) -> str: + """Event specific per Freebox entry to signal update in home devices.""" + return f"{DOMAIN}-{self._host}-home-device-update" + @property def sensors(self) -> dict[str, Any]: """Return sensors.""" @@ -196,3 +239,8 @@ class FreeboxRouter: def wifi(self) -> Wifi: """Return the wifi.""" return self._api.wifi + + @property + def home(self) -> Home: + """Return the home.""" + return self._api.home diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 4d5ba490fa..488d2d48f8 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -113,6 +113,7 @@ class FreeboxSensor(SensorEntity): self.entity_description = description self._router = router self._attr_unique_id = f"{router.mac} {description.name}" + self._attr_device_info = router.device_info @callback def async_update_state(self) -> None: @@ -123,11 +124,6 @@ class FreeboxSensor(SensorEntity): else: self._attr_native_value = state - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return self._router.device_info - @callback def async_on_demand_update(self): """Update state.""" @@ -193,19 +189,18 @@ class FreeboxDiskSensor(FreeboxSensor): self._disk = disk self._partition = partition self._attr_name = f"{partition['label']} {description.name}" - self._attr_unique_id = f"{self._router.mac} {description.key} {self._disk['id']} {self._partition['id']}" + self._attr_unique_id = ( + f"{router.mac} {description.key} {disk['id']} {partition['id']}" + ) - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._disk["id"])}, - model=self._disk["model"], - name=f"Disk {self._disk['id']}", - sw_version=self._disk["firmware"], + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, disk["id"])}, + model=disk["model"], + name=f"Disk {disk['id']}", + sw_version=disk["firmware"], via_device=( DOMAIN, - self._router.mac, + router.mac, ), ) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 918a114fdf..d2edb99e02 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -15,14 +15,21 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity +from .common import ( + AvmWrapper, + ConnectionInfo, + FritzBoxBaseCoordinatorEntity, + FritzEntityDescription, +) from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @dataclass -class FritzBinarySensorEntityDescription(BinarySensorEntityDescription): +class FritzBinarySensorEntityDescription( + BinarySensorEntityDescription, FritzEntityDescription +): """Describes Fritz sensor entity.""" is_suitable: Callable[[ConnectionInfo], bool] = lambda info: info.wan_enabled @@ -31,15 +38,17 @@ class FritzBinarySensorEntityDescription(BinarySensorEntityDescription): SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = ( FritzBinarySensorEntityDescription( key="is_connected", - name="Connection", + translation_key="is_connected", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda status, _: bool(status.is_connected), ), FritzBinarySensorEntityDescription( key="is_linked", - name="Link", + translation_key="is_linked", device_class=BinarySensorDeviceClass.PLUG, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda status, _: bool(status.is_linked), ), ) @@ -62,25 +71,16 @@ async def async_setup_entry( async_add_entities(entities, True) -class FritzBoxBinarySensor(FritzBoxBaseEntity, BinarySensorEntity): +class FritzBoxBinarySensor(FritzBoxBaseCoordinatorEntity, BinarySensorEntity): """Define FRITZ!Box connectivity class.""" - def __init__( - self, - avm_wrapper: AvmWrapper, - device_friendly_name: str, - description: BinarySensorEntityDescription, - ) -> None: - """Init FRITZ!Box connectivity class.""" - self.entity_description = description - self._attr_name = f"{device_friendly_name} {description.name}" - self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" - super().__init__(avm_wrapper, device_friendly_name) + entity_description: FritzBinarySensorEntityDescription - def update(self) -> None: - """Update data.""" - _LOGGER.debug("Updating FRITZ!Box binary sensors") - if self.entity_description.key == "is_connected": - self._attr_is_on = bool(self._avm_wrapper.fritz_status.is_connected) - elif self.entity_description.key == "is_linked": - self._attr_is_on = bool(self._avm_wrapper.fritz_status.is_linked) + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if isinstance( + state := self.coordinator.data.get(self.entity_description.key), bool + ): + return state + return None diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index a186d50960..f732e32b75 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -39,28 +39,28 @@ class FritzButtonDescription(ButtonEntityDescription, FritzButtonDescriptionMixi BUTTONS: Final = [ FritzButtonDescription( key="firmware_update", - name="Firmware Update", + translation_key="firmware_update", device_class=ButtonDeviceClass.UPDATE, entity_category=EntityCategory.CONFIG, press_action=lambda avm_wrapper: avm_wrapper.async_trigger_firmware_update(), ), FritzButtonDescription( key="reboot", - name="Reboot", + translation_key="reboot", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=lambda avm_wrapper: avm_wrapper.async_trigger_reboot(), ), FritzButtonDescription( key="reconnect", - name="Reconnect", + translation_key="reconnect", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=lambda avm_wrapper: avm_wrapper.async_trigger_reconnect(), ), FritzButtonDescription( key="cleanup", - name="Cleanup", + translation_key="cleanup", icon="mdi:broom", entity_category=EntityCategory.CONFIG, press_action=lambda avm_wrapper: avm_wrapper.async_trigger_cleanup(), @@ -86,6 +86,7 @@ class FritzButton(ButtonEntity): """Defines a Fritz!Box base button.""" entity_description: FritzButtonDescription + _attr_has_entity_name = True def __init__( self, @@ -97,11 +98,11 @@ class FritzButton(ButtonEntity): self.entity_description = description self.avm_wrapper = avm_wrapper - self._attr_name = f"{device_friendly_name} {description.name}" self._attr_unique_id = f"{self.avm_wrapper.unique_id}-{description.key}" self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, avm_wrapper.mac)} + connections={(CONNECTION_NETWORK_MAC, avm_wrapper.mac)}, + name=device_friendly_name, ) async def async_press(self) -> None: diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 09103a0bcc..89a51581bf 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -35,7 +35,8 @@ from homeassistant.helpers import ( update_coordinator, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from .const import ( @@ -136,7 +137,9 @@ class HostInfo(TypedDict): status: bool -class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): +class FritzBoxTools( + update_coordinator.DataUpdateCoordinator[dict[str, bool | StateType]] +): """FritzBoxTools class.""" def __init__( @@ -175,6 +178,9 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): self._latest_firmware: str | None = None self._update_available: bool = False self._release_url: str | None = None + self._entity_update_functions: dict[ + str, Callable[[FritzStatus, StateType], Any] + ] = {} async def async_setup( self, options: MappingProxyType[str, Any] | None = None @@ -237,12 +243,36 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): ) self.device_is_router = self.fritz_status.has_wan_enabled - async def _async_update_data(self) -> None: + def register_entity_updates( + self, key: str, update_fn: Callable[[FritzStatus, StateType], Any] + ) -> Callable[[], None]: + """Register an entity to be updated by coordinator.""" + + def unregister_entity_updates() -> None: + """Unregister an entity to be updated by coordinator.""" + if key in self._entity_update_functions: + _LOGGER.debug("unregister entity %s from updates", key) + self._entity_update_functions.pop(key) + + if key not in self._entity_update_functions: + _LOGGER.debug("register entity %s for updates", key) + self._entity_update_functions[key] = update_fn + return unregister_entity_updates + + async def _async_update_data(self) -> dict[str, bool | StateType]: """Update FritzboxTools data.""" + enity_data: dict[str, bool | StateType] = {} try: await self.async_scan_devices() + for key, update_fn in self._entity_update_functions.items(): + _LOGGER.debug("update entity %s", key) + enity_data[key] = await self.hass.async_add_executor_job( + update_fn, self.fritz_status, self.data.get(key) + ) except FRITZ_EXCEPTIONS as ex: raise update_coordinator.UpdateFailed(ex) from ex + _LOGGER.debug("enity_data: %s", enity_data) + return enity_data @property def unique_id(self) -> str: @@ -981,6 +1011,54 @@ class FritzBoxBaseEntity: ) +@dataclass +class FritzRequireKeysMixin: + """Fritz entity description mix in.""" + + value_fn: Callable[[FritzStatus, Any], Any] + + +@dataclass +class FritzEntityDescription(EntityDescription, FritzRequireKeysMixin): + """Fritz entity base description.""" + + +class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity): + """Fritz host coordinator entity base class.""" + + coordinator: AvmWrapper + entity_description: FritzEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + avm_wrapper: AvmWrapper, + device_name: str, + description: FritzEntityDescription, + ) -> None: + """Init device info class.""" + super().__init__(avm_wrapper) + self.async_on_remove( + avm_wrapper.register_entity_updates(description.key, description.value_fn) + ) + self.entity_description = description + self._device_name = device_name + self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return DeviceInfo( + configuration_url=f"http://{self.coordinator.host}", + connections={(dr.CONNECTION_NETWORK_MAC, self.coordinator.mac)}, + identifiers={(DOMAIN, self.coordinator.unique_id)}, + manufacturer="AVM", + model=self.coordinator.model, + name=self._device_name, + sw_version=self.coordinator.current_firmware, + ) + + @dataclass class ConnectionInfo: """Fritz sensor connection information class.""" diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 1008426558..b117218e23 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.11.0", "xmltodict==0.13.0"], + "requirements": ["fritzconnection==1.12.0", "xmltodict==0.13.0"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 628d56dc45..2b15604609 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -5,9 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta import logging -from typing import Any -from fritzconnection.core.exceptions import FritzConnectionException from fritzconnection.lib.fritzstatus import FritzStatus from homeassistant.components.sensor import ( @@ -25,9 +23,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity +from .common import ( + AvmWrapper, + ConnectionInfo, + FritzBoxBaseCoordinatorEntity, + FritzEntityDescription, +) from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION _LOGGER = logging.getLogger(__name__) @@ -139,14 +143,7 @@ def _retrieve_link_attenuation_received_state( @dataclass -class FritzRequireKeysMixin: - """Fritz sensor data class.""" - - value_fn: Callable[[FritzStatus, Any], Any] - - -@dataclass -class FritzSensorEntityDescription(SensorEntityDescription, FritzRequireKeysMixin): +class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescription): """Describes Fritz sensor entity.""" is_suitable: Callable[[ConnectionInfo], bool] = lambda info: info.wan_enabled @@ -155,20 +152,20 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzRequireKeysMixi SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( FritzSensorEntityDescription( key="external_ip", - name="External IP", + translation_key="external_ip", icon="mdi:earth", value_fn=_retrieve_external_ip_state, ), FritzSensorEntityDescription( key="external_ipv6", - name="External IPv6", + translation_key="external_ipv6", icon="mdi:earth", value_fn=_retrieve_external_ipv6_state, is_suitable=lambda info: info.ipv6_active, ), FritzSensorEntityDescription( key="device_uptime", - name="Device Uptime", + translation_key="device_uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_device_uptime_state, @@ -176,14 +173,14 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="connection_uptime", - name="Connection Uptime", + translation_key="connection_uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_connection_uptime_state, ), FritzSensorEntityDescription( key="kb_s_sent", - name="Upload Throughput", + translation_key="kb_s_sent", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, @@ -192,7 +189,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="kb_s_received", - name="Download Throughput", + translation_key="kb_s_received", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, @@ -201,7 +198,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="max_kb_s_sent", - name="Max Connection Upload Throughput", + translation_key="max_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", @@ -210,7 +207,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="max_kb_s_received", - name="Max Connection Download Throughput", + translation_key="max_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", @@ -219,7 +216,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="gb_sent", - name="GB sent", + translation_key="gb_sent", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -228,7 +225,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="gb_received", - name="GB received", + translation_key="gb_received", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -237,7 +234,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_kb_s_sent", - name="Link Upload Throughput", + translation_key="link_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", @@ -245,7 +242,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_kb_s_received", - name="Link Download Throughput", + translation_key="link_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", @@ -253,7 +250,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_noise_margin_sent", - name="Link Upload Noise Margin", + translation_key="link_noise_margin_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:upload", value_fn=_retrieve_link_noise_margin_sent_state, @@ -261,7 +258,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_noise_margin_received", - name="Link Download Noise Margin", + translation_key="link_noise_margin_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:download", value_fn=_retrieve_link_noise_margin_received_state, @@ -269,7 +266,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_attenuation_sent", - name="Link Upload Power Attenuation", + translation_key="link_attenuation_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:upload", value_fn=_retrieve_link_attenuation_sent_state, @@ -277,7 +274,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_attenuation_received", - name="Link Download Power Attenuation", + translation_key="link_attenuation_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:download", value_fn=_retrieve_link_attenuation_received_state, @@ -304,36 +301,12 @@ async def async_setup_entry( async_add_entities(entities, True) -class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): +class FritzBoxSensor(FritzBoxBaseCoordinatorEntity, SensorEntity): """Define FRITZ!Box connectivity class.""" entity_description: FritzSensorEntityDescription - def __init__( - self, - avm_wrapper: AvmWrapper, - device_friendly_name: str, - description: FritzSensorEntityDescription, - ) -> None: - """Init FRITZ!Box connectivity class.""" - self.entity_description = description - self._last_device_value: str | None = None - self._attr_available = True - self._attr_name = f"{device_friendly_name} {description.name}" - self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" - super().__init__(avm_wrapper, device_friendly_name) - - def update(self) -> None: - """Update data.""" - _LOGGER.debug("Updating FRITZ!Box sensors") - - status: FritzStatus = self._avm_wrapper.fritz_status - try: - self._attr_native_value = ( - self._last_device_value - ) = self.entity_description.value_fn(status, self._last_device_value) - except FritzConnectionException: - _LOGGER.error("Error getting the state from the FRITZ!Box", exc_info=True) - self._attr_available = False - return - self._attr_available = True + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data.get(self.entity_description.key) diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 2d76479f2b..45262d6f8a 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -52,5 +52,39 @@ } } } + }, + "entity": { + "binary_sensor": { + "is_connected": { "name": "Connection" }, + "is_linked": { "name": "Link" } + }, + "button": { + "cleanup": { "name": "Cleanup" }, + "firmware_update": { "name": "Firmware update" }, + "reboot": { + "name": "[%key:component::button::entity_component::restart::name%]" + }, + "reconnect": { "name": "Reconnect" } + }, + "sensor": { + "connection_uptime": { "name": "Connection uptime" }, + "device_uptime": { "name": "Last restart" }, + "external_ip": { "name": "External IP" }, + "external_ipv6": { "name": "External IPv6" }, + "gb_received": { "name": "GB received" }, + "gb_sent": { "name": "GB sent" }, + "kb_s_received": { "name": "Download throughput" }, + "kb_s_sent": { "name": "Upload throughput" }, + "link_attenuation_received": { + "name": "Link download power attenuation" + }, + "link_attenuation_sent": { "name": "Link upload power attenuation" }, + "link_kb_s_received": { "name": "Link download throughput" }, + "link_kb_s_sent": { "name": "Link upload throughput" }, + "link_noise_margin_received": { "name": "Link download noise margin" }, + "link_noise_margin_sent": { "name": "Link upload noise margin" }, + "max_kb_s_received": { "name": "Max connection download throughput" }, + "max_kb_s_sent": { "name": "Max connection upload throughput" } + } } } diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 38f0e375e8..bd246dd914 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -113,8 +113,8 @@ class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC): self.ain = ain if entity_description is not None: + self._attr_has_entity_name = True self.entity_description = entity_description - self._attr_name = f"{self.data.name} {entity_description.name}" self._attr_unique_id = f"{ain}_{entity_description.key}" else: self._attr_name = self.data.name diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 35fd41240d..f87beb3407 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -40,14 +40,14 @@ class FritzBinarySensorEntityDescription( BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( FritzBinarySensorEntityDescription( key="alarm", - name="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] ), FritzBinarySensorEntityDescription( key="lock", - name="Button Lock on Device", + translation_key="lock", device_class=BinarySensorDeviceClass.LOCK, entity_category=EntityCategory.CONFIG, suitable=lambda device: device.lock is not None, @@ -55,7 +55,7 @@ BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( ), FritzBinarySensorEntityDescription( key="device_lock", - name="Button Lock via UI", + translation_key="device_lock", device_class=BinarySensorDeviceClass.LOCK, entity_category=EntityCategory.CONFIG, suitable=lambda device: device.device_lock is not None, @@ -87,17 +87,6 @@ class FritzboxBinarySensor(FritzBoxDeviceEntity, BinarySensorEntity): entity_description: FritzBinarySensorEntityDescription - def __init__( - self, - coordinator: FritzboxDataUpdateCoordinator, - ain: str, - entity_description: FritzBinarySensorEntityDescription, - ) -> None: - """Initialize the FritzBox entity.""" - super().__init__(coordinator, ain, entity_description) - self._attr_name = f"{self.data.name} {entity_description.name}" - self._attr_unique_id = f"{ain}_{entity_description.key}" - @property def is_on(self) -> bool | None: """Return true if sensor is on.""" diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index a048a7bba5..7922224e19 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -91,7 +91,7 @@ def value_scheduled_preset(device: FritzhomeDevice) -> str: SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( FritzSensorEntityDescription( key="temperature", - name="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -101,7 +101,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="humidity", - name="Humidity", + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, @@ -110,7 +110,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="battery", - name="Battery", + translation_key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, @@ -119,7 +119,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="power_consumption", - name="Power Consumption", + translation_key="power_consumption", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -128,7 +128,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="voltage", - name="Voltage", + translation_key="voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -137,7 +137,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="electric_current", - name="Electric Current", + translation_key="electric_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -146,7 +146,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="total_energy", - name="Total Energy", + translation_key="total_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -156,44 +156,50 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( # Thermostat Sensors FritzSensorEntityDescription( key="comfort_temperature", - name="Comfort Temperature", + translation_key="comfort_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_comfort_temperature, native_value=lambda device: device.comfort_temperature, # type: ignore[no-any-return] ), FritzSensorEntityDescription( key="eco_temperature", - name="Eco Temperature", + translation_key="eco_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_eco_temperature, native_value=lambda device: device.eco_temperature, # type: ignore[no-any-return] ), FritzSensorEntityDescription( key="nextchange_temperature", - name="Next Scheduled Temperature", + translation_key="nextchange_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_nextchange_temperature, native_value=lambda device: device.nextchange_temperature, # type: ignore[no-any-return] ), FritzSensorEntityDescription( key="nextchange_time", - name="Next Scheduled Change Time", + translation_key="nextchange_time", device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_nextchange_time, native_value=lambda device: utc_from_timestamp(device.nextchange_endperiod), ), FritzSensorEntityDescription( key="nextchange_preset", - name="Next Scheduled Preset", + translation_key="nextchange_preset", + entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_nextchange_temperature, native_value=value_nextchange_preset, ), FritzSensorEntityDescription( key="scheduled_preset", - name="Current Scheduled Preset", + translation_key="scheduled_preset", + entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_nextchange_temperature, native_value=value_scheduled_preset, ), diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index 738c454e23..0b4becd6ff 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -36,5 +36,41 @@ "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } + }, + "entity": { + "binary_sensor": { + "alarm": { "name": "Alarm" }, + "device_lock": { "name": "Button lock via UI" }, + "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%]" + } + } } } diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json index 9f1078b9b9..cde955caa1 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.11.0"] + "requirements": ["fritzconnection==1.12.0"] } diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py index 16e55f1272..94fd5f256a 100644 --- a/homeassistant/components/fronius/coordinator.py +++ b/homeassistant/components/fronius/coordinator.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any, TypeVar from pyfronius import BadStatusError, FroniusError -from homeassistant.components.sensor import SensorEntityDescription from homeassistant.core import callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -25,6 +24,7 @@ from .sensor import ( OHMPILOT_ENTITY_DESCRIPTIONS, POWER_FLOW_ENTITY_DESCRIPTIONS, STORAGE_ENTITY_DESCRIPTIONS, + FroniusSensorEntityDescription, ) if TYPE_CHECKING: @@ -41,7 +41,7 @@ class FroniusCoordinatorBase( default_interval: timedelta error_interval: timedelta - valid_descriptions: list[SensorEntityDescription] + valid_descriptions: list[FroniusSensorEntityDescription] MAX_FAILED_UPDATES = 3 diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 8c7055db8b..4e706db032 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -1,6 +1,7 @@ """Support for Fronius devices.""" from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final from homeassistant.components.sensor import ( @@ -25,6 +26,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -77,397 +79,360 @@ async def async_setup_entry( ) -INVERTER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +@dataclass +class FroniusSensorEntityDescription(SensorEntityDescription): + """Describes Fronius sensor entity.""" + + default_value: StateType | None = None + + +INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="energy_day", - name="Energy day", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_year", - name="Energy year", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_total", - name="Energy total", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="frequency_ac", - name="Frequency AC", + default_value=0, native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_ac", - name="Current AC", + default_value=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_dc", - name="Current DC", + default_value=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_dc_2", - name="Current DC 2", + default_value=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_ac", - name="Power AC", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac", - name="Voltage AC", + default_value=0, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc", - name="Voltage DC", + default_value=0, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc_2", - name="Voltage DC 2", + default_value=0, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), # device status entities - SensorEntityDescription( + FroniusSensorEntityDescription( key="inverter_state", - name="Inverter state", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="error_code", - name="Error code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="status_code", - name="Status code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="led_state", - name="LED state", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="led_color", - name="LED color", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), ] -LOGGER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +LOGGER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="co2_factor", - name="CO₂ factor", state_class=SensorStateClass.MEASUREMENT, icon="mdi:molecule-co2", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="cash_factor", - name="Grid export tariff", state_class=SensorStateClass.MEASUREMENT, icon="mdi:cash-plus", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="delivery_factor", - name="Grid import tariff", state_class=SensorStateClass.MEASUREMENT, icon="mdi:cash-minus", ), ] -METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="current_ac_phase_1", - name="Current AC phase 1", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_ac_phase_2", - name="Current AC phase 2", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_ac_phase_3", - name="Current AC phase 3", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_reactive_ac_consumed", - name="Energy reactive AC consumed", native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_reactive_ac_produced", - name="Energy reactive AC produced", native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_ac_minus", - name="Energy real AC minus", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_ac_plus", - name="Energy real AC plus", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_consumed", - name="Energy real consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_produced", - name="Energy real produced", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="frequency_phase_average", - name="Frequency phase average", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="meter_location", - name="Meter location", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent_phase_1", - name="Power apparent phase 1", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent_phase_2", - name="Power apparent phase 2", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent_phase_3", - name="Power apparent phase 3", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent", - name="Power apparent", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor_phase_1", - name="Power factor phase 1", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor_phase_2", - name="Power factor phase 2", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor_phase_3", - name="Power factor phase 3", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor", - name="Power factor", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive_phase_1", - name="Power reactive phase 1", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive_phase_2", - name="Power reactive phase 2", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive_phase_3", - name="Power reactive phase 3", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive", - name="Power reactive", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_phase_1", - name="Power real phase 1", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_phase_2", - name="Power real phase 2", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_phase_3", - name="Power real phase 3", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real", - name="Power real", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_1", - name="Voltage AC phase 1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_2", - name="Voltage AC phase 2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_3", - name="Voltage AC phase 3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_to_phase_12", - name="Voltage AC phase 1-2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_to_phase_23", - name="Voltage AC phase 2-3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_to_phase_31", - name="Voltage AC phase 3-1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -475,176 +440,158 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ ), ] -OHMPILOT_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +OHMPILOT_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="energy_real_ac_consumed", - name="Energy consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_ac", - name="Power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="temperature_channel_1", - name="Temperature channel 1", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="error_code", - name="Error code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="state_code", - name="State code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="state_message", - name="State message", entity_category=EntityCategory.DIAGNOSTIC, ), ] -POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="energy_day", - name="Energy day", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_year", - name="Energy year", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_total", - name="Energy total", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="meter_mode", - name="Meter mode", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_battery", - name="Power battery", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_grid", - name="Power grid", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_load", - name="Power load", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_photovoltaics", - name="Power photovoltaics", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="relative_autonomy", - name="Relative autonomy", + default_value=0, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:home-circle-outline", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="relative_self_consumption", - name="Relative self consumption", + default_value=0, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:solar-power", ), ] -STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +STORAGE_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="capacity_maximum", - name="Capacity maximum", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="capacity_designed", - name="Capacity designed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_dc", - name="Current DC", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc", - name="Voltage DC", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc_maximum_cell", - name="Voltage DC maximum cell", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc_minimum_cell", - name="Voltage DC minimum cell", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="state_of_charge", - name="State of charge", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="temperature_cell", - name="Temperature cell", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -655,7 +602,8 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEntity): """Defines a Fronius coordinator entity.""" - entity_descriptions: list[SensorEntityDescription] + entity_description: FroniusSensorEntityDescription + entity_descriptions: list[FroniusSensorEntityDescription] _attr_has_entity_name = True @@ -672,6 +620,7 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn ) self.solar_net_id = solar_net_id self._attr_native_value = self._get_entity_value() + self._attr_translation_key = self.entity_description.key def _device_data(self) -> dict[str, Any]: """Extract information for SolarNet device from coordinator data.""" @@ -682,7 +631,11 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn new_value = self.coordinator.data[self.solar_net_id][ self.entity_description.key ]["value"] - return round(new_value, 4) if isinstance(new_value, float) else new_value + if new_value is None: + return self.entity_description.default_value + if isinstance(new_value, float): + return round(new_value, 4) + return new_value @callback def _handle_coordinator_update(self) -> None: @@ -690,7 +643,8 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn try: self._attr_native_value = self._get_entity_value() except KeyError: - return + # sets state to `None` if no default_value is defined in entity description + self._attr_native_value = self.entity_description.default_value self.async_write_ha_state() diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index 711e363eeb..4a0f96ed8e 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -21,5 +21,219 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "invalid_host": "[%key:common::config_flow::error::invalid_host%]" } + }, + "entity": { + "sensor": { + "energy_day": { + "name": "Energy day" + }, + "energy_year": { + "name": "Energy year" + }, + "energy_total": { + "name": "Total energy" + }, + "frequency_ac": { + "name": "[%key:component::sensor::entity_component::frequency::name%]" + }, + "current_ac": { + "name": "AC current" + }, + "current_dc": { + "name": "DC current" + }, + "current_dc_2": { + "name": "DC current 2" + }, + "power_ac": { + "name": "AC power" + }, + "voltage_ac": { + "name": "AC voltage" + }, + "voltage_dc": { + "name": "DC voltage" + }, + "voltage_dc_2": { + "name": "DC voltage 2" + }, + "inverter_state": { + "name": "Inverter state" + }, + "error_code": { + "name": "Error code" + }, + "status_code": { + "name": "Status code" + }, + "led_state": { + "name": "LED state" + }, + "led_color": { + "name": "LED color" + }, + "co2_factor": { + "name": "CO₂ factor" + }, + "cash_factor": { + "name": "Grid export tariff" + }, + "delivery_factor": { + "name": "Grid import tariff" + }, + "current_ac_phase_1": { + "name": "Current phase 1" + }, + "current_ac_phase_2": { + "name": "Current phase 2" + }, + "current_ac_phase_3": { + "name": "Current phase 3" + }, + "energy_reactive_ac_consumed": { + "name": "Reactive energy consumed" + }, + "energy_reactive_ac_produced": { + "name": "Reactive energy produced" + }, + "energy_real_ac_minus": { + "name": "Real energy minus" + }, + "energy_real_ac_plus": { + "name": "Real energy plus" + }, + "energy_real_consumed": { + "name": "Real energy consumed" + }, + "energy_real_produced": { + "name": "Real energy produced" + }, + "frequency_phase_average": { + "name": "Frequency phase average" + }, + "meter_location": { + "name": "Meter location" + }, + "power_apparent_phase_1": { + "name": "Apparent power phase 1" + }, + "power_apparent_phase_2": { + "name": "Apparent power phase 2" + }, + "power_apparent_phase_3": { + "name": "Apparent power phase 3" + }, + "power_apparent": { + "name": "[%key:component::sensor::entity_component::apparent_power::name%]" + }, + "power_factor_phase_1": { + "name": "Power factor phase 1" + }, + "power_factor_phase_2": { + "name": "Power factor phase 2" + }, + "power_factor_phase_3": { + "name": "Power factor phase 3" + }, + "power_factor": { + "name": "[%key:component::sensor::entity_component::power_factor::name%]" + }, + "power_reactive_phase_1": { + "name": "Reactive power phase 1" + }, + "power_reactive_phase_2": { + "name": "Reactive power phase 2" + }, + "power_reactive_phase_3": { + "name": "Reactive power phase 3" + }, + "power_reactive": { + "name": "Reactive power" + }, + "power_real_phase_1": { + "name": "Real power phase 1" + }, + "power_real_phase_2": { + "name": "Real power phase 2" + }, + "power_real_phase_3": { + "name": "Real power phase 3" + }, + "power_real": { + "name": "Real power" + }, + "voltage_ac_phase_1": { + "name": "Voltage phase 1" + }, + "voltage_ac_phase_2": { + "name": "Voltage phase 2" + }, + "voltage_ac_phase_3": { + "name": "Voltage phase 3" + }, + "voltage_ac_phase_to_phase_12": { + "name": "Voltage phase 1-2" + }, + "voltage_ac_phase_to_phase_23": { + "name": "Voltage phase 2-3" + }, + "voltage_ac_phase_to_phase_31": { + "name": "Voltage phase 3-1" + }, + "energy_real_ac_consumed": { + "name": "Energy consumed" + }, + "power_real_ac": { + "name": "[%key:component::sensor::entity_component::power::name%]" + }, + "temperature_channel_1": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "state_code": { + "name": "State code" + }, + "state_message": { + "name": "State message" + }, + "meter_mode": { + "name": "Meter mode" + }, + "power_battery": { + "name": "Power battery" + }, + "power_grid": { + "name": "Power grid" + }, + "power_load": { + "name": "Power load" + }, + "power_photovoltaics": { + "name": "Power photovoltaics" + }, + "relative_autonomy": { + "name": "Relative autonomy" + }, + "relative_self_consumption": { + "name": "Relative self consumption" + }, + "capacity_maximum": { + "name": "Maximum capacity " + }, + "capacity_designed": { + "name": "Designed capacity" + }, + "voltage_dc_maximum_cell": { + "name": "Maximum cell voltage" + }, + "voltage_dc_minimum_cell": { + "name": "Minimum cell voltage" + }, + "state_of_charge": { + "name": "State of charge" + }, + "temperature_cell": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + } + } } } diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index b152b2d65d..8c04e59196 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -530,8 +530,9 @@ class IndexView(web_urldispatcher.AbstractResource): """ if ( request.path != "/" - and len(request.url.parts) > 1 - and request.url.parts[1] not in self.hass.data[DATA_PANELS] + and (parts := request.rel_url.parts) + and len(parts) > 1 + and parts[1] not in self.hass.data[DATA_PANELS] ): return None, set() diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2c13e81ee3..9e0659048b 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==20230309.1"] + "requirements": ["home-assistant-frontend==20230411.1"] } diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index a3fbdb52c1..7067f88297 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -1,17 +1,27 @@ """Config flow for Frontier Silicon Media Player integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any +from urllib.parse import urlparse from afsapi import AFSAPI, ConnectionError as FSConnectionError, InvalidPinException import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import ssdp from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult -from .const import CONF_PIN, CONF_WEBFSAPI_URL, DEFAULT_PIN, DEFAULT_PORT, DOMAIN +from .const import ( + CONF_PIN, + CONF_WEBFSAPI_URL, + DEFAULT_PIN, + DEFAULT_PORT, + DOMAIN, + SSDP_ATTR_SPEAKER_NAME, +) _LOGGER = logging.getLogger(__name__) @@ -32,12 +42,19 @@ STEP_DEVICE_CONFIG_DATA_SCHEMA = vol.Schema( ) +def hostname_from_url(url: str) -> str: + """Return the hostname from a url.""" + return str(urlparse(url).hostname) + + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Frontier Silicon Media Player.""" VERSION = 1 + _name: str _webfsapi_url: str + _reauth_entry: config_entries.ConfigEntry | None = None # Only used in reauth flows async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: """Handle the import of legacy configuration.yaml entries.""" @@ -101,6 +118,46 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=data_schema, errors=errors ) + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: + """Process entity discovered via SSDP.""" + + device_url = discovery_info.ssdp_location + if device_url is None: + return self.async_abort(reason="cannot_connect") + + device_hostname = hostname_from_url(device_url) + for entry in self._async_current_entries(include_ignore=False): + if device_hostname == hostname_from_url(entry.data[CONF_WEBFSAPI_URL]): + return self.async_abort(reason="already_configured") + + speaker_name = discovery_info.ssdp_headers.get(SSDP_ATTR_SPEAKER_NAME) + self.context["title_placeholders"] = {"name": speaker_name} + + try: + self._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.debug(exception) + return self.async_abort(reason="unknown") + + try: + # try to login with default pin + afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) + + unique_id = await afsapi.get_radio_id() + except InvalidPinException: + return self.async_abort(reason="invalid_auth") + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONF_WEBFSAPI_URL: self._webfsapi_url}, reload_on_update=True + ) + + self._name = await afsapi.get_friendly_name() + + return await self.async_step_confirm() + async def _async_step_device_config_if_needed(self) -> FlowResult: """Most users will not have changed the default PIN on their radio. @@ -111,22 +168,42 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # try to login with default pin afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) - name = await afsapi.get_friendly_name() + self._name = await afsapi.get_friendly_name() except InvalidPinException: # Ask for a PIN return await self.async_step_device_config() - self.context["title_placeholders"] = {"name": name} + self.context["title_placeholders"] = {"name": self._name} unique_id = await afsapi.get_radio_id() await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() - return self.async_create_entry( - title=name, - data={CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: DEFAULT_PIN}, + return await self._async_create_entry() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Allow the user to confirm adding the device. Used when the default PIN could successfully be used.""" + + if user_input is not None: + return await self._async_create_entry() + + self._set_confirm_only() + return self.async_show_form( + step_id="confirm", description_placeholders={"name": self._name} ) + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._webfsapi_url = config[CONF_WEBFSAPI_URL] + + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + + return await self.async_step_device_config() + async def async_step_device_config( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -145,7 +222,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: afsapi = AFSAPI(self._webfsapi_url, user_input[CONF_PIN]) - name = await afsapi.get_friendly_name() + self._name = await afsapi.get_friendly_name() except FSConnectionError: errors["base"] = "cannot_connect" @@ -155,16 +232,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception(exception) errors["base"] = "unknown" else: + if self._reauth_entry: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data={CONF_PIN: user_input[CONF_PIN]}, + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + unique_id = await afsapi.get_radio_id() - await self.async_set_unique_id(unique_id) + await self.async_set_unique_id(unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() - return self.async_create_entry( - title=name, - data={ - CONF_WEBFSAPI_URL: self._webfsapi_url, - CONF_PIN: user_input[CONF_PIN], - }, - ) + return await self._async_create_entry(user_input[CONF_PIN]) data_schema = self.add_suggested_values_to_schema( STEP_DEVICE_CONFIG_DATA_SCHEMA, user_input @@ -174,3 +253,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=data_schema, errors=errors, ) + + async def _async_create_entry(self, pin: str | None = None): + """Create the entry.""" + + return self.async_create_entry( + title=self._name, + data={CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: pin or DEFAULT_PIN}, + ) diff --git a/homeassistant/components/frontier_silicon/const.py b/homeassistant/components/frontier_silicon/const.py index 9206db8916..34201fe8f4 100644 --- a/homeassistant/components/frontier_silicon/const.py +++ b/homeassistant/components/frontier_silicon/const.py @@ -4,6 +4,9 @@ DOMAIN = "frontier_silicon" CONF_WEBFSAPI_URL = "webfsapi_url" CONF_PIN = "pin" +SSDP_ST = "urn:schemas-frontier-silicon-com:undok:fsapi:1" +SSDP_ATTR_SPEAKER_NAME = "SPEAKER-NAME" + DEFAULT_PIN = "1234" DEFAULT_PORT = 80 diff --git a/homeassistant/components/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json index 62e7e61703..9cc928e6f8 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/frontier_silicon", "iot_class": "local_polling", - "requirements": ["afsapi==0.2.7"] + "requirements": ["afsapi==0.2.7"], + "ssdp": [{ "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1" }] } diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index b05ba272a1..54c17429b5 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -54,21 +54,7 @@ async def async_setup_platform( """Set up the Frontier Silicon platform. YAML is deprecated, and imported automatically. - SSDP discovery is temporarily retained - to be refactor subsequently. """ - if discovery_info is not None: - webfsapi_url = await AFSAPI.get_webfsapi_endpoint( - discovery_info["ssdp_description"] - ) - afsapi = AFSAPI(webfsapi_url, DEFAULT_PIN) - - name = await afsapi.get_friendly_name() - async_add_entities( - [AFSAPIDevice(name, afsapi)], - True, - ) - - return ir.async_create_issue( hass, @@ -328,7 +314,9 @@ class AFSAPIDevice(MediaPlayerEntity): await self.fs_device.set_eq_preset(mode) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Browse media library and preset stations.""" if not media_content_id: diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index 3a0a504761..f40abe1675 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -13,6 +13,9 @@ "data": { "pin": "[%key:common::config_flow::data::pin%]" } + }, + "confirm": { + "description": "Do you want to set up {name}?" } }, "error": { @@ -21,7 +24,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "issues": { diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py index 0fcd8c3543..8c73d47dd7 100644 --- a/homeassistant/components/fully_kiosk/media_player.py +++ b/homeassistant/components/fully_kiosk/media_player.py @@ -72,7 +72,7 @@ class FullyMediaPlayer(FullyKioskEntity, MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the WebSocket media browsing helper.""" diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 0b1e040c79..693959561d 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/generic", "iot_class": "local_push", - "requirements": ["ha-av==10.0.0", "pillow==9.4.0"] + "requirements": ["ha-av==10.0.0", "pillow==9.5.0"] } diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index dfd6be14e6..c1ebc948b9 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -22,6 +22,8 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, callback from homeassistant.helpers import condition @@ -175,6 +177,15 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): async def _async_startup(event): """Init on startup.""" sensor_state = self.hass.states.get(self._sensor_entity_id) + if sensor_state is None or sensor_state.state in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ): + _LOGGER.debug( + "The sensor state is %s, initialization is delayed", + sensor_state.state if sensor_state is not None else "None", + ) + return await self._async_sensor_changed(self._sensor_entity_id, None, sensor_state) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) @@ -259,7 +270,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): return self._state = True await self._async_operate(force=True) - await self.async_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn hygrostat off.""" @@ -268,7 +279,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self._state = False if self._is_device_active: await self._async_device_turn_off() - await self.async_update_ha_state() + self.async_write_ha_state() async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" @@ -277,12 +288,12 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): if self._is_away and self._away_fixed: self._saved_target_humidity = humidity - await self.async_update_ha_state() + self.async_write_ha_state() return self._target_humidity = humidity await self._async_operate() - await self.async_update_ha_state() + self.async_write_ha_state() @property def min_humidity(self): @@ -318,7 +329,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): await self._async_update_humidity(new_state.state) await self._async_operate() - await self.async_update_ha_state() + self.async_write_ha_state() async def _async_sensor_not_responding(self, now=None): """Handle sensor stale event.""" @@ -460,4 +471,4 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): ) await self._async_operate(force=True) - await self.async_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index 895775495f..33ddfae6fe 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -16,9 +16,6 @@ URL = "http://powietrze.gios.gov.pl/pjp/current/station_details/info/{station_id API_TIMEOUT: Final = 30 -ATTR_INDEX: Final = "index" -ATTR_STATION: Final = "station" - ATTR_C6H6: Final = "c6h6" ATTR_CO: Final = "co" ATTR_NO2: Final = "no2" diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 9c73b35889..f078cc074e 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -4,7 +4,8 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any, cast + +from gios.model import GiosSensors from homeassistant.components.sensor import ( DOMAIN as PLATFORM, @@ -14,11 +15,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_NAME, - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - CONF_NAME, -) +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType @@ -32,13 +29,11 @@ from .const import ( ATTR_AQI, ATTR_C6H6, ATTR_CO, - ATTR_INDEX, ATTR_NO2, ATTR_O3, ATTR_PM10, ATTR_PM25, ATTR_SO2, - ATTR_STATION, ATTRIBUTION, DOMAIN, MANUFACTURER, @@ -49,17 +44,23 @@ _LOGGER = logging.getLogger(__name__) @dataclass -class GiosSensorEntityDescription(SensorEntityDescription): +class GiosSensorRequiredKeysMixin: + """Class for GIOS entity required keys.""" + + value: Callable[[GiosSensors], StateType] + + +@dataclass +class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKeysMixin): """Class describing GIOS sensor entities.""" - value: Callable | None = round + subkey: str | None = None SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_AQI, - name="AQI", - value=None, + value=lambda sensors: sensors.aqi.value if sensors.aqi else None, icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], @@ -67,51 +68,111 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_C6H6, - name="C6H6", + value=lambda sensors: sensors.c6h6.value if sensors.c6h6 else None, + suggested_display_precision=0, icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="c6h6", ), GiosSensorEntityDescription( key=ATTR_CO, - name="CO", + value=lambda sensors: sensors.co.value if sensors.co else None, + suggested_display_precision=0, + icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="co", ), GiosSensorEntityDescription( key=ATTR_NO2, - name="NO2", + value=lambda sensors: sensors.no2.value if sensors.no2 else None, + suggested_display_precision=0, 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, + subkey="index", + value=lambda sensors: sensors.no2.index if sensors.no2 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="no2_index", ), GiosSensorEntityDescription( key=ATTR_O3, - name="O3", + value=lambda sensors: sensors.o3.value if sensors.o3 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.OZONE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="o3", + ), + GiosSensorEntityDescription( + key=ATTR_O3, + subkey="index", + value=lambda sensors: sensors.o3.index if sensors.o3 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="o3_index", ), GiosSensorEntityDescription( key=ATTR_PM10, - name="PM10", + value=lambda sensors: sensors.pm10.value if sensors.pm10 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="pm10", + ), + GiosSensorEntityDescription( + key=ATTR_PM10, + subkey="index", + value=lambda sensors: sensors.pm10.index if sensors.pm10 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="pm10_index", ), GiosSensorEntityDescription( key=ATTR_PM25, - name="PM2.5", + value=lambda sensors: sensors.pm25.value if sensors.pm25 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="pm25", + ), + GiosSensorEntityDescription( + key=ATTR_PM25, + subkey="index", + value=lambda sensors: sensors.pm25.index if sensors.pm25 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="pm25_index", ), GiosSensorEntityDescription( key=ATTR_SO2, - name="SO2", + value=lambda sensors: sensors.so2.value if sensors.so2 else None, + suggested_display_precision=0, 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, + subkey="index", + value=lambda sensors: sensors.so2.index if sensors.so2 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="so2_index", ), ) @@ -140,15 +201,13 @@ async def async_setup_entry( ) entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) - sensors: list[GiosSensor | GiosAqiSensor] = [] + sensors: list[GiosSensor] = [] for description in SENSOR_TYPES: if getattr(coordinator.data, description.key) is None: continue - if description.key == ATTR_AQI: - sensors.append(GiosAqiSensor(name, coordinator, description)) - else: - sensors.append(GiosSensor(name, coordinator, description)) + sensors.append(GiosSensor(name, coordinator, description)) + async_add_entities(sensors) @@ -174,45 +233,27 @@ class GiosSensor(CoordinatorEntity[GiosDataUpdateCoordinator], SensorEntity): name=name, configuration_url=URL.format(station_id=coordinator.gios.station_id), ) - self._attr_unique_id = f"{coordinator.gios.station_id}-{description.key}" - self._attrs: dict[str, Any] = { - ATTR_STATION: self.coordinator.gios.station_name, - } + if description.subkey: + self._attr_unique_id = ( + f"{coordinator.gios.station_id}-{description.key}-{description.subkey}" + ) + else: + self._attr_unique_id = f"{coordinator.gios.station_id}-{description.key}" self.entity_description = description - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - self._attrs[ATTR_NAME] = getattr( - self.coordinator.data, self.entity_description.key - ).name - self._attrs[ATTR_INDEX] = getattr( - self.coordinator.data, self.entity_description.key - ).index - return self._attrs - @property def native_value(self) -> StateType: """Return the state.""" - state = getattr(self.coordinator.data, self.entity_description.key).value - assert self.entity_description.value is not None - return cast(StateType, self.entity_description.value(state)) - - -class GiosAqiSensor(GiosSensor): - """Define an GIOS AQI sensor.""" - - @property - def native_value(self) -> StateType: - """Return the state.""" - return cast( - StateType, getattr(self.coordinator.data, self.entity_description.key).value - ) + return self.entity_description.value(self.coordinator.data) @property def available(self) -> bool: """Return if entity is available.""" available = super().available - return available and bool( - getattr(self.coordinator.data, self.entity_description.key) - ) + sensor_data = getattr(self.coordinator.data, self.entity_description.key) + + # Sometimes the API returns sensor data without indexes + if self.entity_description.subkey: + return available and bool(sensor_data.index) + + return available and bool(sensor_data) diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index a76bd3f612..5387c043fc 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -26,6 +26,7 @@ "entity": { "sensor": { "aqi": { + "name": "[%key:component::sensor::entity_component::aqi::name%]", "state": { "very_bad": "Very bad", "bad": "Bad", @@ -34,6 +35,82 @@ "good": "Good", "very_good": "Very good" } + }, + "c6h6": { + "name": "Benzene" + }, + "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": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "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": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "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": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "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": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "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": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } } } } diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py index 514cb9e0ad..db5b189d5e 100644 --- a/homeassistant/components/gitter/sensor.py +++ b/homeassistant/components/gitter/sensor.py @@ -23,7 +23,6 @@ ATTR_USERNAME = "username" DEFAULT_NAME = "Gitter messages" DEFAULT_ROOM = "home-assistant/home-assistant" -ICON = "mdi:message-cog" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -59,6 +58,8 @@ def setup_platform( class GitterSensor(SensorEntity): """Representation of a Gitter sensor.""" + _attr_icon = "mdi:message-cog" + def __init__(self, data, room, name, username): """Initialize the sensor.""" self._name = name @@ -93,11 +94,6 @@ class GitterSensor(SensorEntity): ATTR_MENTION: self._mention, } - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the state.""" diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index cf55118a91..04e133248a 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -45,7 +45,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect.""" api = get_api(hass, data) try: - await api.get_data("all") + await api.get_ha_sensor_data() except GlancesApiError as err: raise CannotConnect from err diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 8ffd2a2da6..01e498a889 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -36,7 +36,6 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Get the latest data from the Glances REST API.""" try: - await self.api.get_data("all") + return await self.api.get_ha_sensor_data() except exceptions.GlancesApiError as err: raise UpdateFailed from err - return self.api.data diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index b8b5d80a20..8b836fba3e 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -15,7 +15,6 @@ from homeassistant.const import ( CONF_NAME, PERCENTAGE, REVOLUTIONS_PER_MINUTE, - STATE_UNAVAILABLE, Platform, UnitOfInformation, UnitOfTemperature, @@ -45,8 +44,8 @@ class GlancesSensorEntityDescription( """Describe Glances sensor entity.""" -SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( - GlancesSensorEntityDescription( +SENSOR_TYPES = { + ("fs", "disk_use_percent"): GlancesSensorEntityDescription( key="disk_use_percent", type="fs", name_suffix="used percent", @@ -54,7 +53,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("fs", "disk_use"): GlancesSensorEntityDescription( key="disk_use", type="fs", name_suffix="used", @@ -63,7 +62,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("fs", "disk_free"): GlancesSensorEntityDescription( key="disk_free", type="fs", name_suffix="free", @@ -72,7 +71,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_use_percent"): GlancesSensorEntityDescription( key="memory_use_percent", type="mem", name_suffix="RAM used percent", @@ -80,7 +79,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_use"): GlancesSensorEntityDescription( key="memory_use", type="mem", name_suffix="RAM used", @@ -89,7 +88,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_free"): GlancesSensorEntityDescription( key="memory_free", type="mem", name_suffix="RAM free", @@ -98,7 +97,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_use_percent"): GlancesSensorEntityDescription( key="swap_use_percent", type="memswap", name_suffix="Swap used percent", @@ -106,7 +105,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_use"): GlancesSensorEntityDescription( key="swap_use", type="memswap", name_suffix="Swap used", @@ -115,7 +114,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_free"): GlancesSensorEntityDescription( key="swap_free", type="memswap", name_suffix="Swap free", @@ -124,42 +123,42 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("load", "processor_load"): GlancesSensorEntityDescription( key="processor_load", type="load", name_suffix="CPU load", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_running"): GlancesSensorEntityDescription( key="process_running", type="processcount", name_suffix="Running", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_total"): GlancesSensorEntityDescription( key="process_total", type="processcount", name_suffix="Total", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_thread"): GlancesSensorEntityDescription( key="process_thread", type="processcount", name_suffix="Thread", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_sleeping"): GlancesSensorEntityDescription( key="process_sleeping", type="processcount", name_suffix="Sleeping", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("cpu", "cpu_use_percent"): GlancesSensorEntityDescription( key="cpu_use_percent", type="cpu", name_suffix="CPU used", @@ -167,7 +166,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "temperature_core"): GlancesSensorEntityDescription( key="temperature_core", type="sensors", name_suffix="Temperature", @@ -175,7 +174,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "temperature_hdd"): GlancesSensorEntityDescription( key="temperature_hdd", type="sensors", name_suffix="Temperature", @@ -183,7 +182,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "fan_speed"): GlancesSensorEntityDescription( key="fan_speed", type="sensors", name_suffix="Fan speed", @@ -191,7 +190,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "battery"): GlancesSensorEntityDescription( key="battery", type="sensors", name_suffix="Charge", @@ -200,14 +199,14 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:battery", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_active"): GlancesSensorEntityDescription( key="docker_active", type="docker", name_suffix="Containers active", icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_cpu_use"): GlancesSensorEntityDescription( key="docker_cpu_use", type="docker", name_suffix="Containers CPU used", @@ -215,7 +214,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_memory_use"): GlancesSensorEntityDescription( key="docker_memory_use", type="docker", name_suffix="Containers RAM used", @@ -224,21 +223,21 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("raid", "used"): GlancesSensorEntityDescription( key="used", type="raid", name_suffix="Raid used", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("raid", "available"): GlancesSensorEntityDescription( key="available", type="raid", name_suffix="Raid available", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), -) +} async def async_setup_entry( @@ -266,64 +265,40 @@ async def async_setup_entry( entity_id, new_unique_id=f"{config_entry.entry_id}-{new_key}" ) - for description in SENSOR_TYPES: - if description.type == "fs": - # fs will provide a list of disks attached - for disk in coordinator.data[description.type]: - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {disk['mnt_point']} {description.name_suffix}", - f"{disk['mnt_point']}-{description.key}", - ) - entities.append( - GlancesSensor( - coordinator, - name, - disk["mnt_point"], - description, - ) - ) - elif description.type == "sensors": - # sensors will provide temp for different devices - for sensor in coordinator.data[description.type]: - if sensor["type"] == description.key: + for sensor_type, sensors in coordinator.data.items(): + if sensor_type in ["fs", "sensors", "raid"]: + for sensor_label, params in sensors.items(): + for param in params: + sensor_description = SENSOR_TYPES[(sensor_type, param)] _migrate_old_unique_ids( hass, - f"{coordinator.host}-{name} {sensor['label']} {description.name_suffix}", - f"{sensor['label']}-{description.key}", + f"{coordinator.host}-{name} {sensor_label} {sensor_description.name_suffix}", + f"{sensor_label}-{sensor_description.key}", ) entities.append( GlancesSensor( coordinator, name, - sensor["label"], - description, + sensor_label, + sensor_description, ) ) - elif description.type == "raid": - for raid_device in coordinator.data[description.type]: + else: + for sensor in sensors: + sensor_description = SENSOR_TYPES[(sensor_type, sensor)] _migrate_old_unique_ids( hass, - f"{coordinator.host}-{name} {raid_device} {description.name_suffix}", - f"{raid_device}-{description.key}", + f"{coordinator.host}-{name} {sensor_description.name_suffix}", + f"-{sensor_description.key}", ) entities.append( - GlancesSensor(coordinator, name, raid_device, description) + GlancesSensor( + coordinator, + name, + "", + sensor_description, + ) ) - elif coordinator.data[description.type]: - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {description.name_suffix}", - f"-{description.key}", - ) - entities.append( - GlancesSensor( - coordinator, - name, - "", - description, - ) - ) async_add_entities(entities) @@ -354,114 +329,10 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{sensor_name_prefix}-{description.key}" @property - def native_value(self) -> StateType: # noqa: C901 + def native_value(self) -> StateType: """Return the state of the resources.""" - if (value := self.coordinator.data) is None: - return None - state: StateType = None - if self.entity_description.type == "fs": - for var in value["fs"]: - if var["mnt_point"] == self._sensor_name_prefix: - disk = var - break - if self.entity_description.key == "disk_free": - try: - state = round(disk["free"] / 1024**3, 1) - except KeyError: - state = round( - (disk["size"] - disk["used"]) / 1024**3, - 1, - ) - elif self.entity_description.key == "disk_use": - state = round(disk["used"] / 1024**3, 1) - elif self.entity_description.key == "disk_use_percent": - state = disk["percent"] - elif self.entity_description.key == "battery": - for sensor in value["sensors"]: - if ( - sensor["type"] == "battery" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "fan_speed": - for sensor in value["sensors"]: - if ( - sensor["type"] == "fan_speed" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "temperature_core": - for sensor in value["sensors"]: - if ( - sensor["type"] == "temperature_core" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "temperature_hdd": - for sensor in value["sensors"]: - if ( - sensor["type"] == "temperature_hdd" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "memory_use_percent": - state = value["mem"]["percent"] - elif self.entity_description.key == "memory_use": - state = round(value["mem"]["used"] / 1024**2, 1) - elif self.entity_description.key == "memory_free": - state = round(value["mem"]["free"] / 1024**2, 1) - elif self.entity_description.key == "swap_use_percent": - state = value["memswap"]["percent"] - elif self.entity_description.key == "swap_use": - state = round(value["memswap"]["used"] / 1024**3, 1) - elif self.entity_description.key == "swap_free": - state = round(value["memswap"]["free"] / 1024**3, 1) - elif self.entity_description.key == "processor_load": - # Windows systems don't provide load details - try: - state = value["load"]["min15"] - except KeyError: - state = value["cpu"]["total"] - elif self.entity_description.key == "process_running": - state = value["processcount"]["running"] - elif self.entity_description.key == "process_total": - state = value["processcount"]["total"] - elif self.entity_description.key == "process_thread": - state = value["processcount"]["thread"] - elif self.entity_description.key == "process_sleeping": - state = value["processcount"]["sleeping"] - elif self.entity_description.key == "cpu_use_percent": - state = value["quicklook"]["cpu"] - elif self.entity_description.key == "docker_active": - count = 0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - count += 1 - state = count - except KeyError: - state = count - elif self.entity_description.key == "docker_cpu_use": - cpu_use = 0.0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - cpu_use += container["cpu"]["total"] - state = round(cpu_use, 1) - except KeyError: - state = STATE_UNAVAILABLE - elif self.entity_description.key == "docker_memory_use": - mem_use = 0.0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - mem_use += container["memory"]["usage"] - state = round(mem_use / 1024**2, 1) - except KeyError: - state = STATE_UNAVAILABLE - elif self.entity_description.type == "raid": - for raid_device, raid in value["raid"].items(): - if raid_device == self._sensor_name_prefix: - state = raid[self.entity_description.key] + value = self.coordinator.data[self.entity_description.type] - return state + if isinstance(value.get(self._sensor_name_prefix), dict): + return value[self._sensor_name_prefix][self.entity_description.key] + return value[self.entity_description.key] diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 53e093758c..f40d225361 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.2.25"] + "requirements": ["goodwe==0.2.31"] } diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index 9f997daec4..3f9714aa37 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -39,8 +39,13 @@ class GoodweNumberEntityDescription( """Class describing Goodwe number entities.""" +def _get_setting_unit(inverter: Inverter, setting: str) -> str: + """Return the unit of an inverter setting.""" + return next((s.unit for s in inverter.settings() if s.id_ == setting), "") + + NUMBERS = ( - # non DT inverters (limit in W) + # Export limit in W GoodweNumberEntityDescription( key="grid_export_limit", name="Grid export limit", @@ -53,9 +58,9 @@ NUMBERS = ( native_max_value=10000, getter=lambda inv: inv.get_grid_export_limit(), setter=lambda inv, val: inv.set_grid_export_limit(val), - filter=lambda inv: type(inv).__name__ != "DT", + filter=lambda inv: _get_setting_unit(inv, "grid_export_limit") != "%", ), - # DT inverters (limit is in %) + # Export limit in % GoodweNumberEntityDescription( key="grid_export_limit", name="Grid export limit", @@ -67,7 +72,7 @@ NUMBERS = ( native_max_value=100, getter=lambda inv: inv.get_grid_export_limit(), setter=lambda inv, val: inv.set_grid_export_limit(val), - filter=lambda inv: type(inv).__name__ == "DT", + filter=lambda inv: _get_setting_unit(inv, "grid_export_limit") == "%", ), GoodweNumberEntityDescription( key="battery_discharge_depth", diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index b4adf97c3e..d76d620283 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -19,13 +19,16 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + POWER_VOLT_AMPERE_REACTIVE, EntityCategory, + UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfFrequency, UnitOfPower, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo @@ -111,6 +114,20 @@ _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { value=lambda coordinator, sensor: coordinator.total_sensor_value(sensor), available=lambda coordinator: coordinator.data is not None, ), + "VA": GoodweSensorEntityDescription( + key="VA", + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + entity_registry_enabled_default=False, + ), + "var": GoodweSensorEntityDescription( + key="var", + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + entity_registry_enabled_default=False, + ), "C": GoodweSensorEntityDescription( key="C", device_class=SensorDeviceClass.TEMPERATURE, @@ -123,6 +140,13 @@ _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, ), + "h": GoodweSensorEntityDescription( + key="h", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_registry_enabled_default=False, + ), "%": GoodweSensorEntityDescription( key="%", state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 934b34c126..e05a6f6fb9 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -43,6 +43,7 @@ from .const import ( EVENT_IN, EVENT_IN_DAYS, EVENT_IN_WEEKS, + EVENT_LOCATION, EVENT_START_DATE, EVENT_START_DATETIME, EVENT_SUMMARY, @@ -116,6 +117,7 @@ ADD_EVENT_SERVICE_SCHEMA = vol.All( vol.Required(EVENT_CALENDAR_ID): cv.string, vol.Required(EVENT_SUMMARY): cv.string, vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, + vol.Optional(EVENT_LOCATION, default=""): cv.string, vol.Inclusive( EVENT_START_DATE, "dates", "Start and end dates must both be specified" ): cv.date, @@ -283,16 +285,18 @@ async def async_setup_add_event_service( raise ValueError( "Missing required fields to set start or end date/datetime" ) - + event = Event( + summary=call.data[EVENT_SUMMARY], + description=call.data[EVENT_DESCRIPTION], + start=start, + end=end, + ) + if location := call.data.get(EVENT_LOCATION): + event.location = location try: await calendar_service.async_create_event( call.data[EVENT_CALENDAR_ID], - Event( - summary=call.data[EVENT_SUMMARY], - description=call.data[EVENT_DESCRIPTION], - start=start, - end=end, - ), + event, ) except ApiException as err: raise HomeAssistantError(str(err)) from err diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index d20155ad90..347e844494 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -24,6 +24,7 @@ from homeassistant.components.calendar import ( ENTITY_ID_FORMAT, EVENT_DESCRIPTION, EVENT_END, + EVENT_LOCATION, EVENT_RRULE, EVENT_START, EVENT_SUMMARY, @@ -282,8 +283,8 @@ class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): "Unable to get events: Sync from server has not completed" ) return self.data.overlapping( - dt_util.as_local(start_date), - dt_util.as_local(end_date), + start_date, + end_date, ) @property @@ -509,6 +510,8 @@ class GoogleCalendarEntity( EVENT_DESCRIPTION: kwargs.get(EVENT_DESCRIPTION), } ) + if location := kwargs.get(EVENT_LOCATION): + event.location = location if rrule := kwargs.get(EVENT_RRULE): event.recurrence = [f"{RRULE_PREFIX}{rrule}"] @@ -595,17 +598,20 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> if start is None or end is None: raise ValueError("Missing required fields to set start or end date/datetime") + event = Event( + summary=call.data[EVENT_SUMMARY], + description=call.data[EVENT_DESCRIPTION], + start=start, + end=end, + ) + if location := call.data.get(EVENT_LOCATION): + event.location = location try: await cast( CalendarSyncUpdateCoordinator, entity.coordinator ).sync.api.async_create_event( entity.calendar_id, - Event( - summary=call.data[EVENT_SUMMARY], - description=call.data[EVENT_DESCRIPTION], - start=start, - end=end, - ), + event, ) except ApiException as err: raise HomeAssistantError(str(err)) from err diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index 6a2c1974f6..add98441e3 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -38,6 +38,7 @@ EVENT_END_DATETIME = "end_date_time" EVENT_IN = "in" EVENT_IN_DAYS = "days" EVENT_IN_WEEKS = "weeks" +EVENT_LOCATION = "location" EVENT_START_DATE = "start_date" EVENT_START_DATETIME = "start_date_time" EVENT_SUMMARY = "summary" diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index d79cd105c8..f4177e8c30 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/calendar.google/", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==4.1.2", "oauth2client==4.1.3"] + "requirements": ["gcal-sync==4.1.4", "oauth2client==4.1.3"] } diff --git a/homeassistant/components/google/services.yaml b/homeassistant/components/google/services.yaml index a303ad7e18..e7eeef7594 100644 --- a/homeassistant/components/google/services.yaml +++ b/homeassistant/components/google/services.yaml @@ -103,3 +103,9 @@ create_event: example: '"days": 2 or "weeks": 2' selector: object: + location: + name: Location + description: The location of the event. Optional. + example: "Conference Room - F123, Bldg. 002" + selector: + text: diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index b248ffbac2..3752574f31 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -75,7 +75,6 @@ from homeassistant.util.percentage import ( from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( - CHALLENGE_ACK_NEEDED, CHALLENGE_FAILED_PIN_NEEDED, CHALLENGE_PIN_NEEDED, ERR_ALREADY_ARMED, @@ -2131,14 +2130,6 @@ def _verify_pin_challenge(data, state, challenge): raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED) -def _verify_ack_challenge(data, state, challenge): - """Verify an ack challenge.""" - if not data.config.should_2fa(state): - return - if not challenge or not challenge.get("ack"): - raise ChallengeNeeded(CHALLENGE_ACK_NEEDED) - - MEDIA_COMMAND_SUPPORT_MAPPING = { COMMAND_MEDIA_NEXT: media_player.SUPPORT_NEXT_TRACK, COMMAND_MEDIA_PAUSE: media_player.SUPPORT_PAUSE, diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 93699321ed..7a9ca70bf1 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -150,6 +150,14 @@ class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent): "url": "https://www.home-assistant.io/integrations/google_assistant_sdk/", } + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + language_code = self.entry.options.get( + CONF_LANGUAGE_CODE, default_language_code(self.hass) + ) + return [language_code] + async def async_process( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: diff --git a/homeassistant/components/google_assistant_sdk/notify.py b/homeassistant/components/google_assistant_sdk/notify.py index 0c0338dd97..fa117b579a 100644 --- a/homeassistant/components/google_assistant_sdk/notify.py +++ b/homeassistant/components/google_assistant_sdk/notify.py @@ -20,7 +20,7 @@ LANG_TO_BROADCAST_COMMAND = { "it": ("Trasmetti {0}", "Trasmetti in {1} {0}"), "ja": ("{0}とブロードキャストして", "{0}と{1}にブロードキャストして"), "ko": ("{0} 라고 방송해 줘", "{0} 라고 {1}에 방송해 줘"), - "pt": ("Transmite {0}", "Transmite para {1} {0}"), + "pt": ("Transmitir {0}", "Transmitir {0} para {1}"), } diff --git a/homeassistant/components/google_mail/notify.py b/homeassistant/components/google_mail/notify.py index eba38c3249..974b2e4e4b 100644 --- a/homeassistant/components/google_mail/notify.py +++ b/homeassistant/components/google_mail/notify.py @@ -2,7 +2,7 @@ from __future__ import annotations import base64 -from email.message import EmailMessage +from email.mime.text import MIMEText from typing import Any from googleapiclient.http import HttpRequest @@ -43,8 +43,7 @@ class GMailNotificationService(BaseNotificationService): data: dict[str, Any] = kwargs.get(ATTR_DATA) or {} title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - email = EmailMessage() - email.set_content(message) + email = MIMEText(message, "html") if to_addrs := kwargs.get(ATTR_TARGET): email["To"] = ", ".join(to_addrs) email["From"] = data.get(ATTR_FROM, ATTR_ME) diff --git a/homeassistant/components/google_maps/manifest.json b/homeassistant/components/google_maps/manifest.json index 6ba831442d..d7364e834a 100644 --- a/homeassistant/components/google_maps/manifest.json +++ b/homeassistant/components/google_maps/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_maps", "iot_class": "cloud_polling", "loggers": ["locationsharinglib"], - "requirements": ["locationsharinglib==4.1.5"] + "requirements": ["locationsharinglib==5.0.1"] } diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 62189fdde0..01f98b996d 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -1,9 +1,17 @@ """Support for interface with a Gree climate systems.""" from __future__ import annotations -from typing import Any +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, cast -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from greeclimate.device import Device + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -13,6 +21,81 @@ from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DISPATCHERS, DOMAIN from .entity import GreeEntity +@dataclass +class GreeRequiredKeysMixin: + """Mixin for required keys.""" + + get_value_fn: Callable[[Device], bool] + set_value_fn: Callable[[Device, bool], None] + + +@dataclass +class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin): + """Describes Gree switch entity.""" + + +def _set_light(device: Device, value: bool) -> None: + """Typed helper to set device light property.""" + device.light = value + + +def _set_quiet(device: Device, value: bool) -> None: + """Typed helper to set device quiet property.""" + device.quiet = value + + +def _set_fresh_air(device: Device, value: bool) -> None: + """Typed helper to set device fresh_air property.""" + device.fresh_air = value + + +def _set_xfan(device: Device, value: bool) -> None: + """Typed helper to set device xfan property.""" + device.xfan = value + + +def _set_anion(device: Device, value: bool) -> None: + """Typed helper to set device anion property.""" + device.anion = value + + +GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( + GreeSwitchEntityDescription( + icon="mdi:lightbulb", + name="Panel Light", + key="light", + get_value_fn=lambda d: d.light, + set_value_fn=_set_light, + ), + GreeSwitchEntityDescription( + name="Quiet", + key="quiet", + get_value_fn=lambda d: d.quiet, + set_value_fn=_set_quiet, + ), + GreeSwitchEntityDescription( + name="Fresh Air", + key="fresh_air", + get_value_fn=lambda d: d.fresh_air, + set_value_fn=_set_fresh_air, + ), + GreeSwitchEntityDescription( + name="XFan", + key="xfan", + get_value_fn=lambda d: d.xfan, + set_value_fn=_set_xfan, + ), + GreeSwitchEntityDescription( + icon="mdi:pine-tree", + name="Health mode", + key="anion", + get_value_fn=lambda d: d.anion, + set_value_fn=_set_anion, + entity_registry_enabled_default=False, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -23,13 +106,10 @@ async def async_setup_entry( @callback def init_device(coordinator): """Register the device.""" + async_add_entities( - [ - GreePanelLightSwitchEntity(coordinator), - GreeQuietModeSwitchEntity(coordinator), - GreeFreshAirSwitchEntity(coordinator), - GreeXFanSwitchEntity(coordinator), - ] + GreeSwitch(coordinator=coordinator, description=description) + for description in GREE_SWITCHES ) for coordinator in hass.data[DOMAIN][COORDINATORS]: @@ -40,126 +120,31 @@ async def async_setup_entry( ) -class GreePanelLightSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the front panel light on the device.""" +class GreeSwitch(GreeEntity, SwitchEntity): + """Generic Gree switch entity.""" - def __init__(self, coordinator): + _attr_device_class = SwitchDeviceClass.SWITCH + entity_description: GreeSwitchEntityDescription + + def __init__(self, coordinator, description: GreeSwitchEntityDescription) -> None: """Initialize the Gree device.""" - super().__init__(coordinator, "Panel Light") + self.entity_description = description - @property - def icon(self) -> str | None: - """Return the icon for the device.""" - return "mdi:lightbulb" - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH - - @property - def is_on(self) -> bool: - """Return if the light is turned on.""" - return self.coordinator.device.light - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" - self.coordinator.device.light = True - await self.coordinator.push_state_update() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - self.coordinator.device.light = False - await self.coordinator.push_state_update() - self.async_write_ha_state() - - -class GreeQuietModeSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the quiet mode state of the device.""" - - def __init__(self, coordinator): - """Initialize the Gree device.""" - super().__init__(coordinator, "Quiet") - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH + super().__init__(coordinator, cast(str, description.name)) @property def is_on(self) -> bool: """Return if the state is turned on.""" - return self.coordinator.device.quiet + return self.entity_description.get_value_fn(self.coordinator.device) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - self.coordinator.device.quiet = True + self.entity_description.set_value_fn(self.coordinator.device, True) await self.coordinator.push_state_update() self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - self.coordinator.device.quiet = False - await self.coordinator.push_state_update() - self.async_write_ha_state() - - -class GreeFreshAirSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the fresh air mode state of the device.""" - - def __init__(self, coordinator): - """Initialize the Gree device.""" - super().__init__(coordinator, "Fresh Air") - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH - - @property - def is_on(self) -> bool: - """Return if the state is turned on.""" - return self.coordinator.device.fresh_air - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" - self.coordinator.device.fresh_air = True - await self.coordinator.push_state_update() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - self.coordinator.device.fresh_air = False - await self.coordinator.push_state_update() - self.async_write_ha_state() - - -class GreeXFanSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the extra fan mode state of the device.""" - - def __init__(self, coordinator): - """Initialize the Gree device.""" - super().__init__(coordinator, "XFan") - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH - - @property - def is_on(self) -> bool: - """Return if the state is turned on.""" - return self.coordinator.device.xfan - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" - self.coordinator.device.xfan = True - await self.coordinator.push_state_update() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - self.coordinator.device.xfan = False + self.entity_description.set_value_fn(self.coordinator.device, False) await self.coordinator.push_state_update() self.async_write_ha_state() diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 069f74bf70..53a8fd0626 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -31,6 +31,7 @@ _STATISTIC_MEASURES = [ 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"), ] diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index 7e8ce92364..378a785234 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -32,18 +32,16 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def update(input_dict: dict[str, Any], update_source: dict[str, Any]) -> dict[str, Any]: - """Deep update a dictionary. - - Async friendly. - """ - for key, val in update_source.items(): +def add_defaults( + input_data: dict[str, Any], default_data: dict[str, Any] +) -> dict[str, Any]: + """Deep update a dictionary with default values.""" + for key, val in default_data.items(): if isinstance(val, Mapping): - recurse = update(input_dict.get(key, {}), val) # type: ignore[arg-type] - input_dict[key] = recurse - else: - input_dict[key] = update_source[key] - return input_dict + input_data[key] = add_defaults(input_data.get(key, {}), val) # type: ignore[arg-type] + elif key not in input_data: + input_data[key] = val + return input_data async def async_get_service( @@ -71,8 +69,8 @@ class GroupNotifyPlatform(BaseNotificationService): tasks: list[asyncio.Task[bool | None]] = [] for entity in self.entities: sending_payload = deepcopy(payload.copy()) - if (data := entity.get(ATTR_DATA)) is not None: - update(sending_payload, data) + if (default_data := entity.get(ATTR_DATA)) is not None: + add_defaults(sending_payload, default_data) tasks.append( asyncio.create_task( self.hass.services.async_call( diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 265e1640d0..4c6e8dccc1 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -54,6 +54,7 @@ ATTR_LAST = "last" ATTR_LAST_ENTITY_ID = "last_entity_id" ATTR_RANGE = "range" ATTR_SUM = "sum" +ATTR_PRODUCT = "product" SENSOR_TYPES = { ATTR_MIN_VALUE: "min", ATTR_MAX_VALUE: "max", @@ -62,6 +63,7 @@ SENSOR_TYPES = { ATTR_LAST: "last", ATTR_RANGE: "range", ATTR_SUM: "sum", + ATTR_PRODUCT: "product", } SENSOR_TYPE_TO_ATTR = {v: k for k, v in SENSOR_TYPES.items()} @@ -226,6 +228,17 @@ def calc_sum( return {}, result +def calc_product( + sensor_values: list[tuple[str, float, State]] +) -> tuple[dict[str, str | None], float]: + """Calculate a product of values.""" + result = 1.0 + for _, sensor_value, _ in sensor_values: + result *= sensor_value + + return {}, result + + CALC_TYPES: dict[ str, Callable[ @@ -239,6 +252,7 @@ CALC_TYPES: dict[ "last": calc_last, "range": calc_range, "sum": calc_sum, + "product": calc_product, } diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index e78fe982d5..9f50545468 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -169,6 +169,11 @@ "unlocked": "[%key:common::state::unlocked%]", "ok": "[%key:component::binary_sensor::entity_component::problem::state::off%]", "problem": "[%key:component::binary_sensor::entity_component::problem::state::on%]" + }, + "state_attributes": { + "entity_id": { + "name": "Members" + } } } } diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py index 04e91e4317..cb221d4941 100644 --- a/homeassistant/components/gstreamer/media_player.py +++ b/homeassistant/components/gstreamer/media_player.py @@ -166,7 +166,9 @@ class GstreamerDevice(MediaPlayerEntity): return self._album async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 77e1d0f7d3..9fac4d0192 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -610,14 +610,6 @@ class GTFSDepartureSensor(SensorEntity): self._include_tomorrow, ) - # Define the state as a UTC timestamp with ISO 8601 format - if not self._departure: - self._state = None - else: - self._state = self._departure["departure_time"].replace( - tzinfo=dt_util.UTC - ) - # Fetch trip and route details once, unless updated if not self._departure: self._trip = None @@ -648,6 +640,19 @@ class GTFSDepartureSensor(SensorEntity): ) self._agency = False + # Define the state as a UTC timestamp with ISO 8601 format + if not self._departure: + self._state = None + else: + if self._agency: + self._state = self._departure["departure_time"].replace( + tzinfo=dt_util.get_time_zone(self._agency.agency_timezone) + ) + else: + self._state = self._departure["departure_time"].replace( + tzinfo=dt_util.UTC + ) + # Assign attributes, icon and name self.update_attributes() diff --git a/homeassistant/components/hardware/models.py b/homeassistant/components/hardware/models.py index 801bc9b923..6b85229132 100644 --- a/homeassistant/components/hardware/models.py +++ b/homeassistant/components/hardware/models.py @@ -7,7 +7,7 @@ from typing import Protocol from homeassistant.core import HomeAssistant, callback -@dataclass +@dataclass(slots=True) class BoardInfo: """Board info type.""" @@ -17,7 +17,7 @@ class BoardInfo: revision: str | None -@dataclass(frozen=True) +@dataclass(slots=True, frozen=True) class USBInfo: """USB info type.""" @@ -28,7 +28,7 @@ class USBInfo: description: str | None -@dataclass(frozen=True) +@dataclass(slots=True, frozen=True) class HardwareInfo: """Hardware info type.""" diff --git a/homeassistant/components/hardware/websocket_api.py b/homeassistant/components/hardware/websocket_api.py index 66b90edc89..918c96c564 100644 --- a/homeassistant/components/hardware/websocket_api.py +++ b/homeassistant/components/hardware/websocket_api.py @@ -20,7 +20,7 @@ from .hardware import async_process_hardware_platforms from .models import HardwareProtocol -@dataclass +@dataclass(slots=True) class SystemStatus: """System status.""" diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 25482ddde9..78d974fe9c 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from contextlib import suppress -from datetime import timedelta +from datetime import datetime, timedelta import logging import os from typing import Any, NamedTuple @@ -29,6 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import ( DOMAIN as HASS_DOMAIN, + HassJob, HomeAssistant, ServiceCall, callback, @@ -72,6 +73,7 @@ from .const import ( DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, + DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, SupervisorEntityModel, ) @@ -83,9 +85,12 @@ from .handler import ( # noqa: F401 async_get_addon_discovery_info, async_get_addon_info, async_get_addon_store_info, + async_get_yellow_settings, async_install_addon, + async_reboot_host, async_restart_addon, async_set_addon_options, + async_set_yellow_settings, async_start_addon, async_stop_addon, async_uninstall_addon, @@ -126,7 +131,6 @@ DATA_SUPERVISOR_STATS = "hassio_supervisor_stats" DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs" DATA_ADDONS_INFO = "hassio_addons_info" DATA_ADDONS_STATS = "hassio_addons_stats" -DATA_SUPERVISOR_ISSUES = "supervisor_issues" HASSIO_UPDATE_INTERVAL = timedelta(minutes=5) ADDONS_COORDINATOR = "hassio_addons_coordinator" @@ -244,6 +248,11 @@ HARDWARE_INTEGRATIONS = { } +def hostname_from_addon_slug(addon_slug: str) -> str: + """Return hostname of add-on.""" + return addon_slug.replace("_", "-") + + @callback @bind_hass def get_info(hass: HomeAssistant) -> dict[str, Any] | None: @@ -487,7 +496,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: DOMAIN, service, async_service_handler, schema=settings.schema ) - async def update_info_data(now): + async def update_info_data(_: datetime | None = None) -> None: """Update last available supervisor information.""" try: @@ -511,11 +520,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: _LOGGER.warning("Can't read Supervisor data: %s", err) async_track_point_in_utc_time( - hass, update_info_data, utcnow() + HASSIO_UPDATE_INTERVAL + hass, + HassJob(update_info_data, cancel_on_shutdown=True), + utcnow() + HASSIO_UPDATE_INTERVAL, ) # Fetch data - await update_info_data(None) + await update_info_data() async def async_handle_core_service(call: ServiceCall) -> None: """Service handler for handling core services.""" @@ -606,7 +617,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) # Start listening for problems with supervisor and making issues - hass.data[DATA_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio) + hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio) await issues.setup() return True @@ -865,23 +876,25 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): self.hassio.get_os_info(), ) - addons = [ - addon - for addon in self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) - if addon[ATTR_STATE] == ATTR_STARTED + all_addons = self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) + started_addons = [ + addon for addon in all_addons if addon[ATTR_STATE] == ATTR_STARTED ] stats_data = await asyncio.gather( - *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in addons] + *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in started_addons] ) self.hass.data[DATA_ADDONS_STATS] = dict(stats_data) self.hass.data[DATA_ADDONS_CHANGELOGS] = dict( await asyncio.gather( - *[self._update_addon_changelog(addon[ATTR_SLUG]) for addon in addons] + *[ + self._update_addon_changelog(addon[ATTR_SLUG]) + for addon in all_addons + ] ) ) self.hass.data[DATA_ADDONS_INFO] = dict( await asyncio.gather( - *[self._update_addon_info(addon[ATTR_SLUG]) for addon in addons] + *[self._update_addon_info(addon[ATTR_SLUG]) for addon in all_addons] ) ) diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py index 16845e6f76..e2cd1bae27 100644 --- a/homeassistant/components/hassio/binary_sensor.py +++ b/homeassistant/components/hassio/binary_sensor.py @@ -29,7 +29,7 @@ ADDON_ENTITY_DESCRIPTIONS = ( device_class=BinarySensorDeviceClass.RUNNING, entity_registry_enabled_default=False, key=ATTR_STATE, - name="Running", + translation_key="state", target=ATTR_STARTED, ), ) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index cc9c58a3d2..1dfd5ce53c 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -16,10 +16,12 @@ ATTR_FOLDERS = "folders" ATTR_HEALTHY = "healthy" ATTR_HOMEASSISTANT = "homeassistant" ATTR_INPUT = "input" +ATTR_ISSUES = "issues" ATTR_METHOD = "method" ATTR_PANELS = "panels" ATTR_PASSWORD = "password" ATTR_RESULT = "result" +ATTR_SUGGESTIONS = "suggestions" ATTR_SUPPORTED = "supported" ATTR_TIMEOUT = "timeout" ATTR_TITLE = "title" @@ -49,6 +51,8 @@ EVENT_SUPERVISOR_EVENT = "supervisor_event" EVENT_SUPERVISOR_UPDATE = "supervisor_update" EVENT_HEALTH_CHANGED = "health_changed" EVENT_SUPPORTED_CHANGED = "supported_changed" +EVENT_ISSUE_CHANGED = "issue_changed" +EVENT_ISSUE_REMOVED = "issue_removed" UPDATE_KEY_SUPERVISOR = "supervisor" @@ -69,6 +73,9 @@ DATA_KEY_OS = "os" DATA_KEY_SUPERVISOR = "supervisor" DATA_KEY_CORE = "core" DATA_KEY_HOST = "host" +DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues" + +PLACEHOLDER_KEY_REFERENCE = "reference" class SupervisorEntityModel(str, Enum): diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 29cb53de70..2a5ce2485d 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -22,13 +22,14 @@ from .handler import HassIO, HassioAPIError _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(slots=True) class HassioServiceInfo(BaseServiceInfo): """Prepared info from hassio entries.""" config: dict[str, Any] name: str slug: str + uuid: str @callback @@ -93,6 +94,7 @@ class HassIODiscovery(HomeAssistantView): service: str = data[ATTR_SERVICE] config_data: dict[str, Any] = data[ATTR_CONFIG] slug: str = data[ATTR_ADDON] + uuid: str = data[ATTR_UUID] # Read additional Add-on info try: @@ -109,7 +111,7 @@ class HassIODiscovery(HomeAssistantView): self.hass, service, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=config_data, name=name, slug=slug), + data=HassioServiceInfo(config=config_data, name=name, slug=slug, uuid=uuid), ) async def async_process_del(self, data): @@ -128,6 +130,6 @@ class HassIODiscovery(HomeAssistantView): # Use config flow for entry in self.hass.config_entries.async_entries(service): - if entry.source != config_entries.SOURCE_HASSIO: + if entry.source != config_entries.SOURCE_HASSIO or entry.unique_id != uuid: continue await self.hass.config_entries.async_remove(entry.entry_id) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index d7af26851d..e4a0dd0f77 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -5,6 +5,7 @@ import asyncio from http import HTTPStatus import logging import os +from typing import Any import aiohttp @@ -249,6 +250,49 @@ async def async_update_core( ) +@bind_hass +@_api_bool +async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> bool: + """Apply a suggestion from supervisor's resolution center. + + The caller of the function should handle HassioAPIError. + """ + hassio = hass.data[DOMAIN] + command = f"/resolution/suggestion/{suggestion_uuid}" + return await hassio.send_command(command, timeout=None) + + +@api_data +async def async_get_yellow_settings(hass: HomeAssistant) -> dict[str, bool]: + """Return settings specific to Home Assistant Yellow.""" + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command("/os/boards/yellow", method="get") + + +@api_data +async def async_set_yellow_settings( + hass: HomeAssistant, settings: dict[str, bool] +) -> dict: + """Set settings specific to Home Assistant Yellow. + + Returns an empty dict. + """ + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command( + "/os/boards/yellow", method="post", payload=settings + ) + + +@api_data +async def async_reboot_host(hass: HomeAssistant) -> dict: + """Reboot the host. + + Returns an empty dict. + """ + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command("/host/reboot", method="post", timeout=60) + + class HassIO: """Small API wrapper for Hass.io.""" @@ -416,6 +460,16 @@ class HassIO: """ return self.send_command("/resolution/info", method="get") + @api_data + def get_suggestions_for_issue(self, issue_id: str) -> dict[str, Any]: + """Return suggestions for issue from Supervisor resolution center. + + This method returns a coroutine. + """ + return self.send_command( + f"/resolution/issue/{issue_id}/suggestions", method="get" + ) + @_api_bool async def update_hass_api(self, http_config, refresh_token): """Update Home Assistant API data on Hass.io.""" @@ -454,6 +508,14 @@ class HassIO: "/supervisor/options", payload={"diagnostics": diagnostics} ) + @_api_bool + def apply_suggestion(self, suggestion_uuid: str): + """Apply a suggestion from supervisor's resolution center. + + This method returns a coroutine. + """ + return self.send_command(f"/resolution/suggestion/{suggestion_uuid}") + async def send_command( self, command, diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index fecf05f74b..2480353c2d 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -35,7 +35,6 @@ _LOGGER = logging.getLogger(__name__) MAX_UPLOAD_SIZE = 1024 * 1024 * 1024 -# pylint: disable=implicit-str-concat NO_TIMEOUT = re.compile( r"^(?:" r"|backups/.+/full" diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index a0d51c4806..ac6af7f348 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -1,7 +1,12 @@ """Supervisor events monitor.""" from __future__ import annotations -from typing import Any +import asyncio +from dataclasses import dataclass, field +import logging +from typing import Any, TypedDict + +from typing_extensions import NotRequired from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -14,6 +19,8 @@ from homeassistant.helpers.issue_registry import ( from .const import ( ATTR_DATA, ATTR_HEALTHY, + ATTR_ISSUES, + ATTR_SUGGESTIONS, ATTR_SUPPORTED, ATTR_UNHEALTHY, ATTR_UNHEALTHY_REASONS, @@ -23,19 +30,26 @@ from .const import ( ATTR_WS_EVENT, DOMAIN, EVENT_HEALTH_CHANGED, + EVENT_ISSUE_CHANGED, + EVENT_ISSUE_REMOVED, EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, EVENT_SUPPORTED_CHANGED, + PLACEHOLDER_KEY_REFERENCE, UPDATE_KEY_SUPERVISOR, ) -from .handler import HassIO +from .handler import HassIO, HassioAPIError +ISSUE_KEY_UNHEALTHY = "unhealthy" +ISSUE_KEY_UNSUPPORTED = "unsupported" ISSUE_ID_UNHEALTHY = "unhealthy_system" ISSUE_ID_UNSUPPORTED = "unsupported_system" INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy" INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported" +PLACEHOLDER_KEY_REASON = "reason" + UNSUPPORTED_REASONS = { "apparmor", "connectivity_check", @@ -69,6 +83,88 @@ UNHEALTHY_REASONS = { "untrusted", } +# Keys (type + context) of issues that when found should be made into a repair +ISSUE_KEYS_FOR_REPAIRS = { + "issue_system_multiple_data_disks", + "issue_system_reboot_required", +} + +_LOGGER = logging.getLogger(__name__) + + +class SuggestionDataType(TypedDict): + """Suggestion dictionary as received from supervisor.""" + + uuid: str + type: str + context: str + reference: str | None + + +@dataclass(slots=True, frozen=True) +class Suggestion: + """Suggestion from Supervisor which resolves an issue.""" + + uuid: str + type_: str + context: str + reference: str | None = None + + @property + def key(self) -> str: + """Get key for suggestion (combination of context and type).""" + return f"{self.context}_{self.type_}" + + @classmethod + def from_dict(cls, data: SuggestionDataType) -> Suggestion: + """Convert from dictionary representation.""" + return cls( + uuid=data["uuid"], + type_=data["type"], + context=data["context"], + reference=data["reference"], + ) + + +class IssueDataType(TypedDict): + """Issue dictionary as received from supervisor.""" + + uuid: str + type: str + context: str + reference: str | None + suggestions: NotRequired[list[SuggestionDataType]] + + +@dataclass(slots=True, frozen=True) +class Issue: + """Issue from Supervisor.""" + + uuid: str + type_: str + context: str + reference: str | None = None + suggestions: list[Suggestion] = field(default_factory=list, compare=False) + + @property + def key(self) -> str: + """Get key for issue (combination of context and type).""" + return f"issue_{self.context}_{self.type_}" + + @classmethod + def from_dict(cls, data: IssueDataType) -> Issue: + """Convert from dictionary representation.""" + suggestions: list[SuggestionDataType] = data.get("suggestions", []) + return cls( + uuid=data["uuid"], + type_=data["type"], + context=data["context"], + reference=data["reference"], + suggestions=[ + Suggestion.from_dict(suggestion) for suggestion in suggestions + ], + ) + class SupervisorIssues: """Create issues from supervisor events.""" @@ -79,6 +175,7 @@ class SupervisorIssues: self._client = client self._unsupported_reasons: set[str] = set() self._unhealthy_reasons: set[str] = set() + self._issues: dict[str, Issue] = {} @property def unhealthy_reasons(self) -> set[str]: @@ -87,14 +184,14 @@ class SupervisorIssues: @unhealthy_reasons.setter def unhealthy_reasons(self, reasons: set[str]) -> None: - """Set unhealthy reasons. Create or delete issues as necessary.""" + """Set unhealthy reasons. Create or delete repairs as necessary.""" for unhealthy in reasons - self.unhealthy_reasons: if unhealthy in UNHEALTHY_REASONS: - translation_key = f"unhealthy_{unhealthy}" + translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}" translation_placeholders = None else: - translation_key = "unhealthy" - translation_placeholders = {"reason": unhealthy} + translation_key = ISSUE_KEY_UNHEALTHY + translation_placeholders = {PLACEHOLDER_KEY_REASON: unhealthy} async_create_issue( self._hass, @@ -119,14 +216,14 @@ class SupervisorIssues: @unsupported_reasons.setter def unsupported_reasons(self, reasons: set[str]) -> None: - """Set unsupported reasons. Create or delete issues as necessary.""" + """Set unsupported reasons. Create or delete repairs as necessary.""" for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons: if unsupported in UNSUPPORTED_REASONS: - translation_key = f"unsupported_{unsupported}" + translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}" translation_placeholders = None else: - translation_key = "unsupported" - translation_placeholders = {"reason": unsupported} + translation_key = ISSUE_KEY_UNSUPPORTED + translation_placeholders = {PLACEHOLDER_KEY_REASON: unsupported} async_create_issue( self._hass, @@ -144,6 +241,61 @@ class SupervisorIssues: self._unsupported_reasons = reasons + def add_issue(self, issue: Issue) -> None: + """Add or update an issue in the list. Create or update a repair if necessary.""" + if issue.key in ISSUE_KEYS_FOR_REPAIRS: + placeholders: dict[str, str] | None = None + if issue.reference: + placeholders = {PLACEHOLDER_KEY_REFERENCE: issue.reference} + async_create_issue( + self._hass, + DOMAIN, + issue.uuid, + is_fixable=bool(issue.suggestions), + severity=IssueSeverity.WARNING, + translation_key=issue.key, + translation_placeholders=placeholders, + ) + + self._issues[issue.uuid] = issue + + async def add_issue_from_data(self, data: IssueDataType) -> None: + """Add issue from data to list after getting latest suggestions.""" + try: + suggestions = (await self._client.get_suggestions_for_issue(data["uuid"]))[ + ATTR_SUGGESTIONS + ] + self.add_issue( + Issue( + uuid=data["uuid"], + type_=data["type"], + context=data["context"], + reference=data["reference"], + suggestions=[ + Suggestion.from_dict(suggestion) for suggestion in suggestions + ], + ) + ) + except HassioAPIError: + _LOGGER.error( + "Could not get suggestions for supervisor issue %s, skipping it", + data["uuid"], + ) + + def remove_issue(self, issue: Issue) -> None: + """Remove an issue from the list. Delete a repair if necessary.""" + if issue.uuid not in self._issues: + return + + if issue.key in ISSUE_KEYS_FOR_REPAIRS: + async_delete_issue(self._hass, DOMAIN, issue.uuid) + + del self._issues[issue.uuid] + + def get_issue(self, issue_id: str) -> Issue | None: + """Get issue from key.""" + return self._issues.get(issue_id) + async def setup(self) -> None: """Create supervisor events listener.""" await self.update() @@ -153,11 +305,22 @@ class SupervisorIssues: ) async def update(self) -> None: - """Update issuess from Supervisor resolution center.""" + """Update issues from Supervisor resolution center.""" data = await self._client.get_resolution_info() self.unhealthy_reasons = set(data[ATTR_UNHEALTHY]) self.unsupported_reasons = set(data[ATTR_UNSUPPORTED]) + # Remove any cached issues that weren't returned + for issue_id in set(self._issues.keys()) - { + issue["uuid"] for issue in data[ATTR_ISSUES] + }: + self.remove_issue(self._issues[issue_id]) + + # Add/update any issues that came back + await asyncio.gather( + *[self.add_issue_from_data(issue) for issue in data[ATTR_ISSUES]] + ) + @callback def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None: """Create issues from supervisor events.""" @@ -183,3 +346,9 @@ class SupervisorIssues: if event[ATTR_DATA][ATTR_SUPPORTED] else set(event[ATTR_DATA][ATTR_UNSUPPORTED_REASONS]) ) + + elif event[ATTR_WS_EVENT] == EVENT_ISSUE_CHANGED: + self.add_issue(Issue.from_dict(event[ATTR_DATA])) + + elif event[ATTR_WS_EVENT] == EVENT_ISSUE_REMOVED: + self.remove_issue(Issue.from_dict(event[ATTR_DATA])) diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py new file mode 100644 index 0000000000..50a9b087a7 --- /dev/null +++ b/homeassistant/components/hassio/repairs.py @@ -0,0 +1,122 @@ +"""Repairs implementation for supervisor integration.""" + +from collections.abc import Callable +from types import MethodType +from typing import Any + +import voluptuous as vol + +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult + +from .const import DATA_KEY_SUPERVISOR_ISSUES, PLACEHOLDER_KEY_REFERENCE +from .handler import HassioAPIError, async_apply_suggestion +from .issues import Issue, Suggestion, SupervisorIssues + +SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"} + + +class SupervisorIssueRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + _data: dict[str, Any] | None = None + _issue: Issue | None = None + + def __init__(self, issue_id: str) -> None: + """Initialize repair flow.""" + self._issue_id = issue_id + super().__init__() + + @property + def issue(self) -> Issue | None: + """Get associated issue.""" + if not self._issue: + supervisor_issues: SupervisorIssues = self.hass.data[ + DATA_KEY_SUPERVISOR_ISSUES + ] + self._issue = supervisor_issues.get_issue(self._issue_id) + + return self._issue + + @property + def description_placeholders(self) -> dict[str, str] | None: + """Get description placeholders for steps.""" + return ( + {PLACEHOLDER_KEY_REFERENCE: self.issue.reference} + if self.issue and self.issue.reference + else None + ) + + def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult: + """Return form for suggestion.""" + return self.async_show_form( + step_id=suggestion.key, + data_schema=vol.Schema({}), + description_placeholders=self.description_placeholders, + last_step=True, + ) + + async def async_step_init(self, _: None = None) -> FlowResult: + """Handle the first step of a fix flow.""" + # Out of sync with supervisor, issue is resolved or not fixable. Remove it + if not self.issue or not self.issue.suggestions: + return self.async_create_entry(data={}) + + # All suggestions have the same logic: Apply them in supervisor, + # optionally with a confirmation step. Generating the required handler for each + # allows for shared logic but screens can still be translated per step id. + for suggestion in self.issue.suggestions: + setattr( + self, + f"async_step_{suggestion.key}", + MethodType(self._async_step(suggestion), self), + ) + + if len(self.issue.suggestions) > 1: + return self.async_show_menu( + step_id="fix_menu", + menu_options=[suggestion.key for suggestion in self.issue.suggestions], + description_placeholders=self.description_placeholders, + ) + + # Always show a form for one suggestion to explain to user what's happening + return self._async_form_for_suggestion(self.issue.suggestions[0]) + + async def _async_step_apply_suggestion( + self, suggestion: Suggestion, confirmed: bool = False + ) -> FlowResult: + """Handle applying a suggestion as a flow step. Optionally request confirmation.""" + if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED: + return self._async_form_for_suggestion(suggestion) + + try: + await async_apply_suggestion(self.hass, suggestion.uuid) + except HassioAPIError: + return self.async_abort(reason="apply_suggestion_fail") + + return self.async_create_entry(data={}) + + @staticmethod + def _async_step(suggestion: Suggestion) -> Callable: + """Generate a step handler for a suggestion.""" + + async def _async_step( + self: SupervisorIssueRepairFlow, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle a flow step for a suggestion.""" + # pylint: disable-next=protected-access + return await self._async_step_apply_suggestion( + suggestion, confirmed=user_input is not None + ) + + return _async_step + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + return SupervisorIssueRepairFlow(issue_id) diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py index b9a97adcbc..b49433961e 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -36,12 +36,12 @@ COMMON_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( entity_registry_enabled_default=False, key=ATTR_VERSION, - name="Version", + translation_key="version", ), SensorEntityDescription( entity_registry_enabled_default=False, key=ATTR_VERSION_LATEST, - name="Newest version", + translation_key="version_latest", ), ) @@ -49,7 +49,7 @@ STATS_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( entity_registry_enabled_default=False, key=ATTR_CPU_PERCENT, - name="CPU percent", + translation_key="cpu_percent", icon="mdi:cpu-64-bit", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -57,7 +57,7 @@ STATS_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( entity_registry_enabled_default=False, key=ATTR_MEMORY_PERCENT, - name="Memory percent", + translation_key="memory_percent", icon="mdi:memory", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -73,19 +73,19 @@ HOST_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( entity_registry_enabled_default=False, key="agent_version", - name="OS Agent version", + translation_key="agent_version", entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( entity_registry_enabled_default=False, key="apparmor_version", - name="Apparmor version", + translation_key="apparmor_version", entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( entity_registry_enabled_default=False, key="disk_total", - name="Disk total", + translation_key="disk_total", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, @@ -93,7 +93,7 @@ HOST_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( entity_registry_enabled_default=False, key="disk_used", - name="Disk used", + translation_key="disk_used", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, @@ -101,7 +101,7 @@ HOST_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( entity_registry_enabled_default=False, key="disk_free", - name="Disk free", + translation_key="disk_free", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 7cda053f43..078aac39a5 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -17,6 +17,32 @@ } }, "issues": { + "issue_system_multiple_data_disks": { + "title": "Multiple data disks detected", + "fix_flow": { + "step": { + "system_rename_data_disk": { + "description": "'{reference}' is a filesystem with the name 'hassos-data' and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the fix option to rename the filesystem to prevent this. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system." + } + }, + "abort": { + "apply_suggestion_fail": "Could not rename the filesystem. Check the supervisor logs for more details." + } + } + }, + "issue_system_reboot_required": { + "title": "Reboot required", + "fix_flow": { + "step": { + "system_execute_reboot": { + "description": "Settings were changed which require a system reboot to take effect.\n\nThis fix will initiate a system reboot which will make Home Assistant and all the Add-ons inaccessible for a brief period." + } + }, + "abort": { + "apply_suggestion_fail": "Could not reboot the system. Check the supervisor logs for more details." + } + } + }, "unhealthy": { "title": "Unhealthy system - {reason}", "description": "System is currently unhealthy due to {reason}. Use the link to learn more and how to fix this." @@ -125,5 +151,21 @@ "title": "Unsupported system - Systemd-Resolved issues", "description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. Use the link to learn more and how to fix this." } + }, + "entity": { + "binary_sensor": { + "state": { "name": "Running" } + }, + "sensor": { + "agent_version": { "name": "OS Agent version" }, + "apparmor_version": { "name": "Apparmor version" }, + "cpu_percent": { "name": "CPU percent" }, + "disk_free": { "name": "Disk free" }, + "disk_total": { "name": "Disk total" }, + "disk_used": { "name": "Disk used" }, + "memory_percent": { "name": "Memory percent" }, + "version": { "name": "Version" }, + "version_latest": { "name": "Newest version" } + } } } diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 3147c1e166..9ad33caf07 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -427,7 +427,9 @@ class HeosMediaPlayer(MediaPlayerEntity): return self._player.volume / 100 async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 36f2f8945c..71200b93f3 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -3,8 +3,6 @@ from __future__ import annotations from datetime import datetime as dt, timedelta from http import HTTPStatus -import logging -import time from typing import cast from aiohttp import web @@ -12,68 +10,40 @@ import voluptuous as vol from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView -from homeassistant.components.recorder import ( - DOMAIN as RECORDER_DOMAIN, - get_instance, - history, -) -from homeassistant.components.recorder.filters import ( - Filters, - extract_include_exclude_filter_conf, - merge_include_exclude_filters, - sqlalchemy_filter_from_include_exclude_conf, -) +from homeassistant.components.recorder import get_instance, history from homeassistant.components.recorder.util import session_scope -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, valid_entity_id import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entityfilter import ( - INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, - convert_include_exclude_filter, -) +from homeassistant.helpers.entityfilter import INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from . import websocket_api from .const import DOMAIN from .helpers import entities_may_have_state_changes_after -from .models import HistoryConfig - -_LOGGER = logging.getLogger(__name__) CONF_ORDER = "use_include_order" +_ONE_DAY = timedelta(days=1) + CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.deprecated(CONF_ORDER), - INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend( - {vol.Optional(CONF_ORDER, default=False): cv.boolean} - ), - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.All( + INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend( + {vol.Optional(CONF_ORDER, default=False): cv.boolean} + ), + ) + }, + ), extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the history hooks.""" - conf = config.get(DOMAIN, {}) - recorder_conf = config.get(RECORDER_DOMAIN, {}) - history_conf = config.get(DOMAIN, {}) - recorder_filter = extract_include_exclude_filter_conf(recorder_conf) - logbook_filter = extract_include_exclude_filter_conf(history_conf) - merged_filter = merge_include_exclude_filters(recorder_filter, logbook_filter) - - possible_merged_entities_filter = convert_include_exclude_filter(merged_filter) - - sqlalchemy_filter = None - entity_filter = None - if not possible_merged_entities_filter.empty_filter: - sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(conf) - entity_filter = possible_merged_entities_filter - - hass.data[DOMAIN] = HistoryConfig(sqlalchemy_filter, entity_filter) - hass.http.register_view(HistoryPeriodView(sqlalchemy_filter)) + hass.http.register_view(HistoryPeriodView()) frontend.async_register_built_in_panel(hass, "history", "history", "hass:chart-box") websocket_api.async_setup(hass) return True @@ -86,50 +56,54 @@ class HistoryPeriodView(HomeAssistantView): name = "api:history:view-period" extra_urls = ["/api/history/period/{datetime}"] - def __init__(self, filters: Filters | None) -> None: - """Initialize the history period view.""" - self.filters = filters - async def get( self, request: web.Request, datetime: str | None = None ) -> web.Response: """Return history over a period of time.""" datetime_ = None + query = request.query + if datetime and (datetime_ := dt_util.parse_datetime(datetime)) is None: return self.json_message("Invalid datetime", HTTPStatus.BAD_REQUEST) - now = dt_util.utcnow() + if not (entity_ids_str := query.get("filter_entity_id")) or not ( + entity_ids := entity_ids_str.strip().lower().split(",") + ): + return self.json_message( + "filter_entity_id is missing", HTTPStatus.BAD_REQUEST + ) - one_day = timedelta(days=1) + hass = request.app["hass"] + + for entity_id in entity_ids: + if not hass.states.get(entity_id) and not valid_entity_id(entity_id): + return self.json_message( + "Invalid filter_entity_id", HTTPStatus.BAD_REQUEST + ) + + now = dt_util.utcnow() if datetime_: start_time = dt_util.as_utc(datetime_) else: - start_time = now - one_day + start_time = now - _ONE_DAY if start_time > now: return self.json([]) - if end_time_str := request.query.get("end_time"): + if end_time_str := query.get("end_time"): if end_time := dt_util.parse_datetime(end_time_str): end_time = dt_util.as_utc(end_time) else: return self.json_message("Invalid end_time", HTTPStatus.BAD_REQUEST) else: - end_time = start_time + one_day - entity_ids_str = request.query.get("filter_entity_id") - entity_ids = None - if entity_ids_str: - entity_ids = entity_ids_str.lower().split(",") - include_start_time_state = "skip_initial_state" not in request.query - significant_changes_only = ( - request.query.get("significant_changes_only", "1") != "0" - ) + end_time = start_time + _ONE_DAY + + include_start_time_state = "skip_initial_state" not in query + significant_changes_only = query.get("significant_changes_only", "1") != "0" minimal_response = "minimal_response" in request.query no_attributes = "no_attributes" in request.query - hass = request.app["hass"] - if ( not include_start_time_state and entity_ids @@ -159,33 +133,27 @@ class HistoryPeriodView(HomeAssistantView): hass: HomeAssistant, start_time: dt, end_time: dt, - entity_ids: list[str] | None, + entity_ids: list[str], include_start_time_state: bool, significant_changes_only: bool, minimal_response: bool, no_attributes: bool, ) -> web.Response: """Fetch significant stats from the database as json.""" - timer_start = time.perf_counter() - with session_scope(hass=hass, read_only=True) as session: - states = history.get_significant_states_with_session( - hass, - session, - start_time, - end_time, - entity_ids, - self.filters, - include_start_time_state, - significant_changes_only, - minimal_response, - no_attributes, + return self.json( + list( + history.get_significant_states_with_session( + hass, + session, + start_time, + end_time, + entity_ids, + None, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + ).values() + ) ) - - if _LOGGER.isEnabledFor(logging.DEBUG): - elapsed = time.perf_counter() - timer_start - _LOGGER.debug( - "Extracted %d states in %fs", sum(map(len, states.values())), elapsed - ) - - return self.json(list(states.values())) diff --git a/homeassistant/components/history/models.py b/homeassistant/components/history/models.py deleted file mode 100644 index 3998d9f7e0..0000000000 --- a/homeassistant/components/history/models.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Models for the history integration.""" -from __future__ import annotations - -from dataclasses import dataclass - -from homeassistant.components.recorder.filters import Filters -from homeassistant.helpers.entityfilter import EntityFilter - - -@dataclass -class HistoryConfig: - """Configuration for the history integration.""" - - sqlalchemy_filter: Filters | None = None - entity_filter: EntityFilter | None = None diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index a761021de5..93a5d27296 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -12,7 +12,6 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.recorder import get_instance, history -from homeassistant.components.recorder.filters import Filters from homeassistant.components.websocket_api import messages from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.const import ( @@ -20,7 +19,6 @@ from homeassistant.const import ( COMPRESSED_STATE_LAST_CHANGED, COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE, - EVENT_STATE_CHANGED, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -29,8 +27,8 @@ from homeassistant.core import ( State, callback, is_callback, + valid_entity_id, ) -from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import ( async_track_point_in_utc_time, async_track_state_change_event, @@ -38,14 +36,13 @@ from homeassistant.helpers.event import ( from homeassistant.helpers.json import JSON_DUMP import homeassistant.util.dt as dt_util -from .const import DOMAIN, EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES +from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES from .helpers import entities_may_have_state_changes_after -from .models import HistoryConfig _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(slots=True) class HistoryLiveStream: """Track a history live stream.""" @@ -69,7 +66,6 @@ def _ws_get_significant_states( start_time: dt, end_time: dt | None, entity_ids: list[str] | None, - filters: Filters | None, include_start_time_state: bool, significant_changes_only: bool, minimal_response: bool, @@ -84,7 +80,7 @@ def _ws_get_significant_states( start_time, end_time, entity_ids, - filters, + None, include_start_time_state, significant_changes_only, minimal_response, @@ -100,7 +96,7 @@ def _ws_get_significant_states( vol.Required("type"): "history/history_during_period", vol.Required("start_time"): str, vol.Optional("end_time"): str, - vol.Optional("entity_ids"): [str], + vol.Required("entity_ids"): [str], vol.Optional("include_start_time_state", default=True): bool, vol.Optional("significant_changes_only", default=True): bool, vol.Optional("minimal_response", default=False): bool, @@ -134,7 +130,12 @@ async def ws_get_history_during_period( connection.send_result(msg["id"], {}) return - entity_ids = msg.get("entity_ids") + entity_ids: list[str] = msg["entity_ids"] + for entity_id in entity_ids: + if not hass.states.get(entity_id) and not valid_entity_id(entity_id): + connection.send_error(msg["id"], "invalid_entity_ids", "Invalid entity_ids") + return + include_start_time_state = msg["include_start_time_state"] no_attributes = msg["no_attributes"] @@ -150,7 +151,6 @@ async def ws_get_history_during_period( significant_changes_only = msg["significant_changes_only"] minimal_response = msg["minimal_response"] - history_config: HistoryConfig = hass.data[DOMAIN] connection.send_message( await get_instance(hass).async_add_executor_job( @@ -160,7 +160,6 @@ async def ws_get_history_during_period( start_time, end_time, entity_ids, - history_config.sqlalchemy_filter, include_start_time_state, significant_changes_only, minimal_response, @@ -214,7 +213,6 @@ def _generate_historical_response( start_time: dt, end_time: dt, entity_ids: list[str] | None, - filters: Filters | None, include_start_time_state: bool, significant_changes_only: bool, minimal_response: bool, @@ -229,7 +227,7 @@ def _generate_historical_response( start_time, end_time, entity_ids, - filters, + None, include_start_time_state, significant_changes_only, minimal_response, @@ -270,7 +268,6 @@ async def _async_send_historical_states( start_time: dt, end_time: dt, entity_ids: list[str] | None, - filters: Filters | None, include_start_time_state: bool, significant_changes_only: bool, minimal_response: bool, @@ -286,7 +283,6 @@ async def _async_send_historical_states( start_time, end_time, entity_ids, - filters, include_start_time_state, significant_changes_only, minimal_response, @@ -365,8 +361,7 @@ def _async_subscribe_events( hass: HomeAssistant, subscriptions: list[CALLBACK_TYPE], target: Callable[[Event], None], - entities_filter: EntityFilter | None, - entity_ids: list[str] | None, + entity_ids: list[str], significant_changes_only: bool, minimal_response: bool, ) -> None: @@ -386,7 +381,7 @@ def _async_subscribe_events( return assert isinstance(new_state, State) assert isinstance(old_state, State) - if (entities_filter and not entities_filter(new_state.entity_id)) or ( + if ( (significant_changes_only or minimal_response) and new_state.state == old_state.state and new_state.domain not in history.SIGNIFICANT_DOMAINS @@ -394,21 +389,8 @@ def _async_subscribe_events( return target(event) - if entity_ids: - subscriptions.append( - async_track_state_change_event( - hass, entity_ids, _forward_state_events_filtered - ) - ) - return - - # We want the firehose subscriptions.append( - hass.bus.async_listen( - EVENT_STATE_CHANGED, - _forward_state_events_filtered, - run_immediately=True, - ) + async_track_state_change_event(hass, entity_ids, _forward_state_events_filtered) ) @@ -417,7 +399,7 @@ def _async_subscribe_events( vol.Required("type"): "history/stream", vol.Required("start_time"): str, vol.Optional("end_time"): str, - vol.Optional("entity_ids"): [str], + vol.Required("entity_ids"): [str], vol.Optional("include_start_time_state", default=True): bool, vol.Optional("significant_changes_only", default=True): bool, vol.Optional("minimal_response", default=False): bool, @@ -431,15 +413,7 @@ async def ws_stream( """Handle history stream websocket command.""" start_time_str = msg["start_time"] msg_id: int = msg["id"] - entity_ids: list[str] | None = msg.get("entity_ids") utc_now = dt_util.utcnow() - filters: Filters | None = None - entities_filter: EntityFilter | None = None - - if not entity_ids: - history_config: HistoryConfig = hass.data[DOMAIN] - filters = history_config.sqlalchemy_filter - entities_filter = history_config.entity_filter if start_time := dt_util.parse_datetime(start_time_str): start_time = dt_util.as_utc(start_time) @@ -459,7 +433,12 @@ async def ws_stream( connection.send_error(msg_id, "invalid_end_time", "Invalid end_time") return - entity_ids = msg.get("entity_ids") + entity_ids: list[str] = msg["entity_ids"] + for entity_id in entity_ids: + if not hass.states.get(entity_id) and not valid_entity_id(entity_id): + connection.send_error(msg["id"], "invalid_entity_ids", "Invalid entity_ids") + return + include_start_time_state = msg["include_start_time_state"] significant_changes_only = msg["significant_changes_only"] no_attributes = msg["no_attributes"] @@ -485,7 +464,6 @@ async def ws_stream( start_time, end_time, entity_ids, - filters, include_start_time_state, significant_changes_only, minimal_response, @@ -535,7 +513,6 @@ async def ws_stream( hass, subscriptions, _queue_or_cancel, - entities_filter, entity_ids, significant_changes_only=significant_changes_only, minimal_response=minimal_response, @@ -551,7 +528,6 @@ async def ws_stream( start_time, subscriptions_setup_complete_time, entity_ids, - filters, include_start_time_state, significant_changes_only, minimal_response, @@ -593,7 +569,6 @@ async def ws_stream( last_event_time or start_time, subscriptions_setup_complete_time, entity_ids, - filters, False, # We don't want the start time state again significant_changes_only, minimal_response, diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index d9b331d82b..af27766f51 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -78,7 +78,7 @@ class HistoryStats: utc_now = dt_util.utcnow() now_timestamp = floored_timestamp(utc_now) - if current_period_start > utc_now: + if current_period_start_timestamp > now_timestamp: # History cannot tell the future self._history_current_period = [] self._previous_run_before_start = True @@ -122,7 +122,9 @@ class HistoryStats: # Don't compute anything as the value cannot have changed return self._state else: - await self._async_history_from_db(current_period_start, current_period_end) + await self._async_history_from_db( + current_period_start_timestamp, current_period_end_timestamp + ) self._previous_run_before_start = False seconds_matched, match_count = self._async_compute_seconds_and_changes( @@ -135,15 +137,15 @@ class HistoryStats: async def _async_history_from_db( self, - current_period_start: datetime.datetime, - current_period_end: datetime.datetime, + current_period_start_timestamp: float, + current_period_end_timestamp: float, ) -> None: """Update history data for the current period from the database.""" instance = get_instance(self.hass) states = await instance.async_add_executor_job( self._state_changes_during_period, - current_period_start, - current_period_end, + current_period_start_timestamp, + current_period_end_timestamp, ) self._history_current_period = [ HistoryState(state.state, state.last_changed.timestamp()) @@ -151,8 +153,11 @@ class HistoryStats: ] def _state_changes_during_period( - self, start: datetime.datetime, end: datetime.datetime + self, start_ts: float, end_ts: float ) -> list[State]: + """Return state changes during a period.""" + start = dt_util.utc_from_timestamp(start_ts) + end = dt_util.utc_from_timestamp(end_ts) return history.state_changes_during_period( self.hass, start, diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index f50ab71155..d0464968d4 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -145,11 +145,14 @@ class HomeConnectDevice: class DeviceWithPrograms(HomeConnectDevice): """Device with programs.""" - PROGRAMS: list[dict[str, str]] = [] - def get_programs_available(self): """Get the available programs.""" - return self.PROGRAMS + try: + programs_available = self.appliance.get_programs_available() + except (HomeConnectError, ValueError): + _LOGGER.debug("Unable to fetch available programs. Probably offline") + programs_available = [] + return programs_available def get_program_switches(self): """Get a dictionary with info about program switches. @@ -157,7 +160,7 @@ class DeviceWithPrograms(HomeConnectDevice): There will be one switch for each program. """ programs = self.get_programs_available() - return [{ATTR_DEVICE: self, "program_name": p["name"]} for p in programs] + return [{ATTR_DEVICE: self, "program_name": p} for p in programs] def get_program_sensors(self): """Get a dictionary with info about program sensors. @@ -265,27 +268,6 @@ class Dryer( ): """Dryer class.""" - PROGRAMS = [ - {"name": "LaundryCare.Dryer.Program.Cotton"}, - {"name": "LaundryCare.Dryer.Program.Synthetic"}, - {"name": "LaundryCare.Dryer.Program.Mix"}, - {"name": "LaundryCare.Dryer.Program.Blankets"}, - {"name": "LaundryCare.Dryer.Program.BusinessShirts"}, - {"name": "LaundryCare.Dryer.Program.DownFeathers"}, - {"name": "LaundryCare.Dryer.Program.Hygiene"}, - {"name": "LaundryCare.Dryer.Program.Jeans"}, - {"name": "LaundryCare.Dryer.Program.Outdoor"}, - {"name": "LaundryCare.Dryer.Program.SyntheticRefresh"}, - {"name": "LaundryCare.Dryer.Program.Towels"}, - {"name": "LaundryCare.Dryer.Program.Delicates"}, - {"name": "LaundryCare.Dryer.Program.Super40"}, - {"name": "LaundryCare.Dryer.Program.Shirts15"}, - {"name": "LaundryCare.Dryer.Program.Pillow"}, - {"name": "LaundryCare.Dryer.Program.AntiShrink"}, - {"name": "LaundryCare.Dryer.Program.TimeCold"}, - {"name": "LaundryCare.Dryer.Program.TimeWarm"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() @@ -311,32 +293,6 @@ class Dishwasher( ): """Dishwasher class.""" - PROGRAMS = [ - {"name": "Dishcare.Dishwasher.Program.Auto1"}, - {"name": "Dishcare.Dishwasher.Program.Auto2"}, - {"name": "Dishcare.Dishwasher.Program.Auto3"}, - {"name": "Dishcare.Dishwasher.Program.Eco50"}, - {"name": "Dishcare.Dishwasher.Program.Quick45"}, - {"name": "Dishcare.Dishwasher.Program.Intensiv70"}, - {"name": "Dishcare.Dishwasher.Program.Normal65"}, - {"name": "Dishcare.Dishwasher.Program.Glas40"}, - {"name": "Dishcare.Dishwasher.Program.GlassCare"}, - {"name": "Dishcare.Dishwasher.Program.PreRinse"}, - {"name": "Dishcare.Dishwasher.Program.NightWash"}, - {"name": "Dishcare.Dishwasher.Program.Quick65"}, - {"name": "Dishcare.Dishwasher.Program.Normal45"}, - {"name": "Dishcare.Dishwasher.Program.Intensiv45"}, - {"name": "Dishcare.Dishwasher.Program.AutoHalfLoad"}, - {"name": "Dishcare.Dishwasher.Program.IntensivPower"}, - {"name": "Dishcare.Dishwasher.Program.MagicDaily"}, - {"name": "Dishcare.Dishwasher.Program.Super60"}, - {"name": "Dishcare.Dishwasher.Program.Kurz60"}, - {"name": "Dishcare.Dishwasher.Program.ExpressSparkle65"}, - {"name": "Dishcare.Dishwasher.Program.MachineCare"}, - {"name": "Dishcare.Dishwasher.Program.SteamFresh"}, - {"name": "Dishcare.Dishwasher.Program.MaximumCleaning"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() @@ -361,14 +317,6 @@ class Oven( ): """Oven class.""" - PROGRAMS = [ - {"name": "Cooking.Oven.Program.HeatingMode.PreHeating"}, - {"name": "Cooking.Oven.Program.HeatingMode.HotAir"}, - {"name": "Cooking.Oven.Program.HeatingMode.TopBottomHeating"}, - {"name": "Cooking.Oven.Program.HeatingMode.PizzaSetting"}, - {"name": "Cooking.Oven.Program.Microwave.600Watt"}, - ] - power_off_state = BSH_POWER_STANDBY def get_entity_info(self): @@ -395,30 +343,6 @@ class Washer( ): """Washer class.""" - PROGRAMS = [ - {"name": "LaundryCare.Washer.Program.Cotton"}, - {"name": "LaundryCare.Washer.Program.Cotton.CottonEco"}, - {"name": "LaundryCare.Washer.Program.EasyCare"}, - {"name": "LaundryCare.Washer.Program.Mix"}, - {"name": "LaundryCare.Washer.Program.DelicatesSilk"}, - {"name": "LaundryCare.Washer.Program.Wool"}, - {"name": "LaundryCare.Washer.Program.Sensitive"}, - {"name": "LaundryCare.Washer.Program.Auto30"}, - {"name": "LaundryCare.Washer.Program.Auto40"}, - {"name": "LaundryCare.Washer.Program.Auto60"}, - {"name": "LaundryCare.Washer.Program.Chiffon"}, - {"name": "LaundryCare.Washer.Program.Curtains"}, - {"name": "LaundryCare.Washer.Program.DarkWash"}, - {"name": "LaundryCare.Washer.Program.Dessous"}, - {"name": "LaundryCare.Washer.Program.Monsoon"}, - {"name": "LaundryCare.Washer.Program.Outdoor"}, - {"name": "LaundryCare.Washer.Program.PlushToy"}, - {"name": "LaundryCare.Washer.Program.ShirtsBlouses"}, - {"name": "LaundryCare.Washer.Program.SportFitness"}, - {"name": "LaundryCare.Washer.Program.Towels"}, - {"name": "LaundryCare.Washer.Program.WaterProof"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() @@ -437,23 +361,6 @@ class Washer( class CoffeeMaker(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteStart): """Coffee maker class.""" - PROGRAMS = [ - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado"}, - ] - power_off_state = BSH_POWER_STANDBY def get_entity_info(self): @@ -479,12 +386,6 @@ class Hood( ): """Hood class.""" - PROGRAMS = [ - {"name": "Cooking.Common.Program.Hood.Automatic"}, - {"name": "Cooking.Common.Program.Hood.Venting"}, - {"name": "Cooking.Common.Program.Hood.DelayedShutOff"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" remote_control = self.get_remote_control() @@ -532,8 +433,6 @@ class Freezer(DeviceWithDoor): class Hob(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteControl): """Hob class.""" - PROGRAMS = [{"name": "Cooking.Hob.Program.PowerLevelMode"}] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" remote_control = self.get_remote_control() diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 4b033fd711..987a4317ba 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -30,16 +30,18 @@ from homeassistant.helpers.service import ( async_extract_referenced_entity_ids, async_register_admin_service, ) -from homeassistant.helpers.template import async_load_custom_jinja +from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.typing import ConfigType +from .const import DATA_EXPOSED_ENTITIES, DOMAIN +from .exposed_entities import ExposedEntities + ATTR_ENTRY_ID = "entry_id" _LOGGER = logging.getLogger(__name__) -DOMAIN = ha.DOMAIN SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" SERVICE_RELOAD_CONFIG_ENTRY = "reload_config_entry" -SERVICE_RELOAD_CUSTOM_JINJA = "reload_custom_jinja" +SERVICE_RELOAD_CUSTOM_TEMPLATES = "reload_custom_templates" SERVICE_CHECK_CONFIG = "check_config" SERVICE_UPDATE_ENTITY = "update_entity" SERVICE_SET_LOCATION = "set_location" @@ -260,12 +262,12 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no vol.Schema({ATTR_LATITUDE: cv.latitude, ATTR_LONGITUDE: cv.longitude}), ) - async def async_handle_reload_jinja(call: ha.ServiceCall) -> None: + async def async_handle_reload_templates(call: ha.ServiceCall) -> None: """Service handler to reload custom Jinja.""" - await async_load_custom_jinja(hass) + await async_load_custom_templates(hass) async_register_admin_service( - hass, ha.DOMAIN, SERVICE_RELOAD_CUSTOM_JINJA, async_handle_reload_jinja + hass, ha.DOMAIN, SERVICE_RELOAD_CUSTOM_TEMPLATES, async_handle_reload_templates ) async def async_handle_reload_config_entry(call: ha.ServiceCall) -> None: @@ -300,7 +302,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no Additionally, it also calls the `homeasssitant.reload_core_config` service, as that reloads the core YAML configuration, the `frontend.reload_themes` service that reloads the themes, and the - `homeassistant.reload_custom_jinja` service that reloads any custom + `homeassistant.reload_custom_templates` service that reloads any custom jinja into memory. We only do so, if there are no configuration errors. @@ -330,7 +332,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no for domain, service in ( (ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG), ("frontend", "reload_themes"), - (ha.DOMAIN, SERVICE_RELOAD_CUSTOM_JINJA), + (ha.DOMAIN, SERVICE_RELOAD_CUSTOM_TEMPLATES), ) ] @@ -340,4 +342,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no hass, ha.DOMAIN, SERVICE_RELOAD_ALL, async_handle_reload_all ) + exposed_entities = ExposedEntities(hass) + await exposed_entities.async_initialize() + hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities + return True diff --git a/homeassistant/components/homeassistant/const.py b/homeassistant/components/homeassistant/const.py new file mode 100644 index 0000000000..f3bc95dd1e --- /dev/null +++ b/homeassistant/components/homeassistant/const.py @@ -0,0 +1,6 @@ +"""Constants for the Homeassistant integration.""" +import homeassistant.core as ha + +DOMAIN = ha.DOMAIN + +DATA_EXPOSED_ENTITIES = f"{DOMAIN}.exposed_entites" diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py new file mode 100644 index 0000000000..852e33721c --- /dev/null +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -0,0 +1,355 @@ +"""Control which entities are exposed to voice assistants.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +import dataclasses +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import get_device_class +from homeassistant.helpers.storage import Store + +from .const import DATA_EXPOSED_ENTITIES, DOMAIN + +KNOWN_ASSISTANTS = ("cloud.alexa", "cloud.google_assistant", "conversation") + +STORAGE_KEY = f"{DOMAIN}.exposed_entities" +STORAGE_VERSION = 1 + +SAVE_DELAY = 10 + +DEFAULT_EXPOSED_DOMAINS = { + "climate", + "cover", + "fan", + "humidifier", + "light", + "lock", + "scene", + "script", + "switch", + "vacuum", + "water_heater", +} + +DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES = { + BinarySensorDeviceClass.DOOR, + BinarySensorDeviceClass.GARAGE_DOOR, + BinarySensorDeviceClass.LOCK, + BinarySensorDeviceClass.MOTION, + BinarySensorDeviceClass.OPENING, + BinarySensorDeviceClass.PRESENCE, + BinarySensorDeviceClass.WINDOW, +} + +DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES = { + SensorDeviceClass.AQI, + SensorDeviceClass.CO, + SensorDeviceClass.CO2, + SensorDeviceClass.HUMIDITY, + SensorDeviceClass.PM10, + SensorDeviceClass.PM25, + SensorDeviceClass.TEMPERATURE, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, +} + +DEFAULT_EXPOSED_ASSISTANT = { + "conversation": True, +} + + +@dataclasses.dataclass(frozen=True) +class AssistantPreferences: + """Preferences for an assistant.""" + + expose_new: bool + + def to_json(self) -> dict[str, Any]: + """Return a JSON serializable representation for storage.""" + return {"expose_new": self.expose_new} + + +class ExposedEntities: + """Control assistant settings.""" + + _assistants: dict[str, AssistantPreferences] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize.""" + self._hass = hass + self._listeners: dict[str, list[Callable[[], None]]] = {} + self._store: Store[dict[str, dict[str, dict[str, Any]]]] = Store( + hass, STORAGE_VERSION, STORAGE_KEY + ) + + async def async_initialize(self) -> None: + """Finish initializing.""" + websocket_api.async_register_command(self._hass, ws_expose_entity) + websocket_api.async_register_command(self._hass, ws_expose_new_entities_get) + websocket_api.async_register_command(self._hass, ws_expose_new_entities_set) + await self.async_load() + + @callback + def async_listen_entity_updates( + self, assistant: str, listener: Callable[[], None] + ) -> None: + """Listen for updates to entity expose settings.""" + self._listeners.setdefault(assistant, []).append(listener) + + @callback + def async_expose_entity( + self, assistant: str, entity_id: str, should_expose: bool + ) -> None: + """Expose an entity to an assistant. + + Notify listeners if expose flag was changed. + """ + entity_registry = er.async_get(self._hass) + if not (registry_entry := entity_registry.async_get(entity_id)): + raise HomeAssistantError("Unknown entity") + + assistant_options: Mapping[str, Any] + if ( + assistant_options := registry_entry.options.get(assistant, {}) + ) and assistant_options.get("should_expose") == should_expose: + return + + assistant_options = assistant_options | {"should_expose": should_expose} + entity_registry.async_update_entity_options( + entity_id, assistant, assistant_options + ) + for listener in self._listeners.get(assistant, []): + listener() + + @callback + def async_get_expose_new_entities(self, assistant: str) -> bool: + """Check if new entities are exposed to an assistant.""" + if prefs := self._assistants.get(assistant): + return prefs.expose_new + return DEFAULT_EXPOSED_ASSISTANT.get(assistant, False) + + @callback + def async_set_expose_new_entities(self, assistant: str, expose_new: bool) -> None: + """Enable an assistant to expose new entities.""" + self._assistants[assistant] = AssistantPreferences(expose_new=expose_new) + self._async_schedule_save() + + @callback + def async_get_assistant_settings( + self, assistant: str + ) -> dict[str, Mapping[str, Any]]: + """Get all entity expose settings for an assistant.""" + entity_registry = er.async_get(self._hass) + result: dict[str, Mapping[str, Any]] = {} + + for entity_id, entry in entity_registry.entities.items(): + if options := entry.options.get(assistant): + result[entity_id] = options + + return result + + @callback + def async_should_expose(self, assistant: str, entity_id: str) -> bool: + """Return True if an entity should be exposed to an assistant.""" + should_expose: bool + + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + entity_registry = er.async_get(self._hass) + if not (registry_entry := entity_registry.async_get(entity_id)): + # Entities which are not in the entity registry are not exposed + return False + + if assistant in registry_entry.options: + if "should_expose" in registry_entry.options[assistant]: + should_expose = registry_entry.options[assistant]["should_expose"] + return should_expose + + if self.async_get_expose_new_entities(assistant): + should_expose = self._is_default_exposed(entity_id, registry_entry) + else: + should_expose = False + + assistant_options: Mapping[str, Any] = registry_entry.options.get(assistant, {}) + assistant_options = assistant_options | {"should_expose": should_expose} + entity_registry.async_update_entity_options( + entity_id, assistant, assistant_options + ) + + return should_expose + + def _is_default_exposed( + self, entity_id: str, registry_entry: er.RegistryEntry + ) -> bool: + """Return True if an entity is exposed by default.""" + if ( + registry_entry.entity_category is not None + or registry_entry.hidden_by is not None + ): + return False + + domain = split_entity_id(entity_id)[0] + if domain in DEFAULT_EXPOSED_DOMAINS: + return True + + device_class = get_device_class(self._hass, entity_id) + if ( + domain == "binary_sensor" + and device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES + ): + return True + + if domain == "sensor" and device_class in DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES: + return True + + return False + + async def async_load(self) -> None: + """Load from the store.""" + data = await self._store.async_load() + + assistants: dict[str, AssistantPreferences] = {} + + if data: + for domain, preferences in data["assistants"].items(): + assistants[domain] = AssistantPreferences(**preferences) + + self._assistants = assistants + + @callback + def _async_schedule_save(self) -> None: + """Schedule saving the preferences.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, dict[str, dict[str, Any]]]: + """Return data to store in a file.""" + data = {} + + data["assistants"] = { + domain: preferences.to_json() + for domain, preferences in self._assistants.items() + } + + return data + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_entity", + vol.Required("assistants"): [vol.In(KNOWN_ASSISTANTS)], + vol.Required("entity_ids"): [str], + vol.Required("should_expose"): bool, + } +) +def ws_expose_entity( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose an entity to an assistant.""" + entity_registry = er.async_get(hass) + entity_ids: str = msg["entity_ids"] + + if blocked := next( + ( + entity_id + for entity_id in entity_ids + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES + ), + None, + ): + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_ALLOWED, f"can't expose '{blocked}'" + ) + return + + if unknown := next( + ( + entity_id + for entity_id in entity_ids + if entity_id not in entity_registry.entities + ), + None, + ): + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, f"can't expose '{unknown}'" + ) + return + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + for entity_id in entity_ids: + for assistant in msg["assistants"]: + exposed_entities.async_expose_entity( + assistant, entity_id, msg["should_expose"] + ) + connection.send_result(msg["id"]) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_new_entities/get", + vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS), + } +) +def ws_expose_new_entities_get( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Check if new entities are exposed to an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + expose_new = exposed_entities.async_get_expose_new_entities(msg["assistant"]) + connection.send_result(msg["id"], {"expose_new": expose_new}) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_new_entities/set", + vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS), + vol.Required("expose_new"): bool, + } +) +def ws_expose_new_entities_set( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose new entities to an assistatant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities(msg["assistant"], msg["expose_new"]) + connection.send_result(msg["id"]) + + +@callback +def async_listen_entity_updates( + hass: HomeAssistant, assistant: str, listener: Callable[[], None] +) -> None: + """Listen for updates to entity expose settings.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_listen_entity_updates(assistant, listener) + + +@callback +def async_get_assistant_settings( + hass: HomeAssistant, assistant: str +) -> dict[str, Mapping[str, Any]]: + """Get all entity expose settings for an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_get_assistant_settings(assistant) + + +@callback +def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) -> bool: + """Return True if an entity should be exposed to an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_should_expose(assistant, entity_id) diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 20f23402a7..2fe27769c3 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -59,10 +59,10 @@ update_entity: target: entity: {} -reload_custom_jinja: +reload_custom_templates: name: Reload custom Jinja2 templates description: >- - Reload Jinja2 templates found in the custom_jinja folder in your config. + Reload Jinja2 templates found in the custom_templates folder in your config. New values will be applied on the next render of the template. reload_config_entry: diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index ffc0594baf..8b04f84570 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -106,7 +106,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(slots=True, frozen=True) class IntegrationAlert: """Issue Registry Entry.""" diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 20fdb97e38..ff2bf9138f 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -8,6 +8,7 @@ import logging from typing import Any import voluptuous as vol +import yarl from homeassistant import config_entries from homeassistant.components.hassio import ( @@ -15,6 +16,7 @@ from homeassistant.components.hassio import ( AddonInfo, AddonManager, AddonState, + hostname_from_addon_slug, is_hassio, ) from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN @@ -26,6 +28,7 @@ from homeassistant.data_entry_flow import ( FlowManager, FlowResult, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.singleton import singleton from .const import LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG @@ -63,12 +66,20 @@ class SerialPortSettings: flow_control: bool -def get_zigbee_socket(hass: HomeAssistant, addon_info: AddonInfo) -> str: +def get_zigbee_socket() -> str: """Return the zigbee socket. Raises AddonError on error """ - return f"socket://{addon_info.hostname}:9999" + hostname = hostname_from_addon_slug(SILABS_MULTIPROTOCOL_ADDON_SLUG) + return f"socket://{hostname}:9999" + + +def is_multiprotocol_url(url: str) -> bool: + """Return if the URL points at the Multiprotocol add-on.""" + parsed = yarl.URL(url) + hostname = hostname_from_addon_slug(SILABS_MULTIPROTOCOL_ADDON_SLUG) + return parsed.host == hostname class BaseMultiPanFlow(FlowHandler, ABC): @@ -289,7 +300,7 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): "new_discovery_info": { "name": self._zha_name(), "port": { - "path": get_zigbee_socket(self.hass, addon_info), + "path": get_zigbee_socket(), }, "radio_type": "ezsp", }, @@ -356,3 +367,51 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): if user_input is None: return self.async_show_form(step_id="addon_installed_other_device") return self.async_create_entry(title="", data={}) + + +async def check_multi_pan_addon(hass: HomeAssistant) -> None: + """Check the multi-PAN addon state, and start it if installed but not started. + + Does nothing if Hass.io is not loaded. + Raises on error or if the add-on is installed but not started. + """ + if not is_hassio(hass): + return + + addon_manager: AddonManager = get_addon_manager(hass) + try: + addon_info: AddonInfo = await addon_manager.async_get_addon_info() + except AddonError as err: + _LOGGER.error(err) + raise HomeAssistantError from err + + # Request the addon to start if it's not started + # addon_manager.async_start_addon returns as soon as the start request has been sent + # and does not wait for the addon to be started, so we raise below + if addon_info.state == AddonState.NOT_RUNNING: + await addon_manager.async_start_addon() + + if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING): + _LOGGER.debug("Multi pan addon installed and in state %s", addon_info.state) + raise HomeAssistantError + + +async def multi_pan_addon_using_device(hass: HomeAssistant, device_path: str) -> bool: + """Return True if the multi-PAN addon is using the given device. + + Returns False if Hass.io is not loaded, the addon is not running or the addon is + connected to another device. + """ + if not is_hassio(hass): + return False + + addon_manager: AddonManager = get_addon_manager(hass) + addon_info: AddonInfo = await addon_manager.async_get_addon_info() + + if addon_info.state != AddonState.RUNNING: + return False + + if addon_info.options["device"] != device_path: + return False + + return True diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 1de919b8c7..0f7ec70471 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -1,75 +1,19 @@ """The Home Assistant SkyConnect integration.""" from __future__ import annotations -import logging - from homeassistant.components import usb -from homeassistant.components.hassio import ( - AddonError, - AddonInfo, - AddonManager, - AddonState, - is_hassio, -) from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( - get_addon_manager, + check_multi_pan_addon, get_zigbee_socket, + multi_pan_addon_using_device, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from .const import DOMAIN from .util import get_usb_service_info -_LOGGER = logging.getLogger(__name__) - - -async def _wait_multi_pan_addon(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Wait for multi-PAN info to be available.""" - if not is_hassio(hass): - return - - addon_manager: AddonManager = get_addon_manager(hass) - try: - addon_info: AddonInfo = await addon_manager.async_get_addon_info() - except AddonError as err: - _LOGGER.error(err) - raise ConfigEntryNotReady from err - - # Start the addon if it's not started - if addon_info.state == AddonState.NOT_RUNNING: - await addon_manager.async_start_addon() - - if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING): - _LOGGER.debug( - "Multi pan addon in state %s, delaying yellow config entry setup", - addon_info.state, - ) - raise ConfigEntryNotReady - - -async def _multi_pan_addon_info( - hass: HomeAssistant, entry: ConfigEntry -) -> AddonInfo | None: - """Return AddonInfo if the multi-PAN addon is enabled for our SkyConnect.""" - if not is_hassio(hass): - return None - - addon_manager: AddonManager = get_addon_manager(hass) - addon_info: AddonInfo = await addon_manager.async_get_addon_info() - - if addon_info.state != AddonState.RUNNING: - return None - - usb_dev = entry.data["device"] - dev_path = await hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) - - if addon_info.options["device"] != dev_path: - return None - - return addon_info - async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: """Finish Home Assistant SkyConnect config entry setup.""" @@ -87,9 +31,10 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return - addon_info = await _multi_pan_addon_info(hass, entry) + usb_dev = entry.data["device"] + dev_path = await hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) - if not addon_info: + if not await multi_pan_addon_using_device(hass, dev_path): usb_info = get_usb_service_info(entry) await hass.config_entries.flow.async_init( "zha", @@ -101,7 +46,7 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: hw_discovery_data = { "name": "SkyConnect Multi-PAN", "port": { - "path": get_zigbee_socket(hass, addon_info), + "path": get_zigbee_socket(), }, "radio_type": "ezsp", } @@ -115,7 +60,10 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant SkyConnect config entry.""" - await _wait_multi_pan_addon(hass, entry) + try: + await check_multi_pan_addon(hass) + except HomeAssistantError as err: + raise ConfigEntryNotReady from err @callback def async_usb_scan_done() -> None: diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 9e22736fc7..30015d1bae 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -1,58 +1,18 @@ """The Home Assistant Yellow integration.""" from __future__ import annotations -import logging - -from homeassistant.components.hassio import ( - AddonError, - AddonInfo, - AddonManager, - AddonState, - get_os_info, -) +from homeassistant.components.hassio import get_os_info from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( - get_addon_manager, + check_multi_pan_addon, get_zigbee_socket, + multi_pan_addon_using_device, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from .const import RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA -_LOGGER = logging.getLogger(__name__) - - -async def _multi_pan_addon_info( - hass: HomeAssistant, entry: ConfigEntry -) -> AddonInfo | None: - """Return AddonInfo if the multi-PAN addon is enabled for the Yellow's radio.""" - addon_manager: AddonManager = get_addon_manager(hass) - try: - addon_info: AddonInfo = await addon_manager.async_get_addon_info() - except AddonError as err: - _LOGGER.error(err) - raise ConfigEntryNotReady from err - - # Start the addon if it's not started - if addon_info.state == AddonState.NOT_RUNNING: - await addon_manager.async_start_addon() - - if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING): - _LOGGER.debug( - "Multi pan addon in state %s, delaying yellow config entry setup", - addon_info.state, - ) - raise ConfigEntryNotReady - - if addon_info.state == AddonState.NOT_INSTALLED: - return None - - if addon_info.options["device"] != RADIO_DEVICE: - return None - - return addon_info - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant Yellow config entry.""" @@ -66,15 +26,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return False - addon_info = await _multi_pan_addon_info(hass, entry) + try: + await check_multi_pan_addon(hass) + except HomeAssistantError as err: + raise ConfigEntryNotReady from err - if not addon_info: + if not await multi_pan_addon_using_device(hass, RADIO_DEVICE): hw_discovery_data = ZHA_HW_DISCOVERY_DATA else: hw_discovery_data = { "name": "Yellow Multi-PAN", "port": { - "path": get_zigbee_socket(hass, addon_info), + "path": get_zigbee_socket(), }, "radio_type": "ezsp", } diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 09cdcc1469..3da67023ab 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -1,15 +1,37 @@ """Config flow for the Home Assistant Yellow integration.""" from __future__ import annotations +import logging from typing import Any +import aiohttp +import async_timeout +import voluptuous as vol + +from homeassistant.components.hassio import ( + HassioAPIError, + async_get_yellow_settings, + async_reboot_host, + async_set_yellow_settings, +) from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector from .const import DOMAIN, ZHA_HW_DISCOVERY_DATA +_LOGGER = logging.getLogger(__name__) + +STEP_HW_SETTINGS_SCHEMA = vol.Schema( + { + vol.Required("disk_led"): selector.BooleanSelector(), + vol.Required("heartbeat_led"): selector.BooleanSelector(), + vol.Required("power_led"): selector.BooleanSelector(), + } +) + class HomeAssistantYellowConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Home Assistant Yellow.""" @@ -35,6 +57,82 @@ class HomeAssistantYellowConfigFlow(ConfigFlow, domain=DOMAIN): class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): """Handle an option flow for Home Assistant Yellow.""" + _hw_settings: dict[str, bool] | None = None + + async def async_step_on_supervisor( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle logic when on Supervisor host.""" + return self.async_show_menu( + step_id="main_menu", + menu_options=[ + "hardware_settings", + "multipan_settings", + ], + ) + + async def async_step_hardware_settings( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle hardware settings.""" + + if user_input is not None: + if self._hw_settings == user_input: + return self.async_create_entry(data={}) + try: + async with async_timeout.timeout(10): + await async_set_yellow_settings(self.hass, user_input) + except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: + _LOGGER.warning("Failed to write hardware settings", exc_info=err) + return self.async_abort(reason="write_hw_settings_error") + return await self.async_step_confirm_reboot() + + try: + async with async_timeout.timeout(10): + self._hw_settings: dict[str, bool] = await async_get_yellow_settings( + self.hass + ) + except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: + _LOGGER.warning("Failed to read hardware settings", exc_info=err) + return self.async_abort(reason="read_hw_settings_error") + + schema = self.add_suggested_values_to_schema( + STEP_HW_SETTINGS_SCHEMA, self._hw_settings + ) + + return self.async_show_form(step_id="hardware_settings", data_schema=schema) + + async def async_step_confirm_reboot( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reboot host.""" + return self.async_show_menu( + step_id="reboot_menu", + menu_options=[ + "reboot_now", + "reboot_later", + ], + ) + + async def async_step_reboot_now( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Reboot now.""" + await async_reboot_host(self.hass) + return self.async_create_entry(data={}) + + async def async_step_reboot_later( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Reboot later.""" + return self.async_create_entry(data={}) + + async def async_step_multipan_settings( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle multipan settings.""" + return await super().async_step_on_supervisor(user_input) + async def _async_serial_port_settings( self, ) -> silabs_multiprotocol_addon.SerialPortSettings: diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 970f9d97a4..d97b01c7c8 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -11,9 +11,31 @@ "addon_installed_other_device": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]" }, + "hardware_settings": { + "title": "Configure hardware settings", + "data": { + "disk_led": "Disk LED", + "heartbeat_led": "Heartbeat LED", + "power_led": "Power LED" + } + }, "install_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" }, + "main_menu": { + "menu_options": { + "hardware_settings": "[%key:component::homeassistant_yellow::options::step::hardware_settings::title%]", + "multipan_settings": "Configure IEEE 802.15.4 radio multiprotocol support" + } + }, + "reboot_menu": { + "title": "Reboot required", + "description": "The settings have changed, but the new settings will not take effect until the system is rebooted", + "menu_options": { + "reboot_later": "Reboot manually later", + "reboot_now": "Reboot now" + } + }, "show_revert_guide": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]", "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]" @@ -31,6 +53,8 @@ "addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]", "addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]", "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", + "read_hw_settings_error": "Failed to read hardware settings", + "write_hw_settings_error": "Failed to write hardware settings", "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]" }, "progress": { diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index d5a6202ea2..2b56a05682 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -302,10 +302,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Begin setup HomeKit for %s", name) # ip_address and advertise_ip are yaml only - ip_address = conf.get( - CONF_IP_ADDRESS, await network.async_get_source_ip(hass, MDNS_TARGET_IP) + ip_address = conf.get(CONF_IP_ADDRESS, [None]) + advertise_ip = conf.get( + CONF_ADVERTISE_IP, await network.async_get_source_ip(hass, MDNS_TARGET_IP) ) - advertise_ip = conf.get(CONF_ADVERTISE_IP) # exclude_accessory_mode is only used for config flow # to indicate that the config entry was setup after # we started creating config entries for entities that @@ -597,7 +597,9 @@ class HomeKit: await self._async_shutdown_accessory(acc) if new_acc := self._async_create_single_accessory([state]): self.driver.accessory = new_acc - self.hass.async_add_job(new_acc.run) + self.hass.async_create_task( + new_acc.run(), f"HomeKit Bridge Accessory: {new_acc.entity_id}" + ) await self.async_config_changed() async def async_reset_accessories_in_bridge_mode( @@ -637,7 +639,9 @@ class HomeKit: await asyncio.sleep(_HOMEKIT_CONFIG_UPDATE_TIME) for state in new: if acc := self.add_bridge_accessory(state): - self.hass.async_add_job(acc.run) + self.hass.async_create_task( + acc.run(), f"HomeKit Bridge Accessory: {acc.entity_id}" + ) await self.async_config_changed() async def async_config_changed(self) -> None: diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index 4addbeb1e2..9c3d9e7929 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -13,7 +13,7 @@ from __future__ import annotations from collections.abc import Generator import random -from fnvhash import fnv1a_32 +from fnv_hash_fast import fnv1a_32 from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 4517f9c5a5..81dbf4f7e2 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -19,6 +19,8 @@ VIDEO_CODEC_COPY = "copy" VIDEO_CODEC_LIBX264 = "libx264" AUDIO_CODEC_OPUS = "libopus" VIDEO_CODEC_H264_OMX = "h264_omx" +VIDEO_CODEC_H264_V4L2M2M = "h264_v4l2m2m" +VIDEO_PROFILE_NAMES = ["baseline", "main", "high"] AUDIO_CODEC_COPY = "copy" # #### Attributes #### @@ -54,6 +56,7 @@ CONF_STREAM_ADDRESS = "stream_address" CONF_STREAM_SOURCE = "stream_source" CONF_SUPPORT_AUDIO = "support_audio" CONF_VIDEO_CODEC = "video_codec" +CONF_VIDEO_PROFILE_NAMES = "video_profile_names" CONF_VIDEO_MAP = "video_map" CONF_VIDEO_PACKET_SIZE = "video_packet_size" CONF_STREAM_COUNT = "stream_count" @@ -71,6 +74,7 @@ DEFAULT_MAX_WIDTH = 1920 DEFAULT_PORT = 21063 DEFAULT_CONFIG_FLOW_PORT = 21064 DEFAULT_VIDEO_CODEC = VIDEO_CODEC_LIBX264 +DEFAULT_VIDEO_PROFILE_NAMES = VIDEO_PROFILE_NAMES DEFAULT_VIDEO_MAP = "0:v:0" DEFAULT_VIDEO_PACKET_SIZE = 1316 DEFAULT_STREAM_COUNT = 3 diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 80eea60b9e..746b097e99 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -1,6 +1,6 @@ { "domain": "homekit", - "name": "HomeKit", + "name": "HomeKit Bridge", "after_dependencies": ["camera", "zeroconf"], "codeowners": ["@bdraco"], "config_flow": true, @@ -10,7 +10,7 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==4.6.0", - "fnvhash==0.1.0", + "fnv-hash-fast==0.3.1", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index d041f8e055..74af388df8 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -24,7 +24,7 @@ "data": { "entities": "Entities" }, - "description": "All “{domains}” entities will be included unless specific entities are selected.", + "description": "Select entities from each domain in “{domains}”. The include will cover the entire domain if you do not select any entities for a given domain.", "title": "Select the entities to be included" }, "exclude": { diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 9d7f2ae4c6..3bc2b1ed6a 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -40,6 +40,7 @@ from .const import ( CONF_VIDEO_CODEC, CONF_VIDEO_MAP, CONF_VIDEO_PACKET_SIZE, + CONF_VIDEO_PROFILE_NAMES, DEFAULT_AUDIO_CODEC, DEFAULT_AUDIO_MAP, DEFAULT_AUDIO_PACKET_SIZE, @@ -51,6 +52,7 @@ from .const import ( DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, + DEFAULT_VIDEO_PROFILE_NAMES, SERV_DOORBELL, SERV_MOTION_SENSOR, SERV_SPEAKER, @@ -111,8 +113,6 @@ RESOLUTIONS = [ (1600, 1200), ] -VIDEO_PROFILE_NAMES = ["baseline", "main", "high"] - FFMPEG_WATCH_INTERVAL = timedelta(seconds=5) FFMPEG_LOGGER = "ffmpeg_logger" FFMPEG_WATCHER = "ffmpeg_watcher" @@ -128,6 +128,7 @@ CONFIG_DEFAULTS = { CONF_AUDIO_MAP: DEFAULT_AUDIO_MAP, CONF_VIDEO_MAP: DEFAULT_VIDEO_MAP, CONF_VIDEO_CODEC: DEFAULT_VIDEO_CODEC, + CONF_VIDEO_PROFILE_NAMES: DEFAULT_VIDEO_PROFILE_NAMES, CONF_AUDIO_PACKET_SIZE: DEFAULT_AUDIO_PACKET_SIZE, CONF_VIDEO_PACKET_SIZE: DEFAULT_VIDEO_PACKET_SIZE, CONF_STREAM_COUNT: DEFAULT_STREAM_COUNT, @@ -346,7 +347,7 @@ class Camera(HomeAccessory, PyhapCamera): if self.config[CONF_VIDEO_CODEC] != "copy": video_profile = ( "-profile:v " - + VIDEO_PROFILE_NAMES[ + + self.config[CONF_VIDEO_PROFILE_NAMES][ int.from_bytes(stream_config["v_profile_id"], byteorder="big") ] + " " diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index b585f38e98..33c35908cd 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -115,20 +115,12 @@ class HumidifierDehumidifier(HomeAccessory): CHAR_CURRENT_HUMIDITY, value=0 ) - max_humidity = state.attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY) - max_humidity = round(max_humidity) - max_humidity = min(max_humidity, 100) - - min_humidity = state.attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) - min_humidity = round(min_humidity) - min_humidity = max(min_humidity, 0) - self.char_target_humidity = serv_humidifier_dehumidifier.configure_char( self._target_humidity_char_name, value=45, properties={ - PROP_MIN_VALUE: min_humidity, - PROP_MAX_VALUE: max_humidity, + PROP_MIN_VALUE: DEFAULT_MIN_HUMIDITY, + PROP_MAX_VALUE: DEFAULT_MAX_HUMIDITY, PROP_MIN_STEP: 1, }, ) @@ -219,7 +211,23 @@ class HumidifierDehumidifier(HomeAccessory): ) if self._target_humidity_char_name in char_values: + state = self.hass.states.get(self.entity_id) + max_humidity = state.attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY) + max_humidity = round(max_humidity) + max_humidity = min(max_humidity, 100) + + min_humidity = state.attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) + min_humidity = round(min_humidity) + min_humidity = max(min_humidity, 0) + humidity = round(char_values[self._target_humidity_char_name]) + + if (humidity < min_humidity) or (humidity > max_humidity): + humidity = min(max_humidity, max(min_humidity, humidity)) + # Update the HomeKit value to the clamped humidity, so the user will get a visual feedback that they + # cannot not set to a value below/above the min/max. + self.char_target_humidity.set_value(humidity) + self.async_call_service( DOMAIN, SERVICE_SET_HUMIDITY, diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 5f0838d91a..0e3bcbfee8 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -95,6 +95,7 @@ from .const import ( TYPE_VALVE, VIDEO_CODEC_COPY, VIDEO_CODEC_H264_OMX, + VIDEO_CODEC_H264_V4L2M2M, VIDEO_CODEC_LIBX264, ) @@ -107,7 +108,12 @@ MAX_VERSION_PART = 2**32 - 1 MAX_PORT = 65535 -VALID_VIDEO_CODECS = [VIDEO_CODEC_LIBX264, VIDEO_CODEC_H264_OMX, AUDIO_CODEC_COPY] +VALID_VIDEO_CODECS = [ + VIDEO_CODEC_LIBX264, + VIDEO_CODEC_H264_OMX, + VIDEO_CODEC_H264_V4L2M2M, + AUDIO_CODEC_COPY, +] VALID_AUDIO_CODECS = [AUDIO_CODEC_OPUS, VIDEO_CODEC_COPY] BASIC_INFO_SCHEMA = vol.Schema( diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index a466d15db5..a741cf5492 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -27,8 +27,6 @@ from . import KNOWN_DEVICES from .connection import HKDevice from .entity import HomeKitEntity -ICON = "mdi:security" - CURRENT_STATE_MAP = { 0: STATE_ALARM_ARMED_HOME, 1: STATE_ALARM_ARMED_AWAY, @@ -72,6 +70,7 @@ async def async_setup_entry( class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): """Representation of a Homekit Alarm Control Panel.""" + _attr_icon = "mdi:security" _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -86,11 +85,6 @@ class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): CharacteristicsTypes.BATTERY_LEVEL, ] - @property - def icon(self) -> str: - """Return icon.""" - return ICON - @property def state(self) -> str: """Return the state of the device.""" diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 4814e7833c..db85dbda3d 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -272,6 +272,8 @@ class HKDevice: self.hass, self.async_update_available_state, timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL), + name=f"HomeKit Controller {self.unique_id} BLE availability " + "check poll", ) ) # BLE devices always get an RSSI sensor as well @@ -286,7 +288,10 @@ class HKDevice: # in the log about concurrent polling. self.config_entry.async_on_unload( async_track_time_interval( - self.hass, self.async_request_update, self.pairing.poll_interval + self.hass, + self.async_request_update, + self.pairing.poll_interval, + name=f"HomeKit Controller {self.unique_id} availability check poll", ) ) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index e4eeea04f5..9db26d4c8e 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==2.6.1"], + "requirements": ["aiohomekit==2.6.3"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 8369b4acce..1b86e36b82 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["homematicip"], "quality_scale": "silver", - "requirements": ["homematicip==1.0.13"] + "requirements": ["homematicip==1.0.14"] } diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index 794bc2d12e..3e3c967f97 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -6,7 +6,7 @@ "data": { "hapid": "Access point ID (SGTIN)", "pin": "[%key:common::config_flow::data::pin%]", - "name": "[%key:common::config_flow::data::name%] (optional, used as name prefix for all devices)" + "name": "Name (optional, used as name prefix for all devices)" } }, "link": { @@ -16,7 +16,7 @@ }, "error": { "register_failed": "Failed to register, please try again.", - "invalid_sgtin_or_pin": "Invalid SGTIN or [%key:common::config_flow::data::pin%], please try again.", + "invalid_sgtin_or_pin": "Invalid SGTIN or PIN code, please try again.", "press_the_button": "Please press the blue button.", "timeout_button": "Blue button press timeout, please try again." }, diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py index 6a245a0042..665406499e 100644 --- a/homeassistant/components/homewizard/button.py +++ b/homeassistant/components/homewizard/button.py @@ -1,4 +1,5 @@ """Support for HomeWizard buttons.""" + from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory @@ -16,7 +17,7 @@ async def async_setup_entry( ) -> None: """Set up the Identify button.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - if coordinator.data.features.has_identify: + if coordinator.supports_identify(): async_add_entities([HomeWizardIdentifyButton(coordinator, entry)]) diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index 34c83626f8..ff06559228 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -4,7 +4,6 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from homewizard_energy.features import Features from homewizard_energy.models import Data, Device, State, System from homeassistant.const import Platform @@ -30,6 +29,5 @@ class DeviceResponseEntry: device: Device data: Data - features: Features - state: State | None + state: State | None = None system: System | None = None diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index 2da618eeb2..fb89989b2a 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -4,7 +4,9 @@ from __future__ import annotations import logging from homewizard_energy import HomeWizardEnergy -from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.const import SUPPORTS_IDENTIFY, SUPPORTS_STATE, SUPPORTS_SYSTEM +from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError +from homewizard_energy.models import Device from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -22,6 +24,8 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] api: HomeWizardEnergy api_disabled: bool = False + _unsupported_error: bool = False + def __init__( self, hass: HomeAssistant, @@ -39,12 +43,24 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] data = DeviceResponseEntry( device=await self.api.device(), data=await self.api.data(), - features=await self.api.features(), - state=await self.api.state(), ) - if data.features.has_system: - data.system = await self.api.system() + try: + if self.supports_state(data.device): + data.state = await self.api.state() + + if self.supports_system(data.device): + data.system = await self.api.system() + + except UnsupportedError as ex: + # Old firmware, ignore + if not self._unsupported_error: + self._unsupported_error = True + _LOGGER.warning( + "%s is running an outdated firmware version (%s). Contact HomeWizard support to update your device", + self.entry.title, + ex, + ) except RequestError as ex: raise UpdateFailed(ex) from ex @@ -61,4 +77,27 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] self.api_disabled = False + self.data = data return data + + def supports_state(self, device: Device | None = None) -> bool: + """Return True if the device supports state.""" + + if device is None: + device = self.data.device + + return device.product_type in SUPPORTS_STATE + + def supports_system(self, device: Device | None = None) -> bool: + """Return True if the device supports system.""" + if device is None: + device = self.data.device + + return device.product_type in SUPPORTS_SYSTEM + + def supports_identify(self, device: Device | None = None) -> bool: + """Return True if the device supports identify.""" + if device is None: + device = self.data.device + + return device.product_type in SUPPORTS_IDENTIFY diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index e05b34dbcd..b1bbd8d094 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==1.8.0"], + "requirements": ["python-homewizard-energy==2.0.1"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index 8d00234f97..0451aed973 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -20,7 +20,7 @@ async def async_setup_entry( ) -> None: """Set up numbers for device.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - if coordinator.data.state: + if coordinator.supports_state(): async_add_entities([HWEnergyNumberEntity(coordinator, entry)]) diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index f6a9ed2b05..1edb9e1e60 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -27,7 +27,7 @@ from .helpers import homewizard_exception_handler class HomeWizardEntityDescriptionMixin: """Mixin values for HomeWizard entities.""" - create_fn: Callable[[DeviceResponseEntry], bool] + create_fn: Callable[[HWEnergyDeviceUpdateCoordinator], bool] available_fn: Callable[[DeviceResponseEntry], bool] is_on_fn: Callable[[DeviceResponseEntry], bool | None] set_fn: Callable[[HomeWizardEnergy, bool], Awaitable[Any]] @@ -46,7 +46,7 @@ SWITCHES = [ HomeWizardSwitchEntityDescription( key="power_on", device_class=SwitchDeviceClass.OUTLET, - create_fn=lambda data: data.state is not None, + create_fn=lambda coordinator: coordinator.supports_state(), available_fn=lambda data: data.state is not None and not data.state.switch_lock, is_on_fn=lambda data: data.state.power_on if data.state else None, set_fn=lambda api, active: api.state_set(power_on=active), @@ -57,7 +57,7 @@ SWITCHES = [ entity_category=EntityCategory.CONFIG, icon="mdi:lock", icon_off="mdi:lock-open", - create_fn=lambda data: data.state is not None, + create_fn=lambda coordinator: coordinator.supports_state(), available_fn=lambda data: data.state is not None, is_on_fn=lambda data: data.state.switch_lock if data.state else None, set_fn=lambda api, active: api.state_set(switch_lock=active), @@ -68,7 +68,7 @@ SWITCHES = [ entity_category=EntityCategory.CONFIG, icon="mdi:cloud", icon_off="mdi:cloud-off-outline", - create_fn=lambda data: data.system is not None, + create_fn=lambda coordinator: coordinator.supports_system(), available_fn=lambda data: data.system is not None, is_on_fn=lambda data: data.system.cloud_enabled if data.system else None, set_fn=lambda api, active: api.system_set(cloud_enabled=active), @@ -91,7 +91,7 @@ async def async_setup_entry( entry=entry, ) for description in SWITCHES - if description.available_fn(coordinator.data) + if description.create_fn(coordinator) ) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 93c29446a5..ff54488228 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -63,6 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except ( aiosomecomfort.device.ConnectionError, aiosomecomfort.device.ConnectionTimeout, + aiosomecomfort.device.SomeComfortError, asyncio.TimeoutError, ) as ex: raise ConfigEntryNotReady( diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index e9dae1e207..dd33da5629 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -1,9 +1,11 @@ """Support for Honeywell (US) Total Connect Comfort climate systems.""" from __future__ import annotations +import asyncio import datetime from typing import Any +from aiohttp import ClientConnectionError import aiosomecomfort from homeassistant.components.climate import ( @@ -212,9 +214,9 @@ class HoneywellUSThermostat(ClimateEntity): return self._device.current_humidity @property - def hvac_mode(self) -> HVACMode: + def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool mode.""" - return HW_MODE_TO_HVAC_MODE[self._device.system_mode] + return HW_MODE_TO_HVAC_MODE.get(self._device.system_mode) @property def hvac_action(self) -> HVACAction | None: @@ -341,12 +343,8 @@ class HoneywellUSThermostat(ClimateEntity): it doesn't get overwritten when away mode is switched on. """ self._away = True - try: - # Get current mode - mode = self._device.system_mode - except aiosomecomfort.SomeComfortError: - _LOGGER.error("Can not get system mode") - return + # Get current mode + mode = self._device.system_mode try: # Set permanent hold # and Set temperature @@ -365,12 +363,8 @@ class HoneywellUSThermostat(ClimateEntity): async def _turn_hold_mode_on(self) -> None: """Turn permanent hold on.""" - try: - # Get current mode - mode = self._device.system_mode - except aiosomecomfort.SomeComfortError: - _LOGGER.error("Can not get system mode") - return + # Get current mode + mode = self._device.system_mode # Check that we got a valid mode back if mode in HW_MODE_TO_HVAC_MODE: try: @@ -421,17 +415,16 @@ class HoneywellUSThermostat(ClimateEntity): try: await self._device.refresh() self._attr_available = True - except ( - aiosomecomfort.SomeComfortError, - OSError, - ): + except aiosomecomfort.SomeComfortError: try: await self._data.client.login() - except aiosomecomfort.AuthError: - self._attr_available = False - await self.hass.async_create_task( - self.hass.config_entries.async_reload(self._data.entry_id) - ) - except aiosomecomfort.SomeComfortError: + except ( + aiosomecomfort.SomeComfortError, + ClientConnectionError, + asyncio.TimeoutError, + ): self._attr_available = False + + except (ClientConnectionError, asyncio.TimeoutError): + self._attr_available = False diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 04b94dc3b8..2d306ba5ee 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -1,6 +1,7 @@ """Support to serve the Home Assistant API as WSGI application.""" from __future__ import annotations +import asyncio import datetime from ipaddress import IPv4Network, IPv6Network, ip_network import logging @@ -10,8 +11,13 @@ from tempfile import NamedTemporaryFile from typing import Any, Final, TypedDict, cast from aiohttp import web -from aiohttp.typedefs import StrOrURL +from aiohttp.abc import AbstractStreamWriter +from aiohttp.http_parser import RawRequestMessage +from aiohttp.streams import StreamReader +from aiohttp.typedefs import JSONDecoder, StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection +from aiohttp.web_log import AccessLogger +from aiohttp.web_protocol import RequestHandler from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -30,6 +36,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.setup import async_start_setup, async_when_setup_or_start from homeassistant.util import ssl as ssl_util +from homeassistant.util.json import json_loads from .auth import async_setup_auth from .ban import setup_bans @@ -220,6 +227,59 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +class HomeAssistantAccessLogger(AccessLogger): + """Access logger for Home Assistant that does not log when disabled.""" + + def log( + self, request: web.BaseRequest, response: web.StreamResponse, time: float + ) -> None: + """Log the request. + + The default implementation logs the request to the logger + with the INFO level and than throws it away if the logger + is not enabled for the INFO level. This implementation + does not log the request if the logger is not enabled for + the INFO level. + """ + if not self.logger.isEnabledFor(logging.INFO): + return + super().log(request, response, time) + + +class HomeAssistantRequest(web.Request): + """Home Assistant request object.""" + + async def json(self, *, loads: JSONDecoder = json_loads) -> Any: + """Return body as JSON.""" + # json_loads is a wrapper around orjson.loads that handles + # bytes and str. We can pass the bytes directly to json_loads. + return json_loads(await self.read()) + + +class HomeAssistantApplication(web.Application): + """Home Assistant application.""" + + def _make_request( + self, + message: RawRequestMessage, + payload: StreamReader, + protocol: RequestHandler, + writer: AbstractStreamWriter, + task: asyncio.Task[None], + _cls: type[web.Request] = HomeAssistantRequest, + ) -> web.Request: + """Create request instance.""" + return _cls( + message, + payload, + protocol, + writer, + task, + loop=self._loop, + client_max_size=self._client_max_size, + ) + + class HomeAssistantHTTP: """HTTP server for Home Assistant.""" @@ -235,7 +295,7 @@ class HomeAssistantHTTP: ssl_profile: str, ) -> None: """Initialize the HTTP Home Assistant server.""" - self.app = web.Application( + self.app = HomeAssistantApplication( middlewares=[], client_max_size=MAX_CLIENT_SIZE, handler_args={ @@ -462,7 +522,9 @@ class HomeAssistantHTTP: # pylint: disable-next=protected-access self.app._router.freeze = lambda: None # type: ignore[method-assign] - self.runner = web.AppRunner(self.app) + self.runner = web.AppRunner( + self.app, access_log_class=HomeAssistantAccessLogger + ) await self.runner.setup() self.site = HomeAssistantTCPSite( diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 5213cd1b07..f2cfe06740 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -13,6 +13,7 @@ from aiohttp.web import Application, Request, StreamResponse, middleware import jwt from yarl import URL +from homeassistant.auth import jwt_wrapper from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User from homeassistant.components import websocket_api @@ -60,9 +61,7 @@ def async_sign_path( url = URL(path) now = dt_util.utcnow() - params = dict(sorted(url.query.items())) - for param in SAFE_QUERY_PARAMS: - params.pop(param, None) + params = [itm for itm in url.query.items() if itm[0] not in SAFE_QUERY_PARAMS] encoded = jwt.encode( { "iss": refresh_token_id, @@ -75,7 +74,7 @@ def async_sign_path( algorithm="HS256", ) - params[SIGN_QUERY_PARAM] = encoded + params.append((SIGN_QUERY_PARAM, encoded)) url = url.with_query(params) return f"{url.path}?{url.query_string}" @@ -175,7 +174,7 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: return False try: - claims = jwt.decode( + claims = jwt_wrapper.verify_and_decode( signature, secret, algorithms=["HS256"], options={"verify_iss": False} ) except jwt.InvalidTokenError: @@ -184,10 +183,11 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: if claims["path"] != request.path: return False - params = dict(sorted(request.query.items())) - del params[SIGN_QUERY_PARAM] - for param in SAFE_QUERY_PARAMS: - params.pop(param, None) + params = [ + list(itm) # claims stores tuples as lists + for itm in request.query.items() + if itm[0] not in SAFE_QUERY_PARAMS and itm[0] != SIGN_QUERY_PARAM + ] if claims["params"] != params: return False diff --git a/homeassistant/components/http/security_filter.py b/homeassistant/components/http/security_filter.py index a9b32bd7f4..e8e3aa4699 100644 --- a/homeassistant/components/http/security_filter.py +++ b/homeassistant/components/http/security_filter.py @@ -35,6 +35,9 @@ FILTERS: Final = re.compile( ) # fmt: on +# Unsafe bytes to be removed per WHATWG spec +UNSAFE_URL_BYTES = ["\t", "\r", "\n"] + @callback def setup_security_filter(app: Application) -> None: @@ -51,6 +54,21 @@ def setup_security_filter(app: Application) -> None: request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: """Process request and block commonly known exploit attempts.""" + for unsafe_byte in UNSAFE_URL_BYTES: + if unsafe_byte in request.path: + _LOGGER.warning( + "Filtered a request with an unsafe byte in path: %s", + request.raw_path, + ) + raise HTTPBadRequest + + if unsafe_byte in request.query_string: + _LOGGER.warning( + "Filtered a request with unsafe byte query string: %s", + request.raw_path, + ) + raise HTTPBadRequest + if FILTERS.search(_recursive_unquote(request.path)): _LOGGER.warning( "Filtered a potential harmful request to: %s", request.raw_path diff --git a/homeassistant/components/ihc/binary_sensor.py b/homeassistant/components/ihc/binary_sensor.py index 48035d27a4..badf0f4e92 100644 --- a/homeassistant/components/ihc/binary_sensor.py +++ b/homeassistant/components/ihc/binary_sensor.py @@ -3,11 +3,15 @@ from __future__ import annotations from ihcsdk.ihccontroller import IHCController -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.enum import try_parse_enum from .const import CONF_INVERTING, DOMAIN, IHC_CONTROLLER from .ihcdevice import IHCDevice @@ -62,24 +66,13 @@ class IHCBinarySensor(IHCDevice, BinarySensorEntity): ) -> None: """Initialize the IHC binary sensor.""" super().__init__(ihc_controller, controller_id, name, ihc_id, product) - self._state = None - self._sensor_type = sensor_type + self._attr_device_class = try_parse_enum(BinarySensorDeviceClass, sensor_type) self.inverting = inverting - @property - def device_class(self): - """Return the class of this sensor.""" - return self._sensor_type - - @property - def is_on(self): - """Return true if the binary sensor is on/open.""" - return self._state - def on_ihc_change(self, ihc_id, value): """IHC resource has changed.""" if self.inverting: - self._state = not value + self._attr_is_on = not value else: - self._state = value + self._attr_is_on = value self.schedule_update_ha_state() diff --git a/homeassistant/components/ihc/manifest.json b/homeassistant/components/ihc/manifest.json index 13bf8bb6d8..2400206c3a 100644 --- a/homeassistant/components/ihc/manifest.json +++ b/homeassistant/components/ihc/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/ihc", "iot_class": "local_push", "loggers": ["ihcsdk"], - "requirements": ["defusedxml==0.7.1", "ihcsdk==2.7.6"] + "requirements": ["defusedxml==0.7.1", "ihcsdk==2.8.5"] } diff --git a/homeassistant/components/ihc/sensor.py b/homeassistant/components/ihc/sensor.py index d3c38687ca..c1210a358d 100644 --- a/homeassistant/components/ihc/sensor.py +++ b/homeassistant/components/ihc/sensor.py @@ -51,29 +51,11 @@ class IHCSensor(IHCDevice, SensorEntity): ) -> None: """Initialize the IHC sensor.""" super().__init__(ihc_controller, controller_id, name, ihc_id, product) - self._state = None - self._unit_of_measurement = unit - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return ( - SensorDeviceClass.TEMPERATURE - if self._unit_of_measurement in TEMPERATURE_UNITS - else None - ) - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_native_unit_of_measurement = unit + if unit in TEMPERATURE_UNITS: + self._attr_device_class = SensorDeviceClass.TEMPERATURE def on_ihc_change(self, ihc_id, value): """Handle IHC resource change.""" - self._state = value + self._attr_native_value = value self.schedule_update_ha_state() diff --git a/homeassistant/components/ihc/switch.py b/homeassistant/components/ihc/switch.py index 8e8edb0b7f..d4593dad57 100644 --- a/homeassistant/components/ihc/switch.py +++ b/homeassistant/components/ihc/switch.py @@ -59,12 +59,6 @@ class IHCSwitch(IHCDevice, SwitchEntity): super().__init__(ihc_controller, controller_id, name, ihc_id, product) self._ihc_off_id = ihc_off_id self._ihc_on_id = ihc_on_id - self._state = False - - @property - def is_on(self): - """Return true if switch is on.""" - return self._state async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" @@ -82,5 +76,5 @@ class IHCSwitch(IHCDevice, SwitchEntity): def on_ihc_change(self, ihc_id, value): """Handle IHC resource change.""" - self._state = value + self._attr_is_on = value self.schedule_update_ha_state() diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index 50f768915e..17c40cfc87 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -44,7 +44,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: image_dir = pathlib.Path(hass.config.path("image")) hass.data[DOMAIN] = storage_collection = ImageStorageCollection(hass, image_dir) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, "image", "image", @@ -57,7 +57,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class ImageStorageCollection(collection.StorageCollection): +class ImageStorageCollection(collection.DictStorageCollection): """Image collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -67,7 +67,6 @@ class ImageStorageCollection(collection.StorageCollection): """Initialize media storage collection.""" super().__init__( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), ) self.async_add_listener(self._change_listener) self.image_dir = image_dir @@ -126,11 +125,11 @@ class ImageStorageCollection(collection.StorageCollection): async def _update_data( self, - data: dict[str, Any], + item: dict[str, Any], update_data: dict[str, Any], ) -> dict[str, Any]: """Return a new updated data object.""" - return {**data, **self.UPDATE_SCHEMA(update_data)} + return {**item, **self.UPDATE_SCHEMA(update_data)} async def _change_listener( self, diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index b53fb8bb29..947c3cb67d 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["pillow==9.4.0"] + "requirements": ["pillow==9.5.0"] } diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index de1ac1e5d6..71b09048e6 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -3,28 +3,45 @@ from __future__ import annotations import asyncio from collections.abc import Mapping +import ssl from typing import Any from aioimaplib import AioImapException import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +from homeassistant.util.ssl import SSLCipherList from .const import ( CONF_CHARSET, CONF_FOLDER, CONF_SEARCH, CONF_SERVER, + CONF_SSL_CIPHER_LIST, DEFAULT_PORT, DOMAIN, ) from .coordinator import connect_to_server from .errors import InvalidAuth, InvalidFolder -STEP_USER_DATA_SCHEMA = vol.Schema( +CIPHER_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=list(SSLCipherList), + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_SSL_CIPHER_LIST, + ) +) + +CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, @@ -35,6 +52,18 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str, } ) +CONFIG_SCHEMA_ADVANCED = { + vol.Optional( + CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT + ): CIPHER_SELECTOR +} + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_FOLDER, default="INBOX"): str, + vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str, + } +) async def validate_input(user_input: dict[str, Any]) -> dict[str, str]: @@ -52,6 +81,11 @@ async def validate_input(user_input: dict[str, Any]) -> dict[str, str]: errors[CONF_USERNAME] = errors[CONF_PASSWORD] = "invalid_auth" except InvalidFolder: errors[CONF_FOLDER] = "invalid_folder" + except ssl.SSLError: + # The aioimaplib library 1.0.1 does not raise an ssl.SSLError correctly, but is logged + # See https://github.com/bamthomas/aioimaplib/issues/91 + # This handler is added to be able to supply a better error message + errors["base"] = "ssl_error" except (asyncio.TimeoutError, AioImapException, ConnectionRefusedError): errors["base"] = "cannot_connect" else: @@ -69,20 +103,44 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 _reauth_entry: config_entries.ConfigEntry | None + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle the import from imap_email_content integration.""" + data = CONFIG_SCHEMA( + { + CONF_SERVER: user_input[CONF_SERVER], + CONF_PORT: user_input[CONF_PORT], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_FOLDER: user_input[CONF_FOLDER], + } + ) + self._async_abort_entries_match( + { + key: data[key] + for key in (CONF_USERNAME, CONF_SERVER, CONF_FOLDER, CONF_SEARCH) + } + ) + title = user_input[CONF_NAME] + if await validate_input(data): + raise AbortFlow("cannot_connect") + return self.async_create_entry(title=title, data=data) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" + + schema = CONFIG_SCHEMA + if self.show_advanced_options: + schema = schema.extend(CONFIG_SCHEMA_ADVANCED) + if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) + return self.async_show_form(step_id="user", data_schema=schema) self._async_abort_entries_match( { - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_FOLDER: user_input[CONF_FOLDER], - CONF_SEARCH: user_input[CONF_SEARCH], + key: user_input[key] + for key in (CONF_USERNAME, CONF_SERVER, CONF_FOLDER, CONF_SEARCH) } ) @@ -91,7 +149,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=title, data=user_input) - schema = self.add_suggested_values_to_schema(STEP_USER_DATA_SCHEMA, user_input) + schema = self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: @@ -128,3 +186,53 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlow: + """Get the options flow for this handler.""" + return OptionsFlow(config_entry) + + +class OptionsFlow(config_entries.OptionsFlowWithConfigEntry): + """Option flow handler.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + errors: dict[str, str] | None = None + entry_data: dict[str, Any] = dict(self._config_entry.data) + if user_input is not None: + try: + self._async_abort_entries_match( + { + CONF_SERVER: self._config_entry.data[CONF_SERVER], + CONF_USERNAME: self._config_entry.data[CONF_USERNAME], + CONF_FOLDER: user_input[CONF_FOLDER], + CONF_SEARCH: user_input[CONF_SEARCH], + } + if user_input + else None + ) + except AbortFlow as err: + errors = {"base": err.reason} + else: + entry_data.update(user_input) + errors = await validate_input(entry_data) + if not errors: + self.hass.config_entries.async_update_entry( + self.config_entry, data=entry_data + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload( + self.config_entry.entry_id + ) + ) + return self.async_create_entry(data={}) + + schema = self.add_suggested_values_to_schema(OPTIONS_SCHEMA, entry_data) + + return self.async_show_form(step_id="init", data_schema=schema, errors=errors) diff --git a/homeassistant/components/imap/const.py b/homeassistant/components/imap/const.py index 080f7bf676..a1ca586b48 100644 --- a/homeassistant/components/imap/const.py +++ b/homeassistant/components/imap/const.py @@ -8,5 +8,6 @@ CONF_SERVER: Final = "server" CONF_FOLDER: Final = "folder" CONF_SEARCH: Final = "search" CONF_CHARSET: Final = "charset" +CONF_SSL_CIPHER_LIST: Final = "ssl_cipher_list" DEFAULT_PORT: Final = 993 diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index e9bbb62301..07b55dc478 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -3,40 +3,140 @@ from __future__ import annotations import asyncio from collections.abc import Mapping -from datetime import timedelta +from datetime import datetime, timedelta +import email import logging from typing import Any -from aioimaplib import AUTH, IMAP4_SSL, SELECTED, AioImapException +from aioimaplib import AUTH, IMAP4_SSL, NONAUTH, SELECTED, AioImapException import async_timeout from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONTENT_TYPE_TEXT_PLAIN, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.ssl import SSLCipherList, client_context -from .const import CONF_CHARSET, CONF_FOLDER, CONF_SEARCH, CONF_SERVER, DOMAIN +from .const import ( + CONF_CHARSET, + CONF_FOLDER, + CONF_SEARCH, + CONF_SERVER, + CONF_SSL_CIPHER_LIST, + DOMAIN, +) from .errors import InvalidAuth, InvalidFolder _LOGGER = logging.getLogger(__name__) BACKOFF_TIME = 10 +EVENT_IMAP = "imap_content" + async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" - client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT]) + ssl_context = client_context( + ssl_cipher_list=data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT) + ) + client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT], ssl_context=ssl_context) + await client.wait_hello_from_server() - await client.login(data[CONF_USERNAME], data[CONF_PASSWORD]) - if client.protocol.state != AUTH: + + if client.protocol.state == NONAUTH: + await client.login(data[CONF_USERNAME], data[CONF_PASSWORD]) + if client.protocol.state not in {AUTH, SELECTED}: raise InvalidAuth("Invalid username or password") - await client.select(data[CONF_FOLDER]) + if client.protocol.state == AUTH: + await client.select(data[CONF_FOLDER]) if client.protocol.state != SELECTED: raise InvalidFolder(f"Folder {data[CONF_FOLDER]} is invalid") return client +class ImapMessage: + """Class to parse an RFC822 email message.""" + + def __init__(self, raw_message: bytes) -> None: + """Initialize IMAP message.""" + self.email_message = email.message_from_bytes(raw_message) + + @property + 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] + return header_base + + @property + def date(self) -> datetime | None: + """Get the date the email was sent.""" + # See https://www.rfc-editor.org/rfc/rfc2822#section-3.3 + 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" + ) + + @property + def sender(self) -> str: + """Get the parsed message sender from the email.""" + return str(email.utils.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) + + @property + def text(self) -> str: + """Get the message text from the email. + + Will look for text/plain or use text/html if not found. + """ + message_text = None + message_html = None + message_untyped_text = None + + for part in self.email_message.walk(): + if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: + if message_text is None: + message_text = part.get_payload() + elif part.get_content_type() == "text/html": + if message_html is None: + message_html = part.get_payload() + elif ( + part.get_content_type().startswith("text") + and message_untyped_text is None + ): + message_untyped_text = part.get_payload() + + if message_text is not None: + return message_text + + if message_html is not None: + return message_html + + if message_untyped_text is not None: + return message_untyped_text + + return self.email_message.get_payload() + + class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Base class for imap client.""" @@ -50,6 +150,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): ) -> None: """Initiate imap client.""" self.imap_client = imap_client + self._last_message_id: str | None = None super().__init__( hass, _LOGGER, @@ -65,8 +166,31 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): if self.imap_client is None: self.imap_client = await connect_to_server(self.config_entry.data) + async def _async_process_event(self, last_message_id: str) -> None: + """Send a event for the last message if the last message was changed.""" + response = await self.imap_client.fetch(last_message_id, "BODY.PEEK[]") + if response.result == "OK": + message = ImapMessage(response.lines[1]) + data = { + "server": self.config_entry.data[CONF_SERVER], + "username": self.config_entry.data[CONF_USERNAME], + "search": self.config_entry.data[CONF_SEARCH], + "folder": self.config_entry.data[CONF_FOLDER], + "date": message.date, + "text": message.text, + "sender": message.sender, + "subject": message.subject, + "headers": message.headers, + } + self.hass.bus.fire(EVENT_IMAP, data) + _LOGGER.debug( + "Message processed, sender: %s, subject: %s", + message.sender, + message.subject, + ) + async def _async_fetch_number_of_messages(self) -> int | None: - """Fetch number of messages.""" + """Fetch last message and messages count.""" await self._async_reconnect_if_needed() await self.imap_client.noop() result, lines = await self.imap_client.search( @@ -77,7 +201,21 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): raise UpdateFailed( f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) - return len(lines[0].split()) + count: int = len(message_ids := lines[0].split()) + last_message_id = ( + str(message_ids[-1:][0], encoding=self.config_entry.data[CONF_CHARSET]) + if count + else None + ) + if ( + count + and last_message_id is not None + and self._last_message_id != last_message_id + ): + self._last_message_id = last_message_id + await self._async_process_event(last_message_id) + + return count async def _cleanup(self, log_error: bool = False) -> None: """Close resources.""" @@ -88,10 +226,9 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): await self.imap_client.stop_wait_server_push() await self.imap_client.close() await self.imap_client.logout() - except (AioImapException, asyncio.TimeoutError) as ex: + except (AioImapException, asyncio.TimeoutError): if log_error: - self.async_set_update_error(ex) - _LOGGER.warning("Error while cleaning up imap connection") + _LOGGER.debug("Error while cleaning up imap connection") self.imap_client = None async def shutdown(self, *_) -> None: @@ -115,18 +252,18 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): UpdateFailed, asyncio.TimeoutError, ) as ex: - self.async_set_update_error(ex) await self._cleanup() + self.async_set_update_error(ex) raise UpdateFailed() from ex except InvalidFolder as ex: _LOGGER.warning("Selected mailbox folder is invalid") - self.async_set_update_error(ex) await self._cleanup() + self.async_set_update_error(ex) raise ConfigEntryError("Selected mailbox folder is invalid.") from ex except InvalidAuth as ex: _LOGGER.warning("Username or password incorrect, starting reauthentication") - self.async_set_update_error(ex) await self._cleanup() + self.async_set_update_error(ex) raise ConfigEntryAuthFailed() from ex @@ -155,30 +292,30 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): try: number_of_messages = await self._async_fetch_number_of_messages() except InvalidAuth as ex: + await self._cleanup() _LOGGER.warning( "Username or password incorrect, starting reauthentication" ) self.config_entry.async_start_reauth(self.hass) self.async_set_update_error(ex) - await self._cleanup() await asyncio.sleep(BACKOFF_TIME) except InvalidFolder as ex: _LOGGER.warning("Selected mailbox folder is invalid") + await self._cleanup() self.config_entry.async_set_state( self.hass, ConfigEntryState.SETUP_ERROR, "Selected mailbox folder is invalid.", ) self.async_set_update_error(ex) - await self._cleanup() await asyncio.sleep(BACKOFF_TIME) except ( UpdateFailed, AioImapException, asyncio.TimeoutError, ) as ex: - self.async_set_update_error(ex) await self._cleanup() + self.async_set_update_error(ex) await asyncio.sleep(BACKOFF_TIME) continue else: @@ -191,15 +328,13 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): await idle except (AioImapException, asyncio.TimeoutError): - _LOGGER.warning( + _LOGGER.debug( "Lost %s (will attempt to reconnect after %s s)", self.config_entry.data[CONF_SERVER], BACKOFF_TIME, ) - self.async_set_update_error(UpdateFailed("Lost connection")) await self._cleanup() await asyncio.sleep(BACKOFF_TIME) - continue async def shutdown(self, *_) -> None: """Close resources.""" diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index aeaf7b6fe9..39dfc6c0d4 100644 --- a/homeassistant/components/imap/manifest.json +++ b/homeassistant/components/imap/manifest.json @@ -1,7 +1,7 @@ { "domain": "imap", "name": "IMAP", - "codeowners": ["@engrbm87"], + "codeowners": ["@engrbm87", "@jbouwh"], "config_flow": true, "dependencies": ["repairs"], "documentation": "https://www.home-assistant.io/integrations/imap", diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 4dc0c0fffb..776abc174a 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_USERNAME +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -41,10 +41,6 @@ class ImapSensor( ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - # To be removed when YAML import is removed - if CONF_NAME in coordinator.config_entry.data: - self._attr_name = coordinator.config_entry.data[CONF_NAME] - self._attr_has_entity_name = False 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 2fedef55f6..e50370dd9b 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -9,7 +9,8 @@ "port": "[%key:common::config_flow::data::port%]", "charset": "Character set", "folder": "Folder", - "search": "IMAP search" + "search": "IMAP search", + "ssl_cipher_list": "SSL cipher list (Advanced)" } }, "reauth_confirm": { @@ -24,11 +25,40 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_charset": "The specified charset is not supported", - "invalid_search": "The selected search is invalid" + "invalid_folder": "The selected folder is invalid", + "invalid_search": "The selected search is invalid", + "ssl_error": "An SSL error occurred. Change SSL cipher list and try again" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "options": { + "step": { + "init": { + "data": { + "folder": "[%key:component::imap::config::step::user::data::folder%]", + "search": "[%key:component::imap::config::step::user::data::search%]" + } + } + }, + "error": { + "already_configured": "An entry with these folder and search options already exists", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_charset": "[%key:component::imap::config::error::invalid_charset%]", + "invalid_folder": "[%key:component::imap::config::error::invalid_folder%]", + "invalid_search": "[%key:component::imap::config::error::invalid_search%]" + } + }, + "selector": { + "ssl_cipher_list": { + "options": { + "python_default": "Default settings", + "modern": "Modern ciphers", + "intermediate": "Intermediate ciphers" + } + } } } diff --git a/homeassistant/components/imap_email_content/__init__.py b/homeassistant/components/imap_email_content/__init__.py index 263f57a3a9..1a148f4591 100644 --- a/homeassistant/components/imap_email_content/__init__.py +++ b/homeassistant/components/imap_email_content/__init__.py @@ -1 +1,12 @@ """The imap_email_content component.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up imap_email_content.""" + return True diff --git a/homeassistant/components/imap_email_content/const.py b/homeassistant/components/imap_email_content/const.py new file mode 100644 index 0000000000..5f1c653030 --- /dev/null +++ b/homeassistant/components/imap_email_content/const.py @@ -0,0 +1,13 @@ +"""Constants for the imap email content integration.""" + +DOMAIN = "imap_email_content" + +CONF_SERVER = "server" +CONF_SENDERS = "senders" +CONF_FOLDER = "folder" + +ATTR_FROM = "from" +ATTR_BODY = "body" +ATTR_SUBJECT = "subject" + +DEFAULT_PORT = 993 diff --git a/homeassistant/components/imap_email_content/manifest.json b/homeassistant/components/imap_email_content/manifest.json index 2e510a8c42..b7d0589b83 100644 --- a/homeassistant/components/imap_email_content/manifest.json +++ b/homeassistant/components/imap_email_content/manifest.json @@ -2,6 +2,7 @@ "domain": "imap_email_content", "name": "IMAP Email Content", "codeowners": [], + "dependencies": ["imap"], "documentation": "https://www.home-assistant.io/integrations/imap_email_content", "iot_class": "cloud_push" } diff --git a/homeassistant/components/imap_email_content/repairs.py b/homeassistant/components/imap_email_content/repairs.py new file mode 100644 index 0000000000..f19b049904 --- /dev/null +++ b/homeassistant/components/imap_email_content/repairs.py @@ -0,0 +1,173 @@ +"""Repair flow for imap email content integration.""" + +from typing import Any + +import voluptuous as vol +import yaml + +from homeassistant import data_entry_flow +from homeassistant.components.imap import DOMAIN as IMAP_DOMAIN +from homeassistant.components.repairs import RepairsFlow +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VALUE_TEMPLATE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_FOLDER, CONF_SENDERS, CONF_SERVER, DOMAIN + + +async def async_process_issue(hass: HomeAssistant, config: ConfigType) -> None: + """Register an issue and suggest new config.""" + + name: str = config.get(CONF_NAME) or config[CONF_USERNAME] + + issue_id = ( + f"{name}_{config[CONF_USERNAME]}_{config[CONF_SERVER]}_{config[CONF_FOLDER]}" + ) + + if CONF_VALUE_TEMPLATE in config: + template: str = config[CONF_VALUE_TEMPLATE].template + template = template.replace("subject", 'trigger.event.data["subject"]') + template = template.replace("from", 'trigger.event.data["sender"]') + template = template.replace("date", 'trigger.event.data["date"]') + template = template.replace("body", 'trigger.event.data["text"]') + else: + template = '{{ trigger.event.data["subject"] }}' + + template_sensor_config: ConfigType = { + "template": [ + { + "trigger": [ + { + "id": "custom_event", + "platform": "event", + "event_type": "imap_content", + "event_data": {"sender": config[CONF_SENDERS][0]}, + } + ], + "sensor": [ + { + "state": template, + "name": name, + } + ], + } + ] + } + + data = { + CONF_SERVER: config[CONF_SERVER], + CONF_PORT: config[CONF_PORT], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_FOLDER: config[CONF_FOLDER], + } + data[CONF_VALUE_TEMPLATE] = template + data[CONF_NAME] = name + placeholders = {"yaml_example": yaml.dump(template_sensor_config)} + placeholders.update(data) + + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version="2023.10.0", + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key="migration", + translation_placeholders=placeholders, + data=data, + ) + + +class DeprecationRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, issue_id: str, config: ConfigType) -> None: + """Create flow.""" + self._name: str = config[CONF_NAME] + self._config: dict[str, Any] = config + self._issue_id = issue_id + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_start() + + @callback + def _async_get_placeholders(self) -> dict[str, str] | None: + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(self.handler, self.issue_id): + description_placeholders = issue.translation_placeholders + + return description_placeholders + + async def async_step_start( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Wait for the user to start the config migration.""" + placeholders = self._async_get_placeholders() + if user_input is None: + return self.async_show_form( + step_id="start", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + ) + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + placeholders = self._async_get_placeholders() + if user_input is not None: + user_input[CONF_NAME] = self._name + result = await self.hass.config_entries.flow.async_init( + IMAP_DOMAIN, context={"source": SOURCE_IMPORT}, data=self._config + ) + if result["type"] == FlowResultType.ABORT: + ir.async_delete_issue(self.hass, DOMAIN, self._issue_id) + ir.async_create_issue( + self.hass, + DOMAIN, + self._issue_id, + breaks_in_ha_version="2023.10.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecation", + translation_placeholders=placeholders, + data=self._config, + learn_more_url="https://www.home-assistant.io/integrations/imap/#using-events", + ) + return self.async_abort(reason=result["reason"]) + return self.async_create_entry( + title="", + data={}, + ) + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None], +) -> RepairsFlow: + """Create flow.""" + return DeprecationRepairFlow(issue_id, data) diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py index 53cb921860..1df207e296 100644 --- a/homeassistant/components/imap_email_content/sensor.py +++ b/homeassistant/components/imap_email_content/sensor.py @@ -26,18 +26,19 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.ssl import client_context +from .const import ( + ATTR_BODY, + ATTR_FROM, + ATTR_SUBJECT, + CONF_FOLDER, + CONF_SENDERS, + CONF_SERVER, + DEFAULT_PORT, +) +from .repairs import async_process_issue + _LOGGER = logging.getLogger(__name__) -CONF_SERVER = "server" -CONF_SENDERS = "senders" -CONF_FOLDER = "folder" - -ATTR_FROM = "from" -ATTR_BODY = "body" -ATTR_SUBJECT = "subject" - -DEFAULT_PORT = 993 - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME): cv.string, @@ -79,6 +80,8 @@ def setup_platform( value_template, ) + hass.add_job(async_process_issue, hass, config) + if sensor.connected: add_entities([sensor], True) diff --git a/homeassistant/components/imap_email_content/strings.json b/homeassistant/components/imap_email_content/strings.json new file mode 100644 index 0000000000..f84435971b --- /dev/null +++ b/homeassistant/components/imap_email_content/strings.json @@ -0,0 +1,27 @@ +{ + "issues": { + "deprecation": { + "title": "The IMAP email content integration is deprecated", + "description": "The IMAP email content integration is deprecated. Your IMAP server configuration was already migrated to to the [imap integration](https://my.home-assistant.io/redirect/config_flow_start?domain=imap). To set up a sensor for the IMAP email content, set up a template sensor with the config:\n\n```yaml\n{yaml_example}```\n\nPlease remove the deprecated `imap_email_plaform` sensor configuration from your `configuration.yaml`.\n\nNote that the event filter only filters on the first of the configured allowed senders, customize the filter if needed.\n\nYou can skip this part if you have already set up a template sensor." + }, + "migration": { + "title": "The IMAP email content integration needs attention", + "fix_flow": { + "step": { + "start": { + "title": "Migrate your IMAP email configuration", + "description": "The IMAP email content integration is deprecated. Your IMAP server configuration can be migrated automatically to the [imap integration](https://my.home-assistant.io/redirect/config_flow_start?domain=imap), this will enable using a custom `imap` event trigger. To set up a sensor that has an IMAP content state, a template sensor can be used. Remove the `imap_email_plaform` sensor configuration from your `configuration.yaml` after migration.\n\nSubmit to start migration of your IMAP server configuration to the `imap` integration." + }, + "confirm": { + "title": "Your IMAP server settings will be migrated", + "description": "In this step an `imap` config entry will be set up with the following configuration:\n\n```text\nServer\t{server}\nPort\t{port}\nUsername\t{username}\nPassword\t*****\nFolder\t{folder}\n```\n\nSee also: (https://www.home-assistant.io/integrations/imap/)\n\nFitering configuration on allowed `sender` is part of the template sensor config that can copied and placed in your `configuration.yaml.\n\nNote that the event filter only filters on the first of the configured allowed senders, customize the filter if needed.\n\n```yaml\n{yaml_example}```\nDo not forget to cleanup the your `configuration.yaml` after migration.\n\nSubmit to migrate your IMAP server configuration to an `imap` configuration entry." + } + }, + "abort": { + "already_configured": "The IMAP server config was already migrated to the imap integration. Remove the `imap_email_plaform` sensor configuration from your `configuration.yaml`.", + "cannot_connect": "Migration failed. Failed to connect to the IMAP server. Perform a manual migration." + } + } + } + } +} diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index a8b221e493..33cb4b9e57 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -65,7 +65,7 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -class InputBooleanStorageCollection(collection.StorageCollection): +class InputBooleanStorageCollection(collection.DictStorageCollection): """Input boolean collection stored in storage.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -79,10 +79,10 @@ class InputBooleanStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data @bind_hass @@ -110,7 +110,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = InputBooleanStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -122,7 +121,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) diff --git a/homeassistant/components/input_boolean/strings.json b/homeassistant/components/input_boolean/strings.json index 8294d72875..d8e1e133f5 100644 --- a/homeassistant/components/input_boolean/strings.json +++ b/homeassistant/components/input_boolean/strings.json @@ -6,6 +6,15 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + } } } } diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py index f8ff916421..8a1f078543 100644 --- a/homeassistant/components/input_button/__init__.py +++ b/homeassistant/components/input_button/__init__.py @@ -56,7 +56,7 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -class InputButtonStorageCollection(collection.StorageCollection): +class InputButtonStorageCollection(collection.DictStorageCollection): """Input button collection stored in storage.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -70,10 +70,10 @@ class InputButtonStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return cast(str, info[CONF_NAME]) - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -95,7 +95,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = InputButtonStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -107,7 +106,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) diff --git a/homeassistant/components/input_button/strings.json b/homeassistant/components/input_button/strings.json new file mode 100644 index 0000000000..cfd616fd5e --- /dev/null +++ b/homeassistant/components/input_button/strings.json @@ -0,0 +1,17 @@ +{ + "title": "Input button", + "entity_component": { + "_": { + "name": "[%key:component::input_button::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + } + } + } + } +} diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 34ded40d58..c51c0fdd67 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -148,7 +148,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = DateTimeStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -160,7 +159,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) @@ -204,7 +203,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class DateTimeStorageCollection(collection.StorageCollection): +class DateTimeStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, has_date_or_time)) @@ -218,10 +217,10 @@ class DateTimeStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputDatetime(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/input_datetime/strings.json b/homeassistant/components/input_datetime/strings.json index 8d51025070..0c3a4b0b0d 100644 --- a/homeassistant/components/input_datetime/strings.json +++ b/homeassistant/components/input_datetime/strings.json @@ -1 +1,38 @@ -{ "title": "Input datetime" } +{ + "title": "Input datetime", + "entity_component": { + "_": { + "name": "[%key:component::input_datetime::title%]", + "state_attributes": { + "day": { + "name": "Day" + }, + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "hour": { + "name": "Hour" + }, + "minute": { + "name": "Minute" + }, + "month": { + "name": "Month" + }, + "second": { + "name": "Second" + }, + "timestamp": { + "name": "Timestamp" + }, + "year": { + "name": "Year" + } + } + } + } +} diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 05d4a4f8b9..061b388ace 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -125,7 +125,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = NumberStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -137,7 +136,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) @@ -171,7 +170,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class NumberStorageCollection(collection.StorageCollection): +class NumberStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_number)) @@ -185,7 +184,7 @@ class NumberStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data. A past bug caused frontend to add initial value to all input numbers. @@ -201,10 +200,10 @@ class NumberStorageCollection(collection.StorageCollection): return data - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputNumber(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/input_number/strings.json b/homeassistant/components/input_number/strings.json index 35bbbebbdd..11ed2f8bf1 100644 --- a/homeassistant/components/input_number/strings.json +++ b/homeassistant/components/input_number/strings.json @@ -1 +1,37 @@ -{ "title": "Input number" } +{ + "title": "Input number", + "entity_component": { + "_": { + "name": "[%key:component::input_number::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "initial": { + "name": "Initial value" + }, + "max": { + "name": "[%key:component::number::entity_component::_::state_attributes::max::name%]" + }, + "min": { + "name": "[%key:component::number::entity_component::_::state_attributes::min::name%]" + }, + "mode": { + "name": "[%key:component::number::entity_component::_::state_attributes::mode::name%]", + "state": { + "auto": "[%key:component::number::entity_component::_::state_attributes::mode::state::auto%]", + "box": "[%key:component::number::entity_component::_::state_attributes::mode::state::box%]", + "slider": "[%key:component::number::entity_component::_::state_attributes::mode::state::slider%]" + } + }, + "step": { + "name": "[%key:component::number::entity_component::_::state_attributes::step::name%]" + } + } + } + } +} diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 9e4833954d..186ab84fb8 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -156,7 +156,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: InputSelectStore( hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR ), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -168,7 +167,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) @@ -232,7 +231,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class InputSelectStorageCollection(collection.StorageCollection): +class InputSelectStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_select)) @@ -247,11 +246,11 @@ class InputSelectStorageCollection(collection.StorageCollection): return cast(str, info[CONF_NAME]) async def _update_data( - self, data: dict[str, Any], update_data: dict[str, Any] + self, item: dict[str, Any], update_data: dict[str, Any] ) -> dict[str, Any]: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): diff --git a/homeassistant/components/input_select/strings.json b/homeassistant/components/input_select/strings.json index c3cd5c0c71..f0dead7a1d 100644 --- a/homeassistant/components/input_select/strings.json +++ b/homeassistant/components/input_select/strings.json @@ -1 +1,20 @@ -{ "title": "Input select" } +{ + "title": "Input select", + "entity_component": { + "_": { + "name": "[%key:component::input_select::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "options": { + "name": "[%key:component::select::entity_component::_::state_attributes::options::name%]" + } + } + } + } +} diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 6ebfdcd70d..efd58e38e7 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -125,7 +125,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = InputTextStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -137,7 +136,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) @@ -165,7 +164,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class InputTextStorageCollection(collection.StorageCollection): +class InputTextStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_text)) @@ -179,10 +178,10 @@ class InputTextStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputText(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/input_text/strings.json b/homeassistant/components/input_text/strings.json index dac5995aca..d713c395b6 100644 --- a/homeassistant/components/input_text/strings.json +++ b/homeassistant/components/input_text/strings.json @@ -1 +1,33 @@ -{ "title": "Input text" } +{ + "title": "Input text", + "entity_component": { + "_": { + "name": "[%key:component::input_text::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "max": { + "name": "[%key:component::text::entity_component::_::state_attributes::max::name%]" + }, + "min": { + "name": "[%key:component::text::entity_component::_::state_attributes::min::name%]" + }, + "mode": { + "name": "[%key:component::text::entity_component::_::state_attributes::mode::name%]", + "state": { + "text": "[%key:component::text::entity_component::_::state_attributes::mode::state::text%]", + "password": "[%key:component::text::entity_component::_::state_attributes::mode::state::password%]" + } + }, + "pattern": { + "name": "[%key:component::text::entity_component::_::state_attributes::pattern::name%]" + } + } + } + } +} diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index af9396399a..08adce918c 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.0", + "pyinsteon==1.4.2", "insteon-frontend-home-assistant==0.3.4" ], "usb": [ diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 54e50b7b1d..d199b8808d 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -197,6 +197,15 @@ class IntegrationSensor(RestoreEntity, SensorEntity): old_state: State | None = event.data.get("old_state") new_state: State | None = event.data.get("new_state") + if ( + source_state := self.hass.states.get(self._sensor_source_id) + ) is None or source_state.state == STATE_UNAVAILABLE: + self._attr_available = False + self.async_write_ha_state() + return + + self._attr_available = True + if new_state is None or new_state.state in ( STATE_UNKNOWN, STATE_UNAVAILABLE, diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index 7ac30cc5a2..70b53b80d9 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -30,7 +30,7 @@ CONF_DIRECTION = "direction" CONF_STOPS_AT = "stops_at" DEFAULT_NAME = "Next Train" -ICON = "mdi:train" + SCAN_INTERVAL = timedelta(minutes=2) TIME_STR_FORMAT = "%H:%M" @@ -76,6 +76,7 @@ class IrishRailTransportSensor(SensorEntity): """Implementation of an irish rail public transport sensor.""" _attr_attribution = "Data provided by Irish Rail" + _attr_icon = "mdi:train" def __init__(self, data, station, direction, destination, stops_at, name): """Initialize the sensor.""" @@ -128,11 +129,6 @@ class IrishRailTransportSensor(SensorEntity): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and update the states.""" self.data.update() diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 95a7db632b..d8810b0ad4 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.async_on_unload( config_entry.add_update_listener(async_options_updated) ) - hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 3612e87f8e..2f60490d8c 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -25,19 +25,15 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from .const import ( _LOGGER, CONF_IGNORE_STRING, CONF_NETWORK, - CONF_RESTORE_LIGHT_STATE, CONF_SENSOR_STRING, CONF_TLS_VER, CONF_VAR_SENSOR_STRING, DEFAULT_IGNORE_STRING, - DEFAULT_RESTORE_LIGHT_STATE, DEFAULT_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING, DOMAIN, @@ -55,90 +51,16 @@ from .services import async_setup_services, async_unload_services from .util import _async_cleanup_registry_entries CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): cv.url, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_TLS_VER): vol.Coerce(float), - vol.Optional( - CONF_IGNORE_STRING, default=DEFAULT_IGNORE_STRING - ): cv.string, - vol.Optional( - CONF_SENSOR_STRING, default=DEFAULT_SENSOR_STRING - ): cv.string, - vol.Optional( - CONF_VAR_SENSOR_STRING, default=DEFAULT_VAR_SENSOR_STRING - ): cv.string, - vol.Required( - CONF_RESTORE_LIGHT_STATE, default=DEFAULT_RESTORE_LIGHT_STATE - ): bool, - }, - ) - }, - ), + cv.deprecated(DOMAIN), extra=vol.ALLOW_EXTRA, ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the isy994 integration from YAML.""" - isy_config: ConfigType | None = config.get(DOMAIN) - hass.data.setdefault(DOMAIN, {}) - - if not isy_config: - return True - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.5.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - # Only import if we haven't before. - config_entry = _async_find_matching_config_entry(hass) - if not config_entry: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=dict(isy_config), - ) - ) - return True - - # Update the entry based on the YAML configuration, in case it changed. - hass.config_entries.async_update_entry(config_entry, data=dict(isy_config)) - return True - - -@callback -def _async_find_matching_config_entry( - hass: HomeAssistant, -) -> config_entries.ConfigEntry | None: - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.source == config_entries.SOURCE_IMPORT: - return entry - return None - - async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry ) -> bool: """Set up the ISY 994 integration.""" - # As there currently is no way to import options from yaml - # when setting up a config entry, we fallback to adding - # the options to the config entry and pull them out here if - # they are missing from the options - _async_import_options_from_data_if_missing(hass, entry) - + hass.data.setdefault(DOMAIN, {}) isy_data = hass.data[DOMAIN][entry.entry_id] = IsyData() isy_config = entry.data @@ -268,25 +190,6 @@ async def _async_update_listener( await hass.config_entries.async_reload(entry.entry_id) -@callback -def _async_import_options_from_data_if_missing( - hass: HomeAssistant, entry: config_entries.ConfigEntry -) -> None: - options = dict(entry.options) - modified = False - for importable_option in ( - CONF_IGNORE_STRING, - CONF_SENSOR_STRING, - CONF_RESTORE_LIGHT_STATE, - ): - if importable_option not in entry.options and importable_option in entry.data: - options[importable_option] = entry.data[importable_option] - modified = True - - if modified: - hass.config_entries.async_update_entry(entry, options=options) - - @callback def _async_get_or_create_isy_device_in_registry( hass: HomeAssistant, entry: config_entries.ConfigEntry, isy: ISY diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index c9dd0f8266..621b17f096 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -400,12 +400,18 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity): Insteon leak sensors set their primary node to On when the state is DRY, not WET, so we invert the binary state if the user indicates that it is a moisture sensor. + + Dusk/Dawn sensors set their node to On when DUSK, not light detected, + so this is inverted as well. """ if self._computed_state is None: - # Do this first so we don't invert None on moisture sensors + # Do this first so we don't invert None on moisture or light sensors return None - if self.device_class == BinarySensorDeviceClass.MOISTURE: + if self.device_class in ( + BinarySensorDeviceClass.LIGHT, + BinarySensorDeviceClass.MOISTURE, + ): return not self._computed_state return self._computed_state diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 0b61b14d9b..d6bbf236c1 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -168,10 +168,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle import.""" - return await self.async_step_user(user_input) - async def _async_set_unique_id_or_update( self, isy_mac: str, ip_address: str, port: int | None ) -> None: diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 37ae1a82b9..686ffdb72f 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -335,8 +335,8 @@ UOM_FRIENDLY_NAME = { "18": UnitOfLength.FEET, "19": UnitOfTime.HOURS, "20": UnitOfTime.HOURS, - "21": "%AH", - "22": "%RH", + "21": PERCENTAGE, + "22": PERCENTAGE, "23": UnitOfPressure.INHG, "24": UnitOfVolumetricFlux.INCHES_PER_HOUR, UOM_INDEX: UOM_INDEX, # Index type. Use "node.formatted" for value diff --git a/homeassistant/components/isy994/cover.py b/homeassistant/components/isy994/cover.py index 97f3c66977..4504cde713 100644 --- a/homeassistant/components/isy994/cover.py +++ b/homeassistant/components/isy994/cover.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import _LOGGER, DOMAIN, UOM_8_BIT_RANGE, UOM_BARRIER +from .const import _LOGGER, DOMAIN, UOM_8_BIT_RANGE from .entity import ISYNodeEntity, ISYProgramEntity @@ -63,8 +63,7 @@ class ISYCoverEntity(ISYNodeEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Send the open cover command to the ISY cover device.""" - val = 100 if self._node.uom == UOM_BARRIER else None - if not await self._node.turn_on(val=val): + if not await self._node.turn_on(): _LOGGER.error("Unable to open the cover") async def async_close_cover(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py index 0b62f2bd14..8c64e5b9d5 100644 --- a/homeassistant/components/isy994/light.py +++ b/homeassistant/components/isy994/light.py @@ -10,19 +10,13 @@ from pyisy.nodes import Node from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from homeassistant.helpers.restore_state import RestoreEntity from .const import _LOGGER, CONF_RESTORE_LIGHT_STATE, DOMAIN, UOM_PERCENTAGE from .entity import ISYNodeEntity -from .services import ( - SERVICE_SET_ON_LEVEL, - async_log_deprecated_service_call, - async_setup_light_services, -) ATTR_LAST_BRIGHTNESS = "last_brightness" @@ -43,7 +37,6 @@ async def async_setup_entry( ) async_add_entities(entities) - async_setup_light_services(hass) class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity): @@ -127,35 +120,3 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity): and last_state.attributes[ATTR_LAST_BRIGHTNESS] ): self._last_brightness = last_state.attributes[ATTR_LAST_BRIGHTNESS] - - async def async_set_on_level(self, value: int) -> None: - """Set the ON Level for a device.""" - entity_registry = er.async_get(self.hass) - async_log_deprecated_service_call( - self.hass, - call=ServiceCall(domain=DOMAIN, service=SERVICE_SET_ON_LEVEL), - alternate_service="number.set_value", - alternate_target=entity_registry.async_get_entity_id( - Platform.NUMBER, - DOMAIN, - f"{self._node.isy.uuid}_{self._node.address}_OL", - ), - breaks_in_ha_version="2023.5.0", - ) - await self._node.set_on_level(value) - - async def async_set_ramp_rate(self, value: int) -> None: - """Set the Ramp Rate for a device.""" - entity_registry = er.async_get(self.hass) - async_log_deprecated_service_call( - self.hass, - call=ServiceCall(domain=DOMAIN, service=SERVICE_SET_ON_LEVEL), - alternate_service="select.select_option", - alternate_target=entity_registry.async_get_entity_id( - Platform.NUMBER, - DOMAIN, - f"{self._node.isy.uuid}_{self._node.address}_RR", - ), - breaks_in_ha_version="2023.5.0", - ) - await self._node.set_ramp_rate(value) diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index ea66bc9013..7d7696755c 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -10,36 +10,17 @@ from homeassistant.const import ( CONF_ADDRESS, CONF_COMMAND, CONF_NAME, - CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, - SERVICE_RELOAD, - Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import async_get_platforms -import homeassistant.helpers.entity_registry as er -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service import entity_service_call -from .const import _LOGGER, CONF_NETWORK, DOMAIN, ISY_CONF_NAME -from .util import _async_cleanup_registry_entries +from .const import _LOGGER, DOMAIN # Common Services for All Platforms: -SERVICE_SYSTEM_QUERY = "system_query" -SERVICE_SET_VARIABLE = "set_variable" SERVICE_SEND_PROGRAM_COMMAND = "send_program_command" -SERVICE_RUN_NETWORK_RESOURCE = "run_network_resource" -SERVICE_CLEANUP = "cleanup_entities" - -INTEGRATION_SERVICES = [ - SERVICE_SYSTEM_QUERY, - SERVICE_SET_VARIABLE, - SERVICE_SEND_PROGRAM_COMMAND, - SERVICE_RUN_NETWORK_RESOURCE, - SERVICE_CLEANUP, -] # Entity specific methods (valid for most Groups/ISY Scenes, Lights, Switches, Fans) SERVICE_SEND_RAW_NODE_COMMAND = "send_raw_node_command" @@ -48,10 +29,6 @@ SERVICE_GET_ZWAVE_PARAMETER = "get_zwave_parameter" SERVICE_SET_ZWAVE_PARAMETER = "set_zwave_parameter" SERVICE_RENAME_NODE = "rename_node" -# Services valid only for dimmable lights. -SERVICE_SET_ON_LEVEL = "set_on_level" -SERVICE_SET_RAMP_RATE = "set_ramp_rate" - # Services valid only for Z-Wave Locks SERVICE_SET_ZWAVE_LOCK_USER_CODE = "set_zwave_lock_user_code" SERVICE_DELETE_ZWAVE_LOCK_USER_CODE = "delete_zwave_lock_user_code" @@ -102,18 +79,6 @@ def valid_isy_commands(value: Any) -> str: SCHEMA_GROUP = "name-address" -SERVICE_SYSTEM_QUERY_SCHEMA = vol.Schema( - {vol.Optional(CONF_ADDRESS): cv.string, vol.Optional(CONF_ISY): cv.string} -) - -SERVICE_SET_RAMP_RATE_SCHEMA = { - vol.Required(CONF_VALUE): vol.All(vol.Coerce(int), vol.Range(0, 31)) -} - -SERVICE_SET_VALUE_SCHEMA = { - vol.Required(CONF_VALUE): vol.All(vol.Coerce(int), vol.Range(0, 255)) -} - SERVICE_SEND_RAW_NODE_COMMAND_SCHEMA = { vol.Required(CONF_COMMAND): vol.All(cv.string, valid_isy_commands), vol.Optional(CONF_VALUE): vol.All(vol.Coerce(int), vol.Range(0, 255)), @@ -142,22 +107,6 @@ SERVICE_SET_USER_CODE_SCHEMA = { SERVICE_DELETE_USER_CODE_SCHEMA = {vol.Required(CONF_USER_NUM): vol.Coerce(int)} -SERVICE_SET_VARIABLE_SCHEMA = vol.All( - cv.has_at_least_one_key(CONF_ADDRESS, CONF_TYPE, CONF_NAME), - vol.Schema( - { - vol.Exclusive(CONF_NAME, SCHEMA_GROUP): cv.string, - vol.Inclusive(CONF_ADDRESS, SCHEMA_GROUP): vol.Coerce(int), - vol.Inclusive(CONF_TYPE, SCHEMA_GROUP): vol.All( - vol.Coerce(int), vol.Range(1, 2) - ), - vol.Optional(CONF_INIT, default=False): bool, - vol.Required(CONF_VALUE): vol.Coerce(int), - vol.Optional(CONF_ISY): cv.string, - } - ), -) - SERVICE_SEND_PROGRAM_COMMAND_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_ADDRESS, CONF_NAME), vol.Schema( @@ -170,108 +119,15 @@ SERVICE_SEND_PROGRAM_COMMAND_SCHEMA = vol.All( ), ) -SERVICE_RUN_NETWORK_RESOURCE_SCHEMA = vol.All( - cv.has_at_least_one_key(CONF_ADDRESS, CONF_NAME), - vol.Schema( - { - vol.Exclusive(CONF_NAME, SCHEMA_GROUP): cv.string, - vol.Exclusive(CONF_ADDRESS, SCHEMA_GROUP): vol.Coerce(int), - vol.Optional(CONF_ISY): cv.string, - } - ), -) - @callback def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Create and register services for the ISY integration.""" existing_services = hass.services.async_services().get(DOMAIN) - if existing_services and any( - service in INTEGRATION_SERVICES for service in existing_services - ): + if existing_services and SERVICE_SEND_PROGRAM_COMMAND in existing_services: # Integration-level services have already been added. Return. return - async def async_system_query_service_handler(service: ServiceCall) -> None: - """Handle a system query service call.""" - address = service.data.get(CONF_ADDRESS) - isy_name = service.data.get(CONF_ISY) - entity_registry = er.async_get(hass) - for config_entry_id in hass.data[DOMAIN]: - isy_data = hass.data[DOMAIN][config_entry_id] - isy = isy_data.root - if isy_name and isy_name != isy.conf["name"]: - continue - # If an address is provided, make sure we query the correct ISY. - # Otherwise, query the whole system on all ISY's connected. - if address and isy.nodes.get_by_id(address) is not None: - _LOGGER.debug( - "Requesting query of device %s on ISY %s", - address, - isy.uuid, - ) - await isy.query(address) - async_log_deprecated_service_call( - hass, - call=service, - alternate_service="button.press", - alternate_target=entity_registry.async_get_entity_id( - Platform.BUTTON, - DOMAIN, - f"{isy.uuid}_{address}_query", - ), - breaks_in_ha_version="2023.5.0", - ) - return - _LOGGER.debug("Requesting system query of ISY %s", isy.uuid) - await isy.query() - async_log_deprecated_service_call( - hass, - call=service, - alternate_service="button.press", - alternate_target=entity_registry.async_get_entity_id( - Platform.BUTTON, DOMAIN, f"{isy.uuid}_query" - ), - breaks_in_ha_version="2023.5.0", - ) - - async def async_run_network_resource_service_handler(service: ServiceCall) -> None: - """Handle a network resource service call.""" - address = service.data.get(CONF_ADDRESS) - name = service.data.get(CONF_NAME) - isy_name = service.data.get(CONF_ISY) - - for config_entry_id in hass.data[DOMAIN]: - isy_data = hass.data[DOMAIN][config_entry_id] - isy = isy_data.root - if isy_name and isy_name != isy.conf[ISY_CONF_NAME]: - continue - if isy.networking is None: - continue - command = None - if address: - command = isy.networking.get_by_id(address) - if name: - command = isy.networking.get_by_name(name) - if command is not None: - await command.run() - entity_registry = er.async_get(hass) - async_log_deprecated_service_call( - hass, - call=service, - alternate_service="button.press", - alternate_target=entity_registry.async_get_entity_id( - Platform.BUTTON, - DOMAIN, - f"{isy.uuid}_{CONF_NETWORK}_{address}", - ), - breaks_in_ha_version="2023.5.0", - ) - return - _LOGGER.error( - "Could not run network resource command; not found or enabled on the ISY" - ) - async def async_send_program_command_service_handler(service: ServiceCall) -> None: """Handle a send program command service call.""" address = service.data.get(CONF_ADDRESS) @@ -294,81 +150,6 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 return _LOGGER.error("Could not send program command; not found or enabled on the ISY") - async def async_set_variable_service_handler(service: ServiceCall) -> None: - """Handle a set variable service call.""" - address = service.data.get(CONF_ADDRESS) - vtype = service.data.get(CONF_TYPE) - name = service.data.get(CONF_NAME) - value = service.data.get(CONF_VALUE) - init = service.data.get(CONF_INIT, False) - isy_name = service.data.get(CONF_ISY) - - for config_entry_id in hass.data[DOMAIN]: - isy_data = hass.data[DOMAIN][config_entry_id] - isy = isy_data.root - if isy_name and isy_name != isy.conf["name"]: - continue - variable = None - if name: - variable = isy.variables.get_by_name(name) - if address and vtype: - variable = isy.variables.vobjs[vtype].get(address) - if variable is not None: - await variable.set_value(value, init) - entity_registry = er.async_get(hass) - async_log_deprecated_service_call( - hass, - call=service, - alternate_service="number.set_value", - alternate_target=entity_registry.async_get_entity_id( - Platform.NUMBER, - DOMAIN, - f"{isy.uuid}_{address}{'_init' if init else ''}", - ), - breaks_in_ha_version="2023.5.0", - ) - return - _LOGGER.error("Could not set variable value; not found or enabled on the ISY") - - @callback - def async_cleanup_registry_entries(service: ServiceCall) -> None: - """Remove extra entities that are no longer part of the integration.""" - async_log_deprecated_service_call( - hass, - call=service, - alternate_service="homeassistant.reload_core_config", - alternate_target=None, - breaks_in_ha_version="2023.5.0", - ) - for config_entry_id in hass.data[DOMAIN]: - _async_cleanup_registry_entries(hass, config_entry_id) - - async def async_reload_config_entries(service: ServiceCall) -> None: - """Trigger a reload of all ISY config entries.""" - async_log_deprecated_service_call( - hass, - call=service, - alternate_service="homeassistant.reload_core_config", - alternate_target=None, - breaks_in_ha_version="2023.5.0", - ) - for config_entry_id in hass.data[DOMAIN]: - hass.async_create_task(hass.config_entries.async_reload(config_entry_id)) - - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_SYSTEM_QUERY, - service_func=async_system_query_service_handler, - schema=SERVICE_SYSTEM_QUERY_SCHEMA, - ) - - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_RUN_NETWORK_RESOURCE, - service_func=async_run_network_resource_service_handler, - schema=SERVICE_RUN_NETWORK_RESOURCE_SCHEMA, - ) - hass.services.async_register( domain=DOMAIN, service=SERVICE_SEND_PROGRAM_COMMAND, @@ -376,23 +157,6 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 schema=SERVICE_SEND_PROGRAM_COMMAND_SCHEMA, ) - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_SET_VARIABLE, - service_func=async_set_variable_service_handler, - schema=SERVICE_SET_VARIABLE_SCHEMA, - ) - - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_CLEANUP, - service_func=async_cleanup_registry_entries, - ) - - hass.services.async_register( - domain=DOMAIN, service=SERVICE_RELOAD, service_func=async_reload_config_entries - ) - async def _async_send_raw_node_command(call: ServiceCall) -> None: await entity_service_call( hass, async_get_platforms(hass, DOMAIN), "async_send_raw_node_command", call @@ -462,74 +226,12 @@ def async_unload_services(hass: HomeAssistant) -> None: return existing_services = hass.services.async_services().get(DOMAIN) - if not existing_services or not any( - service in INTEGRATION_SERVICES for service in existing_services - ): + if not existing_services or SERVICE_SEND_PROGRAM_COMMAND not in existing_services: return _LOGGER.info("Unloading ISY994 Services") - hass.services.async_remove(domain=DOMAIN, service=SERVICE_SYSTEM_QUERY) - hass.services.async_remove(domain=DOMAIN, service=SERVICE_RUN_NETWORK_RESOURCE) hass.services.async_remove(domain=DOMAIN, service=SERVICE_SEND_PROGRAM_COMMAND) - hass.services.async_remove(domain=DOMAIN, service=SERVICE_SET_VARIABLE) - hass.services.async_remove(domain=DOMAIN, service=SERVICE_CLEANUP) - hass.services.async_remove(domain=DOMAIN, service=SERVICE_RELOAD) hass.services.async_remove(domain=DOMAIN, service=SERVICE_SEND_RAW_NODE_COMMAND) hass.services.async_remove(domain=DOMAIN, service=SERVICE_SEND_NODE_COMMAND) - - -@callback -def async_setup_light_services(hass: HomeAssistant) -> None: - """Create device-specific services for the ISY Integration.""" - platform = entity_platform.async_get_current_platform() - - platform.async_register_entity_service( - SERVICE_SET_ON_LEVEL, SERVICE_SET_VALUE_SCHEMA, "async_set_on_level" - ) - platform.async_register_entity_service( - SERVICE_SET_RAMP_RATE, SERVICE_SET_RAMP_RATE_SCHEMA, "async_set_ramp_rate" - ) - - -@callback -def async_log_deprecated_service_call( - hass: HomeAssistant, - call: ServiceCall, - alternate_service: str, - alternate_target: str | None, - breaks_in_ha_version: str, -) -> None: - """Log a warning about a deprecated service call.""" - deprecated_service = f"{call.domain}.{call.service}" - alternate_target = alternate_target or "this device" - - async_create_issue( - hass, - DOMAIN, - f"deprecated_service_{deprecated_service}", - breaks_in_ha_version=breaks_in_ha_version, - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_service", - translation_placeholders={ - "alternate_service": alternate_service, - "alternate_target": alternate_target, - "deprecated_service": deprecated_service, - }, - ) - - alternate_text = "" - if alternate_target: - alternate_text = f' and pass it a target entity ID of "{alternate_target}"' - - _LOGGER.warning( - ( - 'The "%s" service is deprecated and will be removed in %s; use the "%s" ' - "service %s" - ), - deprecated_service, - breaks_in_ha_version, - alternate_service, - alternate_text, - ) + hass.services.async_remove(domain=DOMAIN, service=SERVICE_GET_ZWAVE_PARAMETER) + hass.services.async_remove(domain=DOMAIN, service=SERVICE_SET_ZWAVE_PARAMETER) diff --git a/homeassistant/components/isy994/services.yaml b/homeassistant/components/isy994/services.yaml index 89b6c4d33d..b84fcdd73e 100644 --- a/homeassistant/components/isy994/services.yaml +++ b/homeassistant/components/isy994/services.yaml @@ -181,98 +181,6 @@ rename_node: example: "Front Door Light" selector: text: -set_on_level: - name: Set On Level (Deprecated) - description: "Send a ISY set_on_level command to a Node. Deprecated: Use On Level Number entity instead." - target: - entity: - integration: isy994 - domain: light - fields: - value: - name: Value - description: integer value to set. - required: true - selector: - number: - min: 0 - max: 255 -set_ramp_rate: - name: Set ramp rate (Deprecated) - description: "Send a ISY set_ramp_rate command to a Node. Deprecated: Use On Level Number entity instead." - target: - entity: - integration: isy994 - domain: light - fields: - value: - name: Value - description: Integer value to set, see PyISY/ISY documentation for values to actual ramp times. - required: true - selector: - number: - min: 0 - max: 31 -system_query: - name: System query (Deprecated) - description: "Request the ISY Query the connected devices. Deprecated: Use device Query button entity." - fields: - address: - name: Address - description: ISY Address to Query. Omitting this requests a system-wide scan (typically scheduled once per day). - example: "1A 2B 3C 1" - selector: - text: - isy: - name: ISY - description: If you have more than one ISY connected, provide the name of the ISY to query (as shown on the Device Registry or as the top-first node in the ISY Admin Console). Omitting this will cause all ISYs to be queried. - example: "ISY" - selector: - text: -set_variable: - name: Set variable (Deprecated) - description: "Set an ISY variable's current or initial value. Variables can be set by either type/address or by name. Deprecated: Use number entities instead." - fields: - address: - name: Address - description: The address of the variable for which to set the value. - selector: - number: - min: 0 - max: 255 - type: - name: Type - description: The variable type, 1 = Integer, 2 = State. - selector: - number: - min: 1 - max: 2 - name: - name: Name - description: The name of the variable to set (use instead of type/address). - example: "my_variable_name" - selector: - text: - init: - name: Init - description: If True, the initial (init) value will be updated instead of the current value. - default: false - selector: - boolean: - value: - name: Value - description: The integer value to be sent. - required: true - selector: - number: - min: 0 - max: 255 - isy: - name: ISY - description: If you have more than one ISY connected, provide the name of the ISY to query (as shown on the Device Registry or as the top-first node in the ISY Admin Console). If you have the same variable name or address on multiple ISYs, omitting this will run the command on them all. - example: "ISY" - selector: - text: send_program_command: name: Send program command description: >- @@ -312,32 +220,3 @@ send_program_command: example: "ISY" selector: text: -run_network_resource: - name: Run network resource (Deprecated) - description: "Run a network resource on the ISY. Deprecated: Use Network Resource button entity." - fields: - address: - name: Address - description: The address of the network resource to execute (use either address or name). - selector: - number: - min: 0 - max: 255 - name: - name: Name - description: The name of the network resource to execute (use either address or name). - example: "Network Resource 1" - selector: - text: - isy: - name: ISY - description: If you have more than one ISY connected, provide the name of the ISY to query (as shown on the Device Registry or as the top-first node in the ISY Admin Console). If you have the same resource name or address on multiple ISYs, omitting this will run the command on them all. - example: "ISY" - selector: - text: -reload: - name: Reload - description: Reload the ISY connection(s) without restarting Home Assistant. Use to pick up new devices that have been added or changed on the ISY. -cleanup_entities: - name: Cleanup entities - description: Cleanup old entities and devices no longer used by the ISY integration. Useful if you've removed devices from the ISY or changed the options in the configuration to exclude additional items. diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 6985239489..821f888997 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -53,22 +53,5 @@ "last_heartbeat": "Last Heartbeat Time", "websocket_status": "Event Socket Status" } - }, - "issues": { - "deprecated_service": { - "title": "The {deprecated_service} service will be removed", - "fix_flow": { - "step": { - "confirm": { - "title": "The {deprecated_service} service will be removed", - "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`." - }, - "deprecated_yaml": { - "title": "The ISY/IoX YAML configuration is being removed", - "description": "Configuring Universal Devices ISY/IoX using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `isy994` YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - } - } - } } } diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index 3f2565bd8f..fd8d27ac42 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -3,7 +3,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EXCLUDE, Platform +from homeassistant.const import CONF_EXCLUDE, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -29,29 +29,35 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the iZone component config.""" - if not (conf := config.get(IZONE)): - return True - hass.data[DATA_CONFIG] = conf + # Check for manually added config, this may exclude some devices + if conf := config.get(IZONE): + hass.data[DATA_CONFIG] = conf - # Explicitly added in the config file, create a config entry. - hass.async_create_task( - hass.config_entries.flow.async_init( - IZONE, context={"source": config_entries.SOURCE_IMPORT} + # Explicitly added in the config file, create a config entry. + hass.async_create_task( + hass.config_entries.flow.async_init( + IZONE, context={"source": config_entries.SOURCE_IMPORT} + ) ) - ) + + # Start the discovery service + await async_start_discovery_service(hass) + + async def shutdown_event(event): + await async_stop_discovery_service(hass) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_event) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" - await async_start_discovery_service(hass) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload the config entry and stop discovery process.""" - await async_stop_discovery_service(hass) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 3e19afcca2..4c9eb3a607 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -95,7 +95,9 @@ async def async_setup_entry( init_controller(controller) # connect to register any further components - async_dispatcher_connect(hass, DISPATCH_CONTROLLER_DISCOVERED, init_controller) + config.async_on_unload( + async_dispatcher_connect(hass, DISPATCH_CONTROLLER_DISCOVERED, init_controller) + ) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -140,8 +142,11 @@ class ControllerDevice(ClimateEntity): # If mode RAS, or mode master with CtrlZone 13 then can set master temperature, # otherwise the unit determines which zone to use as target. See interface manual p. 8 + # It appears some systems may have a different numbering system, so will trigger + # this if the control zone is > total zones. if ( - controller.ras_mode == "master" and controller.zone_ctrl == 13 + controller.ras_mode == "master" + and controller.zone_ctrl > controller.zones_total ) or controller.ras_mode == "RAS": self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE diff --git a/homeassistant/components/izone/discovery.py b/homeassistant/components/izone/discovery.py index eb6e7d4a19..a170ed30a7 100644 --- a/homeassistant/components/izone/discovery.py +++ b/homeassistant/components/izone/discovery.py @@ -1,7 +1,8 @@ """Internal discovery service for iZone AC.""" +import logging + import pizone -from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -15,15 +16,17 @@ from .const import ( DISPATCH_ZONE_UPDATE, ) +_LOGGER = logging.getLogger(__name__) + class DiscoveryService(pizone.Listener): """Discovery data and interfacing with pizone library.""" - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialise discovery service.""" super().__init__() self.hass = hass - self.pi_disco = None + self.pi_disco: pizone.DiscoveryService | None = None # Listener interface def controller_discovered(self, ctrl: pizone.Controller) -> None: @@ -52,6 +55,7 @@ async def async_start_discovery_service(hass: HomeAssistant): if disco := hass.data.get(DATA_DISCOVERY_SERVICE): # Already started return disco + _LOGGER.debug("Starting iZone Discovery Service") # discovery local services disco = DiscoveryService(hass) @@ -62,11 +66,6 @@ async def async_start_discovery_service(hass: HomeAssistant): disco.pi_disco = pizone.discovery(disco, session=session) await disco.pi_disco.start_discovery() - async def shutdown_event(event): - await async_stop_discovery_service(hass) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_event) - return disco @@ -77,3 +76,5 @@ async def async_stop_discovery_service(hass: HomeAssistant): await disco.pi_disco.close() del hass.data[DATA_DISCOVERY_SERVICE] + + _LOGGER.debug("Stopped iZone Discovery Service") diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 32ca1d59d7..2025e1a2a6 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -283,7 +283,9 @@ class JellyfinMediaPlayer(JellyfinEntity, MediaPlayerEntity): self.coordinator.api_client.jellyfin.remote_unmute(self.session_id) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Return a BrowseMedia instance. diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 60104545de..91d1d9fa1c 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -12,7 +12,7 @@ from xknx import XKNX from xknx.core import XknxConnectionState from xknx.core.telegram_queue import TelegramQueue from xknx.dpt import DPTArray, DPTBase, DPTBinary -from xknx.exceptions import ConversionError, XKNXException +from xknx.exceptions import ConversionError, CouldNotParseTelegram, XKNXException from xknx.io import ConnectionConfig, ConnectionType, SecureConfig from xknx.telegram import AddressFilter, Telegram from xknx.telegram.address import ( @@ -513,31 +513,29 @@ class KNXModule: ) ): data = telegram.payload.value.value - - if isinstance(data, tuple): - if transcoder := ( - self._group_address_transcoder.get(telegram.destination_address) - or next( + if transcoder := ( + self._group_address_transcoder.get(telegram.destination_address) + or next( + ( + _transcoder + for _filter, _transcoder in self._address_filter_transcoder.items() + if _filter.match(telegram.destination_address) + ), + None, + ) + ): + try: + value = transcoder.from_knx(telegram.payload.value) + except (ConversionError, CouldNotParseTelegram) as err: + _LOGGER.warning( ( - _transcoder - for _filter, _transcoder in self._address_filter_transcoder.items() - if _filter.match(telegram.destination_address) + "Error in `knx_event` at decoding type '%s' from" + " telegram %s\n%s" ), - None, + transcoder.__name__, + telegram, + err, ) - ): - try: - value = transcoder.from_knx(data) - except ConversionError as err: - _LOGGER.warning( - ( - "Error in `knx_event` at decoding type '%s' from" - " telegram %s\n%s" - ), - transcoder.__name__, - telegram, - err, - ) self.hass.bus.async_fire( "knx_event", @@ -656,7 +654,7 @@ class KNXModule: transcoder = DPTBase.parse_transcoder(attr_type) if transcoder is None: raise ValueError(f"Invalid type for knx.send service: {attr_type}") - payload = DPTArray(transcoder.to_knx(attr_payload)) + payload = transcoder.to_knx(attr_payload) elif isinstance(attr_payload, int): payload = DPTBinary(attr_payload) else: diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 85e23cbe54..81610d62dc 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -9,10 +9,15 @@ from typing import Any, Final import voluptuous as vol from xknx import XKNX -from xknx.exceptions.exception import CommunicationError, InvalidSecureConfiguration +from xknx.exceptions.exception import ( + CommunicationError, + InvalidSecureConfiguration, + XKNXException, +) from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.io.gateway_scanner import GatewayDescriptor, GatewayScanner from xknx.io.self_description import request_description +from xknx.io.util import validate_ip as xknx_validate_ip from xknx.secure.keyring import Keyring, XMLInterface, sync_load_keyring from homeassistant.components.file_upload import process_uploaded_file @@ -258,21 +263,25 @@ class KNXCommonFlow(ABC, FlowHandler): if user_input is not None: try: - _host = ip_v4_validator(user_input[CONF_HOST], multicast=False) - except vol.Invalid: + _host = user_input[CONF_HOST] + _host_ip = await xknx_validate_ip(_host) + ip_v4_validator(_host_ip, multicast=False) + except (vol.Invalid, XKNXException): errors[CONF_HOST] = "invalid_ip_address" - if _local_ip := user_input.get(CONF_KNX_LOCAL_IP): + _local_ip = None + if _local := user_input.get(CONF_KNX_LOCAL_IP): try: - _local_ip = ip_v4_validator(_local_ip, multicast=False) - except vol.Invalid: + _local_ip = await xknx_validate_ip(_local) + ip_v4_validator(_local_ip, multicast=False) + except (vol.Invalid, XKNXException): errors[CONF_KNX_LOCAL_IP] = "invalid_ip_address" selected_tunnelling_type = user_input[CONF_KNX_TUNNELING_TYPE] if not errors: try: self._selected_tunnel = await request_description( - gateway_ip=_host, + gateway_ip=_host_ip, gateway_port=user_input[CONF_PORT], local_ip=_local_ip, route_back=user_input[CONF_KNX_ROUTE_BACK], @@ -296,7 +305,7 @@ class KNXCommonFlow(ABC, FlowHandler): host=_host, port=user_input[CONF_PORT], route_back=user_input[CONF_KNX_ROUTE_BACK], - local_ip=_local_ip, + local_ip=_local, device_authentication=None, user_id=None, user_password=None, @@ -636,10 +645,11 @@ class KNXCommonFlow(ABC, FlowHandler): ip_v4_validator(_multicast_group, multicast=True) except vol.Invalid: errors[CONF_KNX_MCAST_GRP] = "invalid_ip_address" - if _local_ip := user_input.get(CONF_KNX_LOCAL_IP): + if _local := user_input.get(CONF_KNX_LOCAL_IP): try: + _local_ip = await xknx_validate_ip(_local) ip_v4_validator(_local_ip, multicast=False) - except vol.Invalid: + except (vol.Invalid, XKNXException): errors[CONF_KNX_LOCAL_IP] = "invalid_ip_address" if not errors: @@ -653,7 +663,7 @@ class KNXCommonFlow(ABC, FlowHandler): individual_address=_individual_address, multicast_group=_multicast_group, multicast_port=_multicast_port, - local_ip=_local_ip, + local_ip=_local, device_authentication=None, user_id=None, user_password=None, diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 0ad4404290..d3aeced46c 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["xknx"], "quality_scale": "platinum", - "requirements": ["xknx==2.7.0"] + "requirements": ["xknx==2.9.0"] } diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index c6206e883e..0f627b724c 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -10,7 +10,7 @@ from typing import Any, ClassVar, Final import voluptuous as vol from xknx.devices.climate import SetpointShiftMode from xknx.dpt import DPTBase, DPTNumeric, DPTString -from xknx.exceptions import ConversionError, CouldNotParseAddress +from xknx.exceptions import ConversionError, CouldNotParseAddress, CouldNotParseTelegram from xknx.telegram.address import IndividualAddress, parse_device_group_address from homeassistant.components.binary_sensor import ( @@ -101,7 +101,11 @@ def ga_validator(value: Any) -> str | int: ) -ga_list_validator = vol.All(cv.ensure_list, [ga_validator]) +ga_list_validator = vol.All( + cv.ensure_list, + [ga_validator], + vol.IsTrue("value must be a group address or a list containing group addresses"), +) ia_validator = vol.Any( vol.All(str, str.strip, cv.matches_regex(IndividualAddress.ADDRESS_RE.pattern)), @@ -181,13 +185,13 @@ def button_payload_sub_validator(entity_config: OrderedDict) -> OrderedDict: raise vol.Invalid(f"'type: {_type}' is not a valid sensor type.") entity_config[CONF_PAYLOAD_LENGTH] = transcoder.payload_length try: - entity_config[CONF_PAYLOAD] = int.from_bytes( - transcoder.to_knx(_payload), byteorder="big" - ) - except ConversionError as ex: + _dpt_payload = transcoder.to_knx(_payload) + _raw_payload = transcoder.validate_payload(_dpt_payload) + except (ConversionError, CouldNotParseTelegram) as ex: raise vol.Invalid( f"'payload: {_payload}' not valid for 'type: {_type}'" ) from ex + entity_config[CONF_PAYLOAD] = int.from_bytes(_raw_payload, byteorder="big") return entity_config _payload = entity_config[CONF_PAYLOAD] diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index ef15398534..ea5ba2f63a 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -53,7 +53,6 @@ class KNXSystemEntityDescription(SensorEntityDescription): SYSTEM_ENTITY_DESCRIPTIONS = ( KNXSystemEntityDescription( key="individual_address", - name="Individual Address", always_available=False, icon="mdi:router-network", should_poll=False, @@ -61,7 +60,6 @@ SYSTEM_ENTITY_DESCRIPTIONS = ( ), KNXSystemEntityDescription( key="connected_since", - name="Connected since", always_available=False, device_class=SensorDeviceClass.TIMESTAMP, should_poll=False, @@ -69,7 +67,6 @@ SYSTEM_ENTITY_DESCRIPTIONS = ( ), KNXSystemEntityDescription( key="connection_type", - name="Connection type", always_available=False, device_class=SensorDeviceClass.ENUM, options=[opt.value for opt in XknxConnectionType], @@ -78,7 +75,6 @@ SYSTEM_ENTITY_DESCRIPTIONS = ( ), KNXSystemEntityDescription( key="telegrams_incoming", - name="Telegrams incoming", icon="mdi:upload-network", entity_registry_enabled_default=False, force_update=True, @@ -87,14 +83,12 @@ SYSTEM_ENTITY_DESCRIPTIONS = ( ), KNXSystemEntityDescription( key="telegrams_incoming_error", - name="Telegrams incoming Error", icon="mdi:help-network", state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_incoming_error, ), KNXSystemEntityDescription( key="telegrams_outgoing", - name="Telegrams outgoing", icon="mdi:download-network", entity_registry_enabled_default=False, force_update=True, @@ -103,14 +97,12 @@ SYSTEM_ENTITY_DESCRIPTIONS = ( ), KNXSystemEntityDescription( key="telegrams_outgoing_error", - name="Telegrams outgoing Error", icon="mdi:close-network", state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_outgoing_error, ), KNXSystemEntityDescription( key="telegram_count", - name="Telegrams", icon="mdi:plus-network", force_update=True, state_class=SensorStateClass.TOTAL_INCREASING, @@ -192,6 +184,8 @@ class KNXSensor(KnxEntity, SensorEntity): class KNXSystemSensor(SensorEntity): """Representation of a KNX system sensor.""" + _attr_has_entity_name = True + def __init__( self, knx: KNXModule, @@ -203,6 +197,7 @@ class KNXSystemSensor(SensorEntity): self._attr_device_info = knx.interface_device.device_info self._attr_should_poll = description.should_poll + self._attr_translation_key = description.key self._attr_unique_id = f"_{knx.entry.entry_id}_{description.key}" @property diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index a781f9d73c..0fce778c52 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -23,13 +23,13 @@ "port": "[%key:common::config_flow::data::port%]", "host": "[%key:common::config_flow::data::host%]", "route_back": "Route back / NAT mode", - "local_ip": "Local IP of Home Assistant" + "local_ip": "Local IP interface" }, "data_description": { "port": "Port of the KNX/IP tunneling device.", - "host": "IP address of the KNX/IP tunneling device.", + "host": "IP address or hostname of the KNX/IP tunneling device.", "route_back": "Enable if your KNXnet/IP tunneling server is behind NAT. Only applies for UDP connections.", - "local_ip": "Leave blank to use auto-discovery." + "local_ip": "Local IP or interface name used for the connection from Home Assistant. Leave blank to use auto-discovery." } }, "secure_key_source": { @@ -93,11 +93,11 @@ "routing_secure": "Use KNX IP Secure", "multicast_group": "Multicast group", "multicast_port": "Multicast port", - "local_ip": "Local IP of Home Assistant" + "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" }, "data_description": { "individual_address": "KNX address to be used by Home Assistant, e.g. `0.0.4`", - "local_ip": "Leave blank to use auto-discovery." + "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" } } }, @@ -253,5 +253,33 @@ "no_tunnel_discovered": "[%key:component::knx::config::error::no_tunnel_discovered%]", "unsupported_tunnel_type": "[%key:component::knx::config::error::unsupported_tunnel_type%]" } + }, + "entity": { + "sensor": { + "individual_address": { + "name": "Individual address" + }, + "connected_since": { + "name": "Connection established" + }, + "connection_type": { + "name": "Connection type" + }, + "telegrams_incoming": { + "name": "Incoming telegrams" + }, + "telegrams_incoming_error": { + "name": "Incoming telegram errors" + }, + "telegrams_outgoing": { + "name": "Outgoing telegrams" + }, + "telegrams_outgoing_error": { + "name": "Outgoing telegram errors" + }, + "telegram_count": { + "name": "Telegrams" + } + } } } diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 029eedb242..3272491a06 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -884,7 +884,9 @@ class KodiEntity(MediaPlayerEntity): return sorted(out, key=lambda out: out[1], reverse=True) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" is_internal = is_internal_request(self.hass) @@ -926,7 +928,7 @@ class KodiEntity(MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 74edd9e0af..18a0c2f8f7 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -36,28 +36,28 @@ class LaMetricButtonEntityDescription( BUTTONS = [ LaMetricButtonEntityDescription( key="app_next", - name="Next app", + translation_key="app_next", icon="mdi:arrow-right-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_next(), ), LaMetricButtonEntityDescription( key="app_previous", - name="Previous app", + translation_key="app_previous", icon="mdi:arrow-left-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_previous(), ), LaMetricButtonEntityDescription( key="dismiss_current", - name="Dismiss current notification", + translation_key="dismiss_current", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_current_notification(), ), LaMetricButtonEntityDescription( key="dismiss_all", - name="Dismiss all notifications", + translation_key="dismiss_all", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_all_notifications(), diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index 295003c853..b7c0e55745 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -37,11 +37,10 @@ class LaMetricSelectEntityDescription( SELECTS = [ LaMetricSelectEntityDescription( key="brightness_mode", - name="Brightness mode", + translation_key="brightness_mode", icon="mdi:brightness-auto", entity_category=EntityCategory.CONFIG, options=["auto", "manual"], - translation_key="brightness_mode", current_fn=lambda device: device.display.brightness_mode.value, select_fn=lambda api, opt: api.display(brightness_mode=BrightnessMode(opt)), ), diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index c12d368efd..0c26d2c7dd 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -38,6 +38,7 @@ class LaMetricSensorEntityDescription( SENSORS = [ LaMetricSensorEntityDescription( key="rssi", + translation_key="rssi", name="Wi-Fi signal", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index eb90b21ff2..21cebe46f2 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -45,13 +45,38 @@ } }, "entity": { + "button": { + "app_next": { + "name": "Next app" + }, + "app_previous": { + "name": "Previous app" + }, + "dismiss_current": { + "name": "Dismiss current notification" + }, + "dismiss_all": { + "name": "Dismiss all notifications" + } + }, + "sensor": { + "rssi": { + "name": "Wi-Fi signal" + } + }, "select": { "brightness_mode": { + "name": "Brightness mode", "state": { "auto": "Automatic", "manual": "Manual" } } + }, + "switch": { + "bluetooth": { + "name": "Bluetooth" + } } } } diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index f6807648b7..c33ec16d61 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -39,7 +39,7 @@ class LaMetricSwitchEntityDescription( SWITCHES = [ LaMetricSwitchEntityDescription( key="bluetooth", - name="Bluetooth", + translation_key="bluetooth", icon="mdi:bluetooth", entity_category=EntityCategory.CONFIG, available_fn=lambda device: device.bluetooth.available, diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 3a44267bd4..0279af2e61 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -67,7 +67,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> await async_migrate_entries( hass, config_entry.entry_id, update_entity_unique_id ) - hass.config_entries.async_update_entry(config_entry) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/landisgyr_heat_meter/const.py b/homeassistant/components/landisgyr_heat_meter/const.py index 56f5980a83..079bcad253 100644 --- a/homeassistant/components/landisgyr_heat_meter/const.py +++ b/homeassistant/components/landisgyr_heat_meter/const.py @@ -4,6 +4,5 @@ from datetime import timedelta DOMAIN = "landisgyr_heat_meter" -GJ_TO_MWH = 0.277778 # conversion factor ULTRAHEAT_TIMEOUT = 30 # reading the IR port can take some time POLLING_INTERVAL = timedelta(days=1) # Polling is only daily to prevent battery drain. diff --git a/homeassistant/components/landisgyr_heat_meter/manifest.json b/homeassistant/components/landisgyr_heat_meter/manifest.json index 5e10f39418..a056f1f656 100644 --- a/homeassistant/components/landisgyr_heat_meter/manifest.json +++ b/homeassistant/components/landisgyr_heat_meter/manifest.json @@ -5,9 +5,6 @@ "config_flow": true, "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter", - "homekit": {}, "iot_class": "local_polling", - "requirements": ["ultraheat-api==0.5.1"], - "ssdp": [], - "zeroconf": [] + "requirements": ["ultraheat-api==0.5.1"] } diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index af96629742..9669648b4c 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -54,6 +54,15 @@ class HeatMeterSensorEntityDescription( HEAT_METER_SENSOR_TYPES = ( + HeatMeterSensorEntityDescription( + key="heat_usage_mwh", + icon="mdi:fire", + name="Heat usage MWh", + native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda res: res.heat_usage_mwh, + ), HeatMeterSensorEntityDescription( key="volume_usage_m3", icon="mdi:fire", @@ -61,7 +70,7 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL, - value_fn=lambda res: getattr(res, "volume_usage_m3", None), + value_fn=lambda res: res.volume_usage_m3, ), HeatMeterSensorEntityDescription( key="heat_usage_gj", @@ -70,15 +79,25 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, - value_fn=lambda res: getattr(res, "heat_usage_gj", None), + value_fn=lambda res: res.heat_usage_gj, + ), + HeatMeterSensorEntityDescription( + key="heat_previous_year_mwh", + icon="mdi:fire", + name="Heat previous year MWh", + native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: res.heat_previous_year_mwh, ), HeatMeterSensorEntityDescription( key="heat_previous_year_gj", icon="mdi:fire", name="Heat previous year GJ", native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, + device_class=SensorDeviceClass.ENERGY, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "heat_previous_year_gj", None), + value_fn=lambda res: res.heat_previous_year_gj, ), HeatMeterSensorEntityDescription( key="volume_previous_year_m3", @@ -87,28 +106,28 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "volume_previous_year_m3", None), + value_fn=lambda res: res.volume_previous_year_m3, ), HeatMeterSensorEntityDescription( key="ownership_number", name="Ownership number", icon="mdi:identifier", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "ownership_number", None), + value_fn=lambda res: res.ownership_number, ), HeatMeterSensorEntityDescription( key="error_number", name="Error number", icon="mdi:home-alert", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "error_number", None), + value_fn=lambda res: res.error_number, ), HeatMeterSensorEntityDescription( key="device_number", name="Device number", icon="mdi:identifier", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "device_number", None), + value_fn=lambda res: res.device_number, ), HeatMeterSensorEntityDescription( key="measurement_period_minutes", @@ -116,7 +135,7 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.MINUTES, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "measurement_period_minutes", None), + value_fn=lambda res: res.measurement_period_minutes, ), HeatMeterSensorEntityDescription( key="power_max_kw", @@ -124,7 +143,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "power_max_kw", None), + value_fn=lambda res: res.power_max_kw, ), HeatMeterSensorEntityDescription( key="power_max_previous_year_kw", @@ -132,7 +151,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "power_max_previous_year_kw", None), + value_fn=lambda res: res.power_max_previous_year_kw, ), HeatMeterSensorEntityDescription( key="flowrate_max_m3ph", @@ -140,7 +159,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, icon="mdi:water-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "flowrate_max_m3ph", None), + value_fn=lambda res: res.flowrate_max_m3ph, ), HeatMeterSensorEntityDescription( key="flowrate_max_previous_year_m3ph", @@ -148,7 +167,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, icon="mdi:water-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "flowrate_max_previous_year_m3ph", None), + value_fn=lambda res: res.flowrate_max_previous_year_m3ph, ), HeatMeterSensorEntityDescription( key="return_temperature_max_c", @@ -156,7 +175,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "return_temperature_max_c", None), + value_fn=lambda res: res.return_temperature_max_c, ), HeatMeterSensorEntityDescription( key="return_temperature_max_previous_year_c", @@ -164,9 +183,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr( - res, "return_temperature_max_previous_year_c", None - ), + value_fn=lambda res: res.return_temperature_max_previous_year_c, ), HeatMeterSensorEntityDescription( key="flow_temperature_max_c", @@ -174,7 +191,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "flow_temperature_max_c", None), + value_fn=lambda res: res.flow_temperature_max_c, ), HeatMeterSensorEntityDescription( key="flow_temperature_max_previous_year_c", @@ -182,7 +199,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "flow_temperature_max_previous_year_c", None), + value_fn=lambda res: res.flow_temperature_max_previous_year_c, ), HeatMeterSensorEntityDescription( key="operating_hours", @@ -190,7 +207,7 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "operating_hours", None), + value_fn=lambda res: res.operating_hours, ), HeatMeterSensorEntityDescription( key="flow_hours", @@ -198,7 +215,7 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "flow_hours", None), + value_fn=lambda res: res.flow_hours, ), HeatMeterSensorEntityDescription( key="fault_hours", @@ -206,7 +223,7 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "fault_hours", None), + value_fn=lambda res: res.fault_hours, ), HeatMeterSensorEntityDescription( key="fault_hours_previous_year", @@ -214,21 +231,21 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "fault_hours_previous_year", None), + value_fn=lambda res: res.fault_hours_previous_year, ), HeatMeterSensorEntityDescription( key="yearly_set_day", name="Yearly set day", icon="mdi:clock-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "yearly_set_day", None), + value_fn=lambda res: res.yearly_set_day, ), HeatMeterSensorEntityDescription( key="monthly_set_day", name="Monthly set day", icon="mdi:clock-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "monthly_set_day", None), + value_fn=lambda res: res.monthly_set_day, ), HeatMeterSensorEntityDescription( key="meter_date_time", @@ -246,13 +263,13 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, icon="mdi:water-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "measuring_range_m3ph", None), + value_fn=lambda res: res.measuring_range_m3ph, ), HeatMeterSensorEntityDescription( key="settings_and_firmware", name="Settings and firmware", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "settings_and_firmware", None), + value_fn=lambda res: res.settings_and_firmware, ), ) @@ -276,7 +293,6 @@ async def async_setup_entry( ) sensors = [] - for description in HEAT_METER_SENSOR_TYPES: sensors.append(HeatMeterSensor(coordinator, description, device)) @@ -305,6 +321,14 @@ class HeatMeterSensor( self.entity_description = description self._attr_device_info = device + if ( + description.native_unit_of_measurement + in {UnitOfEnergy.GIGA_JOULE, UnitOfEnergy.MEGA_WATT_HOUR} + and self.native_value is None + ): + # Some meters will return MWh, others will return GJ. + self._attr_entity_registry_enabled_default = False + @property def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" diff --git a/homeassistant/components/lastfm/manifest.json b/homeassistant/components/lastfm/manifest.json index 2998047a7a..392da95a2a 100644 --- a/homeassistant/components/lastfm/manifest.json +++ b/homeassistant/components/lastfm/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/lastfm", "iot_class": "cloud_polling", "loggers": ["pylast"], - "requirements": ["pylast==4.2.1"] + "requirements": ["pylast==5.1.0"] } diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 497ccf817b..a25171f9c2 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -3,10 +3,8 @@ from __future__ import annotations import hashlib import logging -import re -import pylast as lastfm -from pylast import WSError +from pylast import LastFMNetwork, Track, User, WSError import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -16,7 +14,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) +LOGGER = logging.getLogger(__name__) + +CONF_USERS = "users" ATTR_LAST_PLAYED = "last_played" ATTR_PLAY_COUNT = "play_count" @@ -24,10 +24,6 @@ ATTR_TOP_PLAYED = "top_played" STATE_NOT_SCROBBLING = "Not Scrobbling" -CONF_USERS = "users" - -ICON = "mdi:radio-fm" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, @@ -36,6 +32,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +def format_track(track: Track) -> str: + """Format the track.""" + return f"{track.artist} - {track.title}" + + def setup_platform( hass: HomeAssistant, config: ConfigType, @@ -43,92 +44,46 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Last.fm sensor platform.""" - api_key = config[CONF_API_KEY] - users = config[CONF_USERS] - - lastfm_api = lastfm.LastFMNetwork(api_key=api_key) - + lastfm_api = LastFMNetwork(api_key=config[CONF_API_KEY]) entities = [] - for username in users: + for username in config[CONF_USERS]: try: - lastfm_api.get_user(username).get_image() - entities.append(LastfmSensor(username, lastfm_api)) - except WSError as error: - _LOGGER.error(error) + user = lastfm_api.get_user(username) + entities.append(LastFmSensor(user, lastfm_api)) + except WSError as exc: + LOGGER.error("Failed to load LastFM user `%s`: %r", username, exc) return - add_entities(entities, True) -class LastfmSensor(SensorEntity): +class LastFmSensor(SensorEntity): """A class for the Last.fm account.""" _attr_attribution = "Data provided by Last.fm" + _attr_icon = "mdi:radio-fm" - def __init__(self, user, lastfm_api): + def __init__(self, user: User, lastfm_api: LastFMNetwork) -> None: """Initialize the sensor.""" - self._unique_id = hashlib.sha256(user.encode("utf-8")).hexdigest() - self._user = lastfm_api.get_user(user) - self._name = user - self._lastfm = lastfm_api - self._state = "Not Scrobbling" - self._playcount = None - self._lastplayed = None - self._topplayed = None - self._cover = None - - @property - def unique_id(self): - """Return the unique ID of the sensor.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state + self._attr_unique_id = hashlib.sha256(user.name.encode("utf-8")).hexdigest() + self._attr_name = user.name + self._user = user def update(self) -> None: """Update device state.""" - self._cover = self._user.get_image() - self._playcount = self._user.get_playcount() - - if recent_tracks := self._user.get_recent_tracks(limit=2): - last = recent_tracks[0] - self._lastplayed = f"{last.track.artist} - {last.track.title}" - + self._attr_entity_picture = self._user.get_image() + if now_playing := self._user.get_now_playing(): + self._attr_native_value = format_track(now_playing) + else: + self._attr_native_value = STATE_NOT_SCROBBLING + top_played = None if top_tracks := self._user.get_top_tracks(limit=1): - top = str(top_tracks[0]) - if (toptitle := re.search("', '(.+?)',", top)) and ( - topartist := re.search("'(.+?)',", top) - ): - self._topplayed = f"{topartist.group(1)} - {toptitle.group(1)}" - - if (now_playing := self._user.get_now_playing()) is None: - self._state = STATE_NOT_SCROBBLING - return - - self._state = f"{now_playing.artist} - {now_playing.title}" - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_LAST_PLAYED: self._lastplayed, - ATTR_PLAY_COUNT: self._playcount, - ATTR_TOP_PLAYED: self._topplayed, + top_played = format_track(top_tracks[0].item) + last_played = None + if last_tracks := self._user.get_recent_tracks(limit=1): + last_played = format_track(last_tracks[0].track) + play_count = self._user.get_playcount() + self._attr_extra_state_attributes = { + ATTR_LAST_PLAYED: last_played, + ATTR_PLAY_COUNT: play_count, + ATTR_TOP_PLAYED: top_played, } - - @property - def entity_picture(self): - """Avatar of the user.""" - return self._cover - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 6ba8bf3286..4716519ac1 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble/", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==0.3.1", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==0.4.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 0072609505..a19680ffa5 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble/", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==0.3.1", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==0.4.0", "led-ble==1.0.0"] } diff --git a/homeassistant/components/lidarr/const.py b/homeassistant/components/lidarr/const.py index feadedb6d4..ccf56db802 100644 --- a/homeassistant/components/lidarr/const.py +++ b/homeassistant/components/lidarr/const.py @@ -17,8 +17,6 @@ BYTE_SIZES = [ ] # Defaults -DEFAULT_DAYS = "1" -DEFAULT_HOST = "localhost" DEFAULT_NAME = "Lidarr" DEFAULT_UNIT = UnitOfInformation.GIGABYTES DEFAULT_MAX_RECORDS = 20 diff --git a/homeassistant/components/lidarr/manifest.json b/homeassistant/components/lidarr/manifest.json index 717800e311..30672afdf4 100644 --- a/homeassistant/components/lidarr/manifest.json +++ b/homeassistant/components/lidarr/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["aiopyarr"], - "requirements": ["aiopyarr==22.11.0"] + "requirements": ["aiopyarr==23.4.0"] } diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 1bdbc618fd..f0c38cdfb1 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -210,7 +210,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator.async_setup() try: await coordinator.async_config_entry_first_refresh() - await coordinator.sensor_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: connection.async_stop() raise diff --git a/homeassistant/components/lifx/binary_sensor.py b/homeassistant/components/lifx/binary_sensor.py index 1632cac3d1..110661b1c5 100644 --- a/homeassistant/components/lifx/binary_sensor.py +++ b/homeassistant/components/lifx/binary_sensor.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, HEV_CYCLE_STATE -from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator -from .entity import LIFXSensorEntity +from .coordinator import LIFXUpdateCoordinator +from .entity import LIFXEntity from .util import lifx_features HEV_CYCLE_STATE_SENSOR = BinarySensorEntityDescription( @@ -32,29 +32,24 @@ async def async_setup_entry( if lifx_features(coordinator.device)["hev"]: async_add_entities( - [ - LIFXHevCycleBinarySensorEntity( - coordinator=coordinator.sensor_coordinator, - description=HEV_CYCLE_STATE_SENSOR, - ) - ] + [LIFXHevCycleBinarySensorEntity(coordinator, HEV_CYCLE_STATE_SENSOR)] ) -class LIFXHevCycleBinarySensorEntity(LIFXSensorEntity, BinarySensorEntity): +class LIFXHevCycleBinarySensorEntity(LIFXEntity, BinarySensorEntity): """LIFX HEV cycle state binary sensor.""" _attr_has_entity_name = True def __init__( self, - coordinator: LIFXSensorUpdateCoordinator, + coordinator: LIFXUpdateCoordinator, description: BinarySensorEntityDescription, ) -> None: """Initialise the sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" self._async_update_attrs() @callback diff --git a/homeassistant/components/lifx/button.py b/homeassistant/components/lifx/button.py index 636f90aaf3..b5f5373b3e 100644 --- a/homeassistant/components/lifx/button.py +++ b/homeassistant/components/lifx/button.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, IDENTIFY, RESTART -from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator -from .entity import LIFXSensorEntity +from .coordinator import LIFXUpdateCoordinator +from .entity import LIFXEntity RESTART_BUTTON_DESCRIPTION = ButtonEntityDescription( key=RESTART, @@ -38,22 +38,21 @@ async def async_setup_entry( domain_data = hass.data[DOMAIN] coordinator: LIFXUpdateCoordinator = domain_data[entry.entry_id] async_add_entities( - cls(coordinator.sensor_coordinator) - for cls in (LIFXRestartButton, LIFXIdentifyButton) + [LIFXRestartButton(coordinator), LIFXIdentifyButton(coordinator)] ) -class LIFXButton(LIFXSensorEntity, ButtonEntity): +class LIFXButton(LIFXEntity, ButtonEntity): """Base LIFX button.""" _attr_has_entity_name: bool = True _attr_should_poll: bool = False - def __init__(self, coordinator: LIFXSensorUpdateCoordinator) -> None: + def __init__(self, coordinator: LIFXUpdateCoordinator) -> None: """Initialise a LIFX button.""" super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator.parent.serial_number}_{self.entity_description.key}" + f"{coordinator.serial_number}_{self.entity_description.key}" ) diff --git a/homeassistant/components/lifx/config_flow.py b/homeassistant/components/lifx/config_flow.py index 56a88c8980..22ac66e3bc 100644 --- a/homeassistant/components/lifx/config_flow.py +++ b/homeassistant/components/lifx/config_flow.py @@ -18,12 +18,19 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import DiscoveryInfoType -from .const import _LOGGER, CONF_SERIAL, DOMAIN, TARGET_ANY +from .const import ( + _LOGGER, + CONF_SERIAL, + DEFAULT_ATTEMPTS, + DOMAIN, + OVERALL_TIMEOUT, + TARGET_ANY, +) from .discovery import async_discover_devices from .util import ( async_entry_is_legacy, - async_execute_lifx, async_get_legacy_entry, + async_multi_execute_lifx_with_retries, formatted_serial, lifx_features, mac_matches_serial_number, @@ -225,13 +232,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # get_version required for lifx_features() # get_label required to log the name of the device # get_group required to populate suggested areas - messages = await asyncio.gather( - *[ - async_execute_lifx(device.get_hostfirmware), - async_execute_lifx(device.get_version), - async_execute_lifx(device.get_label), - async_execute_lifx(device.get_group), - ] + messages = await async_multi_execute_lifx_with_retries( + [ + device.get_hostfirmware, + device.get_version, + device.get_label, + device.get_group, + ], + DEFAULT_ATTEMPTS, + OVERALL_TIMEOUT, ) except asyncio.TimeoutError: return None diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index af9dfa5a27..2208537b59 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -7,11 +7,22 @@ DOMAIN = "lifx" TARGET_ANY = "00:00:00:00:00:00" DISCOVERY_INTERVAL = 10 -MESSAGE_TIMEOUT = 1.65 -MESSAGE_RETRIES = 5 -OVERALL_TIMEOUT = 9 +# The number of seconds before we will no longer accept a response +# to a message and consider it invalid +MESSAGE_TIMEOUT = 18 +# Disable the retries in the library since they are not spaced out +# enough to account for WiFi and UDP dropouts +MESSAGE_RETRIES = 1 +OVERALL_TIMEOUT = 15 UNAVAILABLE_GRACE = 90 +# The number of times to retry a request message +DEFAULT_ATTEMPTS = 5 +# The maximum time to wait for a bulb to respond to an update +MAX_UPDATE_TIME = 90 +# The number of tries to send each request message to a bulb during an update +MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE = 5 + CONF_LABEL = "label" CONF_SERIAL = "serial" @@ -50,4 +61,5 @@ INFRARED_BRIGHTNESS_VALUES_MAP = { } DATA_LIFX_MANAGER = "lifx_manager" + _LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 038f93c1e8..66cea18f11 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -28,20 +28,25 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( _LOGGER, ATTR_REMAINING, + DEFAULT_ATTEMPTS, DOMAIN, IDENTIFY_WAVEFORM, + MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE, + MAX_UPDATE_TIME, MESSAGE_RETRIES, MESSAGE_TIMEOUT, + OVERALL_TIMEOUT, TARGET_ANY, UNAVAILABLE_GRACE, ) from .util import ( async_execute_lifx, + async_multi_execute_lifx_with_retries, get_real_mac_addr, infrared_brightness_option_to_value, infrared_brightness_value_to_option, @@ -49,7 +54,6 @@ from .util import ( ) LIGHT_UPDATE_INTERVAL = 10 -SENSOR_UPDATE_INTERVAL = 30 REQUEST_REFRESH_DELAY = 0.35 LIFX_IDENTIFY_DELAY = 3.0 RSSI_DBM_FW = AwesomeVersion("2.77") @@ -79,7 +83,9 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): self.device: Light = connection.device self.lock = asyncio.Lock() self.active_effect = FirmwareEffect.OFF - self.sensor_coordinator = LIFXSensorUpdateCoordinator(hass, self, title) + self._update_rssi: bool = False + self._rssi: int = 0 + self.last_used_theme: str = "" super().__init__( hass, @@ -100,6 +106,24 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): self.device.retry_count = MESSAGE_RETRIES self.device.unregister_timeout = UNAVAILABLE_GRACE + @property + def rssi(self) -> int: + """Return stored RSSI value.""" + return self._rssi + + @property + def rssi_uom(self) -> str: + """Return the RSSI unit of measurement.""" + if AwesomeVersion(self.device.host_firmware_version) <= RSSI_DBM_FW: + return SIGNAL_STRENGTH_DECIBELS + + return SIGNAL_STRENGTH_DECIBELS_MILLIWATT + + @property + def current_infrared_brightness(self) -> str | None: + """Return the current infrared brightness as a string.""" + return infrared_brightness_value_to_option(self.device.infrared_brightness) + @property def serial_number(self) -> str: """Return the internal mac address.""" @@ -166,51 +190,84 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): platform, DOMAIN, f"{self.serial_number}_{key}" ) + async def _async_populate_device_info(self) -> None: + """Populate device info.""" + methods: list[Callable] = [] + device = self.device + if self.device.host_firmware_version is None: + methods.append(device.get_hostfirmware) + if self.device.product is None: + methods.append(device.get_version) + if self.device.group is None: + methods.append(device.get_group) + assert methods, "Device info already populated" + await async_multi_execute_lifx_with_retries( + methods, DEFAULT_ATTEMPTS, OVERALL_TIMEOUT + ) + + @callback + def _async_build_color_zones_update_requests(self) -> list[Callable]: + """Build a color zones update request.""" + device = self.device + return [ + partial(device.get_color_zones, start_index=zone) + for zone in range(0, len(device.color_zones), 8) + ] + async def _async_update_data(self) -> None: """Fetch all device data from the api.""" - async with self.lock: - if self.device.host_firmware_version is None: - self.device.get_hostfirmware() - if self.device.product is None: - self.device.get_version() - if self.device.group is None: - self.device.get_group() + device = self.device + if ( + device.host_firmware_version is None + or device.product is None + or device.group is None + ): + await self._async_populate_device_info() - response = await async_execute_lifx(self.device.get_color) + num_zones = len(device.color_zones) if device.color_zones is not None else 0 + features = lifx_features(self.device) + is_extended_multizone = features["extended_multizone"] + is_legacy_multizone = not is_extended_multizone and features["multizone"] + update_rssi = self._update_rssi + methods: list[Callable] = [self.device.get_color] + if update_rssi: + methods.append(self.device.get_wifiinfo) + if is_extended_multizone: + methods.append(self.device.get_extended_color_zones) + elif is_legacy_multizone: + methods.extend(self._async_build_color_zones_update_requests()) + if is_extended_multizone or is_legacy_multizone: + methods.append(self.device.get_multizone_effect) + if features["hev"]: + methods.append(self.device.get_hev_cycle) + if features["infrared"]: + methods.append(self.device.get_infrared) - if self.device.product is None: - raise UpdateFailed( - f"Failed to fetch get version from device: {self.device.ip_addr}" - ) + responses = await async_multi_execute_lifx_with_retries( + methods, MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE, MAX_UPDATE_TIME + ) + # device.mac_addr is not the mac_address, its the serial number + if device.mac_addr == TARGET_ANY: + device.mac_addr = responses[0].target_addr - # device.mac_addr is not the mac_address, its the serial number - if self.device.mac_addr == TARGET_ANY: - self.device.mac_addr = response.target_addr + if update_rssi: + # We always send the rssi request second + self._rssi = int(floor(10 * log10(responses[1].signal) + 0.5)) - # Update extended multizone devices - if lifx_features(self.device)["extended_multizone"]: - await self.async_get_extended_color_zones() - await self.async_get_multizone_effect() - # use legacy methods for older devices - elif lifx_features(self.device)["multizone"]: - await self.async_get_color_zones() - await self.async_get_multizone_effect() + if is_extended_multizone or is_legacy_multizone: + self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")] + if is_legacy_multizone and num_zones != len(device.color_zones): + # The number of zones has changed so we need + # to update the zones again. This happens rarely. + await self.async_get_color_zones() async def async_get_color_zones(self) -> None: """Get updated color information for each zone.""" - zone = 0 - top = 1 - while zone < top: - # Each get_color_zones can update 8 zones at once - resp = await async_execute_lifx( - partial(self.device.get_color_zones, start_index=zone) - ) - zone += 8 - top = resp.count - - # We only await multizone responses so don't ask for just one - if zone == top - 1: - zone -= 1 + await async_multi_execute_lifx_with_retries( + self._async_build_color_zones_update_requests(), + DEFAULT_ATTEMPTS, + OVERALL_TIMEOUT, + ) async def async_get_extended_color_zones(self) -> None: """Get updated color information for all zones.""" @@ -294,11 +351,6 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): ) ) - async def async_get_multizone_effect(self) -> None: - """Update the device firmware effect running state.""" - await async_execute_lifx(self.device.get_multizone_effect) - self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")] - async def async_set_multizone_effect( self, effect: str, @@ -357,64 +409,6 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): """Return the enum value of the currently active firmware effect.""" return self.active_effect.value - -class LIFXSensorUpdateCoordinator(DataUpdateCoordinator[None]): - """DataUpdateCoordinator to gather data for a specific lifx device.""" - - def __init__( - self, - hass: HomeAssistant, - parent: LIFXUpdateCoordinator, - title: str, - ) -> None: - """Initialize DataUpdateCoordinator.""" - self.parent: LIFXUpdateCoordinator = parent - self.device: Light = parent.device - self._update_rssi: bool = False - self._rssi: int = 0 - self.last_used_theme: str = "" - - super().__init__( - hass, - _LOGGER, - name=f"{title} Sensors ({self.device.ip_addr})", - update_interval=timedelta(seconds=SENSOR_UPDATE_INTERVAL), - # Refresh immediately because the changes are not visible - request_refresh_debouncer=Debouncer( - hass, _LOGGER, cooldown=0, immediate=True - ), - ) - - @property - def rssi(self) -> int: - """Return stored RSSI value.""" - return self._rssi - - @property - def rssi_uom(self) -> str: - """Return the RSSI unit of measurement.""" - if AwesomeVersion(self.device.host_firmware_version) <= RSSI_DBM_FW: - return SIGNAL_STRENGTH_DECIBELS - - return SIGNAL_STRENGTH_DECIBELS_MILLIWATT - - @property - def current_infrared_brightness(self) -> str | None: - """Return the current infrared brightness as a string.""" - return infrared_brightness_value_to_option(self.device.infrared_brightness) - - async def _async_update_data(self) -> None: - """Fetch all device data from the api.""" - - if self._update_rssi is True: - await self.async_update_rssi() - - if lifx_features(self.device)["hev"]: - await self.async_get_hev_cycle() - - if lifx_features(self.device)["infrared"]: - await async_execute_lifx(self.device.get_infrared) - async def async_set_infrared_brightness(self, option: str) -> None: """Set infrared brightness.""" infrared_brightness = infrared_brightness_option_to_value(option) @@ -425,13 +419,13 @@ class LIFXSensorUpdateCoordinator(DataUpdateCoordinator[None]): bulb: Light = self.device if bulb.power_level: # just flash the bulb for three seconds - await self.parent.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) + await self.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) return # Turn the bulb on first, flash for 3 seconds, then turn off - await self.parent.async_set_power(state=True, duration=1) - await self.parent.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) + await self.async_set_power(state=True, duration=1) + await self.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) await asyncio.sleep(LIFX_IDENTIFY_DELAY) - await self.parent.async_set_power(state=False, duration=1) + await self.async_set_power(state=False, duration=1) def async_enable_rssi_updates(self) -> Callable[[], None]: """Enable RSSI signal strength updates.""" @@ -444,22 +438,12 @@ class LIFXSensorUpdateCoordinator(DataUpdateCoordinator[None]): self._update_rssi = True return _async_disable_rssi_updates - async def async_update_rssi(self) -> None: - """Update RSSI value.""" - resp = await async_execute_lifx(self.device.get_wifiinfo) - self._rssi = int(floor(10 * log10(resp.signal) + 0.5)) - def async_get_hev_cycle_state(self) -> bool | None: """Return the current HEV cycle state.""" if self.device.hev_cycle is None: return None return bool(self.device.hev_cycle.get(ATTR_REMAINING, 0) > 0) - async def async_get_hev_cycle(self) -> None: - """Update the HEV cycle status from a LIFX Clean bulb.""" - if lifx_features(self.device)["hev"]: - await async_execute_lifx(self.device.get_hev_cycle) - async def async_set_hev_cycle_state(self, enable: bool, duration: int = 0) -> None: """Start or stop an HEV cycle on a LIFX Clean bulb.""" if lifx_features(self.device)["hev"]: @@ -471,4 +455,4 @@ class LIFXSensorUpdateCoordinator(DataUpdateCoordinator[None]): """Apply the selected theme to the device.""" self.last_used_theme = theme_name theme = ThemeLibrary().get_theme(theme_name) - await ThemePainter(self.hass.loop).paint(theme, [self.parent.device]) + await ThemePainter(self.hass.loop).paint(theme, [self.device]) diff --git a/homeassistant/components/lifx/entity.py b/homeassistant/components/lifx/entity.py index 63996d6002..a86bda53cf 100644 --- a/homeassistant/components/lifx/entity.py +++ b/homeassistant/components/lifx/entity.py @@ -8,7 +8,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator +from .coordinator import LIFXUpdateCoordinator class LIFXEntity(CoordinatorEntity[LIFXUpdateCoordinator]): @@ -27,21 +27,3 @@ class LIFXEntity(CoordinatorEntity[LIFXUpdateCoordinator]): sw_version=self.bulb.host_firmware_version, suggested_area=self.bulb.group, ) - - -class LIFXSensorEntity(CoordinatorEntity[LIFXSensorUpdateCoordinator]): - """Representation of a LIFX sensor entity with a sensor coordinator.""" - - def __init__(self, coordinator: LIFXSensorUpdateCoordinator) -> None: - """Initialise the sensor.""" - super().__init__(coordinator) - self.bulb = coordinator.parent.device - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.parent.serial_number)}, - connections={(dr.CONNECTION_NETWORK_MAC, coordinator.parent.mac_address)}, - manufacturer="LIFX", - name=coordinator.parent.label, - model=products.product_map.get(self.bulb.product, "LIFX Bulb"), - sw_version=self.bulb.host_firmware_version, - suggested_area=self.bulb.group, - ) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index eb62cb8016..dd4e50d8f1 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -206,61 +206,60 @@ class LIFXLight(LIFXEntity, LightEntity): async def set_state(self, **kwargs: Any) -> None: """Set a color on the light and turn it on/off.""" self.coordinator.async_set_updated_data(None) - async with self.coordinator.lock: - # Cancel any pending refreshes - bulb = self.bulb + # Cancel any pending refreshes + bulb = self.bulb - await self.effects_conductor.stop([bulb]) + await self.effects_conductor.stop([bulb]) - if ATTR_EFFECT in kwargs: - await self.default_effect(**kwargs) - return + if ATTR_EFFECT in kwargs: + await self.default_effect(**kwargs) + return - if ATTR_INFRARED in kwargs: - infrared_entity_id = self.coordinator.async_get_entity_id( - Platform.SELECT, INFRARED_BRIGHTNESS - ) - _LOGGER.warning( - ( - "The 'infrared' attribute of 'lifx.set_state' is deprecated:" - " call 'select.select_option' targeting '%s' instead" - ), - infrared_entity_id, - ) - bulb.set_infrared(convert_8_to_16(kwargs[ATTR_INFRARED])) + if ATTR_INFRARED in kwargs: + infrared_entity_id = self.coordinator.async_get_entity_id( + Platform.SELECT, INFRARED_BRIGHTNESS + ) + _LOGGER.warning( + ( + "The 'infrared' attribute of 'lifx.set_state' is deprecated:" + " call 'select.select_option' targeting '%s' instead" + ), + infrared_entity_id, + ) + bulb.set_infrared(convert_8_to_16(kwargs[ATTR_INFRARED])) - if ATTR_TRANSITION in kwargs: - fade = int(kwargs[ATTR_TRANSITION] * 1000) - else: - fade = 0 + if ATTR_TRANSITION in kwargs: + fade = int(kwargs[ATTR_TRANSITION] * 1000) + else: + fade = 0 - # These are both False if ATTR_POWER is not set - power_on = kwargs.get(ATTR_POWER, False) - power_off = not kwargs.get(ATTR_POWER, True) + # These are both False if ATTR_POWER is not set + power_on = kwargs.get(ATTR_POWER, False) + power_off = not kwargs.get(ATTR_POWER, True) - hsbk = find_hsbk(self.hass, **kwargs) + hsbk = find_hsbk(self.hass, **kwargs) - if not self.is_on: - if power_off: - await self.set_power(False) - # If fading on with color, set color immediately - if hsbk and power_on: - await self.set_color(hsbk, kwargs) - await self.set_power(True, duration=fade) - elif hsbk: - await self.set_color(hsbk, kwargs, duration=fade) - elif power_on: - await self.set_power(True, duration=fade) - else: - if power_on: - await self.set_power(True) - if hsbk: - await self.set_color(hsbk, kwargs, duration=fade) - if power_off: - await self.set_power(False, duration=fade) + if not self.is_on: + if power_off: + await self.set_power(False) + # If fading on with color, set color immediately + if hsbk and power_on: + await self.set_color(hsbk, kwargs) + await self.set_power(True, duration=fade) + elif hsbk: + await self.set_color(hsbk, kwargs, duration=fade) + elif power_on: + await self.set_power(True, duration=fade) + else: + if power_on: + await self.set_power(True) + if hsbk: + await self.set_color(hsbk, kwargs, duration=fade) + if power_off: + await self.set_power(False, duration=fade) - # Avoid state ping-pong by holding off updates as the state settles - await asyncio.sleep(LIFX_STATE_SETTLE_DELAY) + # Avoid state ping-pong by holding off updates as the state settles + await asyncio.sleep(LIFX_STATE_SETTLE_DELAY) # Update when the transition starts and ends await self.update_during_transition(fade) @@ -274,9 +273,7 @@ class LIFXLight(LIFXEntity, LightEntity): "This device does not support setting HEV cycle state" ) - await self.coordinator.sensor_coordinator.async_set_hev_cycle_state( - power, duration or 0 - ) + await self.coordinator.async_set_hev_cycle_state(power, duration or 0) await self.update_during_transition(duration or 0) async def set_power( diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 0019f68ab9..e867bb65eb 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -1,7 +1,7 @@ { "domain": "lifx", "name": "LIFX", - "codeowners": ["@bdraco", "@Djelibeybi"], + "codeowners": ["@bdraco"], "config_flow": true, "dependencies": ["network"], "dhcp": [ @@ -41,8 +41,8 @@ "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "quality_scale": "platinum", "requirements": [ - "aiolifx==0.8.9", - "aiolifx_effects==0.3.1", - "aiolifx_themes==0.4.0" + "aiolifx==0.8.10", + "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 1b58ea0468..9ad457e027 100644 --- a/homeassistant/components/lifx/select.py +++ b/homeassistant/components/lifx/select.py @@ -15,8 +15,8 @@ from .const import ( INFRARED_BRIGHTNESS, INFRARED_BRIGHTNESS_VALUES_MAP, ) -from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator -from .entity import LIFXSensorEntity +from .coordinator import LIFXUpdateCoordinator +from .entity import LIFXEntity from .util import lifx_features THEME_NAMES = [theme_name.lower() for theme_name in ThemeLibrary().themes] @@ -42,39 +42,33 @@ async def async_setup_entry( """Set up LIFX from a config entry.""" coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - entities: list[LIFXSensorEntity] = [] + entities: list[LIFXEntity] = [] if lifx_features(coordinator.device)["infrared"]: entities.append( - LIFXInfraredBrightnessSelectEntity( - coordinator.sensor_coordinator, description=INFRARED_BRIGHTNESS_ENTITY - ) + LIFXInfraredBrightnessSelectEntity(coordinator, INFRARED_BRIGHTNESS_ENTITY) ) if lifx_features(coordinator.device)["multizone"] is True: - entities.append( - LIFXThemeSelectEntity( - coordinator.sensor_coordinator, description=THEME_ENTITY - ) - ) + entities.append(LIFXThemeSelectEntity(coordinator, THEME_ENTITY)) async_add_entities(entities) -class LIFXInfraredBrightnessSelectEntity(LIFXSensorEntity, SelectEntity): +class LIFXInfraredBrightnessSelectEntity(LIFXEntity, SelectEntity): """LIFX Nightvision infrared brightness configuration entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: LIFXSensorUpdateCoordinator, + coordinator: LIFXUpdateCoordinator, description: SelectEntityDescription, ) -> None: """Initialise the IR brightness config entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" self._attr_current_option = coordinator.current_infrared_brightness @callback @@ -93,21 +87,21 @@ class LIFXInfraredBrightnessSelectEntity(LIFXSensorEntity, SelectEntity): await self.coordinator.async_set_infrared_brightness(option) -class LIFXThemeSelectEntity(LIFXSensorEntity, SelectEntity): +class LIFXThemeSelectEntity(LIFXEntity, SelectEntity): """Theme entity for LIFX multizone devices.""" _attr_has_entity_name = True def __init__( self, - coordinator: LIFXSensorUpdateCoordinator, + coordinator: LIFXUpdateCoordinator, description: SelectEntityDescription, ) -> None: """Initialise the theme selection entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" self._attr_current_option = None @callback diff --git a/homeassistant/components/lifx/sensor.py b/homeassistant/components/lifx/sensor.py index da03b33f52..654b528575 100644 --- a/homeassistant/components/lifx/sensor.py +++ b/homeassistant/components/lifx/sensor.py @@ -15,8 +15,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_RSSI, DOMAIN -from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator -from .entity import LIFXSensorEntity +from .coordinator import LIFXUpdateCoordinator +from .entity import LIFXEntity SCAN_INTERVAL = timedelta(seconds=30) @@ -35,24 +35,24 @@ async def async_setup_entry( ) -> None: """Set up LIFX sensor from config entry.""" coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([LIFXRssiSensor(coordinator.sensor_coordinator, RSSI_SENSOR)]) + async_add_entities([LIFXRssiSensor(coordinator, RSSI_SENSOR)]) -class LIFXRssiSensor(LIFXSensorEntity, SensorEntity): +class LIFXRssiSensor(LIFXEntity, SensorEntity): """LIFX RSSI sensor.""" _attr_has_entity_name = True def __init__( self, - coordinator: LIFXSensorUpdateCoordinator, + coordinator: LIFXUpdateCoordinator, description: SensorEntityDescription, ) -> None: """Initialise the RSSI sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" self._attr_native_unit_of_measurement = coordinator.rssi_uom @callback diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index 67190aaa59..feaeba8da8 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -4,12 +4,12 @@ from __future__ import annotations import asyncio from collections.abc import Callable +from functools import partial from typing import Any from aiolifx import products from aiolifx.aiolifx import Light from aiolifx.message import Message -import async_timeout from awesomeversion import AwesomeVersion from homeassistant.components.light import ( @@ -28,7 +28,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr import homeassistant.util.color as color_util -from .const import _LOGGER, DOMAIN, INFRARED_BRIGHTNESS_VALUES_MAP, OVERALL_TIMEOUT +from .const import ( + _LOGGER, + DEFAULT_ATTEMPTS, + DOMAIN, + INFRARED_BRIGHTNESS_VALUES_MAP, + OVERALL_TIMEOUT, +) FIX_MAC_FW = AwesomeVersion("3.70") @@ -177,21 +183,61 @@ def mac_matches_serial_number(mac_addr: str, serial_number: str) -> bool: async def async_execute_lifx(method: Callable) -> Message: - """Execute a lifx coroutine and wait for a response.""" - future: asyncio.Future[Message] = asyncio.Future() + """Execute a lifx callback method and wait for a response.""" + return ( + await async_multi_execute_lifx_with_retries( + [method], DEFAULT_ATTEMPTS, OVERALL_TIMEOUT + ) + )[0] - def _callback(bulb: Light, message: Message) -> None: - if not future.done(): - # The future will get canceled out from under - # us by async_timeout when we hit the OVERALL_TIMEOUT + +async def async_multi_execute_lifx_with_retries( + methods: list[Callable], attempts: int, overall_timeout: int +) -> list[Message]: + """Execute multiple lifx callback methods with retries and wait for a response. + + This functional will the overall timeout by the number of attempts and + wait for each method to return a result. If we don't get a result + within the split timeout, we will send all methods that did not generate + a response again. + + If we don't get a result after all attempts, we will raise an + asyncio.TimeoutError exception. + """ + loop = asyncio.get_running_loop() + futures: list[asyncio.Future] = [loop.create_future() for _ in methods] + + def _callback( + bulb: Light, message: Message | None, future: asyncio.Future[Message] + ) -> None: + if message and not future.done(): future.set_result(message) - method(callb=_callback) - result = None + timeout_per_attempt = overall_timeout / attempts - async with async_timeout.timeout(OVERALL_TIMEOUT): - result = await future + for _ in range(attempts): + for idx, method in enumerate(methods): + future = futures[idx] + if not future.done(): + method(callb=partial(_callback, future=future)) - if result is None: - raise asyncio.TimeoutError("No response from LIFX bulb") - return result + _, pending = await asyncio.wait(futures, timeout=timeout_per_attempt) + if not pending: + break + + results: list[Message] = [] + failed: list[str] = [] + for idx, future in enumerate(futures): + if not future.done() or not (result := future.result()): + method = methods[idx] + failed.append(str(getattr(method, "__name__", method))) + else: + results.append(result) + + if failed: + failed_methods = ", ".join(failed) + raise asyncio.TimeoutError( + f"{failed_methods} timed out after {attempts} attempts" + ) + + return results diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 02f6e44a70..0c3a711a73 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -68,16 +68,20 @@ ATTR_SUPPORTED_COLOR_MODES = "supported_color_modes" class ColorMode(StrEnum): """Possible light color modes.""" - UNKNOWN = "unknown" # Ambiguous color mode - ONOFF = "onoff" # Must be the only supported mode - BRIGHTNESS = "brightness" # Must be the only supported mode + UNKNOWN = "unknown" + """Ambiguous color mode""" + ONOFF = "onoff" + """Must be the only supported mode""" + BRIGHTNESS = "brightness" + """Must be the only supported mode""" COLOR_TEMP = "color_temp" HS = "hs" XY = "xy" RGB = "rgb" RGBW = "rgbw" RGBWW = "rgbww" - WHITE = "white" # Must *NOT* be the only supported mode + WHITE = "white" + """Must *NOT* be the only supported mode""" # These COLOR_MODE_* constants are deprecated as of Home Assistant 2022.5. diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index e635e80a6e..d3dcf77f32 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.1.1"] + "requirements": ["pylitterbot==2023.4.0"] } diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index bc1613f1c2..feac85ecac 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -3,10 +3,10 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass -import itertools from typing import Any, Generic, TypeVar -from pylitterbot import FeederRobot, LitterRobot +from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot +from pylitterbot.robot.litterrobot4 import BrightnessLevel from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -18,14 +18,21 @@ from .const import DOMAIN from .entity import LitterRobotEntity, _RobotT from .hub import LitterRobotHub -_CastTypeT = TypeVar("_CastTypeT", int, float) +_CastTypeT = TypeVar("_CastTypeT", int, float, str) + +BRIGHTNESS_LEVEL_ICON_MAP: dict[BrightnessLevel | None, str] = { + BrightnessLevel.LOW: "mdi:lightbulb-on-30", + BrightnessLevel.MEDIUM: "mdi:lightbulb-on-50", + BrightnessLevel.HIGH: "mdi:lightbulb-on", + None: "mdi:lightbulb-question", +} @dataclass class RequiredKeysMixin(Generic[_RobotT, _CastTypeT]): """A class that describes robot select entity required keys.""" - current_fn: Callable[[_RobotT], _CastTypeT] + current_fn: Callable[[_RobotT], _CastTypeT | None] options_fn: Callable[[_RobotT], list[_CastTypeT]] select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]] @@ -37,26 +44,42 @@ class RobotSelectEntityDescription( """A class that describes robot select entities.""" entity_category: EntityCategory = EntityCategory.CONFIG + icon_fn: Callable[[_RobotT], str] | None = None -LITTER_ROBOT_SELECT = RobotSelectEntityDescription[LitterRobot, int]( - key="cycle_delay", - name="Clean cycle wait time minutes", - icon="mdi:timer-outline", - unit_of_measurement=UnitOfTime.MINUTES, - current_fn=lambda robot: robot.clean_cycle_wait_time_minutes, - options_fn=lambda robot: robot.VALID_WAIT_TIMES, - select_fn=lambda robot, option: robot.set_wait_time(int(option)), -) -FEEDER_ROBOT_SELECT = RobotSelectEntityDescription[FeederRobot, float]( - key="meal_insert_size", - name="Meal insert size", - icon="mdi:scale", - unit_of_measurement="cups", - current_fn=lambda robot: robot.meal_insert_size, - options_fn=lambda robot: robot.VALID_MEAL_INSERT_SIZES, - select_fn=lambda robot, option: robot.set_meal_insert_size(float(option)), -) +ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { + LitterRobot: RobotSelectEntityDescription[LitterRobot, int]( + key="cycle_delay", + name="Clean cycle wait time minutes", + icon="mdi:timer-outline", + unit_of_measurement=UnitOfTime.MINUTES, + current_fn=lambda robot: robot.clean_cycle_wait_time_minutes, + options_fn=lambda robot: robot.VALID_WAIT_TIMES, + select_fn=lambda robot, opt: robot.set_wait_time(int(opt)), + ), + 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 + else None, + options_fn=lambda _: [level.name.lower() for level in BrightnessLevel], + select_fn=lambda robot, opt: robot.set_panel_brightness( + BrightnessLevel[opt.upper()] + ), + icon_fn=lambda robot: BRIGHTNESS_LEVEL_ICON_MAP[robot.panel_brightness], + ), + FeederRobot: RobotSelectEntityDescription[FeederRobot, float]( + key="meal_insert_size", + name="Meal insert size", + icon="mdi:scale", + unit_of_measurement="cups", + current_fn=lambda robot: robot.meal_insert_size, + options_fn=lambda robot: robot.VALID_MEAL_INSERT_SIZES, + select_fn=lambda robot, opt: robot.set_meal_insert_size(float(opt)), + ), +} async def async_setup_entry( @@ -66,22 +89,16 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot selects using config entry.""" hub: LitterRobotHub = hass.data[DOMAIN][config_entry.entry_id] - entities: list[LitterRobotSelect] = list( - itertools.chain( - ( - LitterRobotSelect(robot=robot, hub=hub, description=LITTER_ROBOT_SELECT) - for robot in hub.litter_robots() - ), - ( - LitterRobotSelect(robot=robot, hub=hub, description=FEEDER_ROBOT_SELECT) - for robot in hub.feeder_robots() - ), - ) - ) + entities = [ + LitterRobotSelectEntity(robot=robot, hub=hub, description=description) + for robot in hub.account.robots + for robot_type, description in ROBOT_SELECT_MAP.items() + if isinstance(robot, robot_type) + ] async_add_entities(entities) -class LitterRobotSelect( +class LitterRobotSelectEntity( LitterRobotEntity[_RobotT], SelectEntity, Generic[_RobotT, _CastTypeT] ): """Litter-Robot Select.""" @@ -99,6 +116,13 @@ class LitterRobotSelect( options = self.entity_description.options_fn(self.robot) self._attr_options = list(map(str, options)) + @property + def icon(self) -> str | None: + """Return the icon to use in the frontend, if any.""" + if icon_fn := self.entity_description.icon_fn: + return str(icon_fn(self.robot)) + return super().icon + @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 2d40eb6a04..b4aa8f0016 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -62,6 +62,15 @@ "spf": "Pinch Detect At Startup" } } + }, + "select": { + "brightness_level": { + "state": { + "low": "Low", + "medium": "Medium", + "high": "High" + } + } } } } diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py index b8d8fdbfb0..b0387c6dcc 100644 --- a/homeassistant/components/livisi/__init__.py +++ b/homeassistant/components/livisi/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers import aiohttp_client, device_registry as dr from .const import DOMAIN from .coordinator import LivisiDataUpdateCoordinator -PLATFORMS: Final = [Platform.CLIMATE, Platform.SWITCH] +PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SWITCH] async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/livisi/binary_sensor.py b/homeassistant/components/livisi/binary_sensor.py new file mode 100644 index 0000000000..42170bbeb4 --- /dev/null +++ b/homeassistant/components/livisi/binary_sensor.py @@ -0,0 +1,110 @@ +"""Code to handle a Livisi Binary Sensor.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, WDS_DEVICE_TYPE +from .coordinator import LivisiDataUpdateCoordinator +from .entity import LivisiEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary_sensor device.""" + coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + known_devices = set() + + @callback + def handle_coordinator_update() -> None: + """Add Window Sensor.""" + shc_devices: list[dict[str, Any]] = coordinator.data + entities: list[BinarySensorEntity] = [] + for device in shc_devices: + if device["id"] not in known_devices and device["type"] == WDS_DEVICE_TYPE: + livisi_binary: BinarySensorEntity = LivisiWindowDoorSensor( + config_entry, coordinator, device + ) + LOGGER.debug("Include device type: %s", device["type"]) + coordinator.devices.add(device["id"]) + known_devices.add(device["id"]) + entities.append(livisi_binary) + async_add_entities(entities) + + config_entry.async_on_unload( + coordinator.async_add_listener(handle_coordinator_update) + ) + + +class LivisiBinarySensor(LivisiEntity, BinarySensorEntity): + """Represents a Livisi Binary Sensor.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: LivisiDataUpdateCoordinator, + device: dict[str, Any], + capability_name: str, + ) -> None: + """Initialize the Livisi sensor.""" + super().__init__(config_entry, coordinator, device) + self._capability_id = self.capabilities[capability_name] + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._capability_id}", + self.update_states, + ) + ) + + @callback + def update_states(self, state: bool) -> None: + """Update the state of the device.""" + self._attr_is_on = state + self.async_write_ha_state() + + +class LivisiWindowDoorSensor(LivisiBinarySensor): + """Represents a Livisi Window/Door Sensor as a Binary Sensor Entity.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: LivisiDataUpdateCoordinator, + device: dict[str, Any], + ) -> None: + """Initialize the Livisi window/door sensor.""" + super().__init__(config_entry, coordinator, device, "WindowDoorSensor") + + self._attr_device_class = ( + BinarySensorDeviceClass.DOOR + if (device.get("tags", {}).get("typeCategory") == "TCDoorId") + else BinarySensorDeviceClass.WINDOW + ) + + async def async_added_to_hass(self) -> None: + """Get current state.""" + await super().async_added_to_hass() + response = await self.coordinator.async_get_device_state( + self._capability_id, "isOpen" + ) + if response is None: + self._attr_available = False + else: + self._attr_is_on = response diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index a6680a19af..952363650d 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import Any +from aiolivisi.const import CAPABILITY_CONFIG + from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -65,8 +67,6 @@ class LivisiClimate(LivisiEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - _attr_target_temperature_high = MAX_TEMPERATURE - _attr_target_temperature_low = MIN_TEMPERATURE def __init__( self, @@ -83,6 +83,10 @@ class LivisiClimate(LivisiEntity, ClimateEntity): self._temperature_capability = self.capabilities["RoomTemperature"] self._humidity_capability = self.capabilities["RoomHumidity"] + config = device.get(CAPABILITY_CONFIG, {}).get("RoomSetpoint", {}) + self._attr_max_temp = config.get("maxTemperature", MAX_TEMPERATURE) + self._attr_min_temp = config.get("minTemperature", MIN_TEMPERATURE) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" response = await self.aio_livisi.async_vrcc_set_temperature( @@ -99,14 +103,15 @@ class LivisiClimate(LivisiEntity, ClimateEntity): await super().async_added_to_hass() - target_temperature = await self.coordinator.async_get_vrcc_target_temperature( - self._target_temperature_capability + target_temperature = await self.coordinator.async_get_device_state( + self._target_temperature_capability, + "setpointTemperature" if self.coordinator.is_avatar else "pointTemperature", ) - temperature = await self.coordinator.async_get_vrcc_temperature( - self._temperature_capability + temperature = await self.coordinator.async_get_device_state( + self._temperature_capability, "temperature" ) - humidity = await self.coordinator.async_get_vrcc_humidity( - self._humidity_capability + humidity = await self.coordinator.async_get_device_state( + self._humidity_capability, "humidity" ) if temperature is None: self._attr_current_temperature = None diff --git a/homeassistant/components/livisi/const.py b/homeassistant/components/livisi/const.py index 98e0b7816c..f6435298f1 100644 --- a/homeassistant/components/livisi/const.py +++ b/homeassistant/components/livisi/const.py @@ -16,6 +16,8 @@ LIVISI_REACHABILITY_CHANGE: Final = "livisi_reachability_change" SWITCH_DEVICE_TYPES: Final = ["ISS", "ISS2", "PSS", "PSSO"] VRCC_DEVICE_TYPE: Final = "VRCC" +WDS_DEVICE_TYPE: Final = "WDS" + MAX_TEMPERATURE: Final = 30.0 MIN_TEMPERATURE: Final = 6.0 diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index e6c29f7151..56e928307c 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -6,6 +6,7 @@ from typing import Any from aiohttp import ClientConnectorError from aiolivisi import AioLivisi, LivisiEvent, Websocket +from aiolivisi.errors import TokenExpiredException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -55,8 +56,15 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Get device configuration from LIVISI.""" try: return await self.async_get_devices() + except TokenExpiredException: + await self.aiolivisi.async_set_token(self.aiolivisi.livisi_connection_data) + return await self.async_get_devices() except ClientConnectorError as exc: - raise UpdateFailed("Failed to get LIVISI the devices") from exc + raise UpdateFailed("Failed to get livisi devices from controller") from exc + + def _async_dispatcher_send(self, event: str, source: str, data: Any) -> None: + if data is not None: + async_dispatcher_send(self.hass, f"{event}_{source}", data) async def async_setup(self) -> None: """Set up the Livisi Smart Home Controller.""" @@ -83,44 +91,14 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Set the discovered devices list.""" return await self.aiolivisi.async_get_devices() - async def async_get_pss_state(self, capability: str) -> bool | None: - """Set the PSS state.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( + async def async_get_device_state(self, capability: str, key: str) -> Any | None: + """Get state from livisi devices.""" + response: dict[str, Any] = await self.aiolivisi.async_get_device_state( capability[1:] ) if response is None: return None - on_state = response["onState"] - return on_state["value"] - - async def async_get_vrcc_target_temperature(self, capability: str) -> float | None: - """Get the target temperature of the climate device.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( - capability[1:] - ) - if response is None: - return None - if self.is_avatar: - return response["setpointTemperature"]["value"] - return response["pointTemperature"]["value"] - - async def async_get_vrcc_temperature(self, capability: str) -> float | None: - """Get the temperature of the climate device.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( - capability[1:] - ) - if response is None: - return None - return response["temperature"]["value"] - - async def async_get_vrcc_humidity(self, capability: str) -> int | None: - """Get the humidity of the climate device.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( - capability[1:] - ) - if response is None: - return None - return response["humidity"]["value"] + return response.get(key, {}).get("value") async def async_set_all_rooms(self) -> None: """Set the room list.""" @@ -132,34 +110,23 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): def on_data(self, event_data: LivisiEvent) -> None: """Define a handler to fire when the data is received.""" - if event_data.onState is not None: - async_dispatcher_send( - self.hass, - f"{LIVISI_STATE_CHANGE}_{event_data.source}", - event_data.onState, - ) - if event_data.vrccData is not None: - async_dispatcher_send( - self.hass, - f"{LIVISI_STATE_CHANGE}_{event_data.source}", - event_data.vrccData, - ) - if event_data.isReachable is not None: - async_dispatcher_send( - self.hass, - f"{LIVISI_REACHABILITY_CHANGE}_{event_data.source}", - event_data.isReachable, - ) + self._async_dispatcher_send( + LIVISI_STATE_CHANGE, event_data.source, event_data.onState + ) + self._async_dispatcher_send( + LIVISI_STATE_CHANGE, event_data.source, event_data.vrccData + ) + self._async_dispatcher_send( + LIVISI_REACHABILITY_CHANGE, event_data.source, event_data.isReachable + ) + self._async_dispatcher_send( + LIVISI_STATE_CHANGE, event_data.source, event_data.isOpen + ) async def on_close(self) -> None: """Define a handler to fire when the websocket is closed.""" for device_id in self.devices: - is_reachable: bool = False - async_dispatcher_send( - self.hass, - f"{LIVISI_REACHABILITY_CHANGE}_{device_id}", - is_reachable, - ) + self._async_dispatcher_send(LIVISI_REACHABILITY_CHANGE, device_id, False) await self.websocket.connect(self.on_data, self.on_close, self.port) diff --git a/homeassistant/components/livisi/switch.py b/homeassistant/components/livisi/switch.py index 1a5789ea24..2c5a2b5137 100644 --- a/homeassistant/components/livisi/switch.py +++ b/homeassistant/components/livisi/switch.py @@ -81,7 +81,9 @@ class LivisiSwitch(LivisiEntity, SwitchEntity): """Register callbacks.""" await super().async_added_to_hass() - response = await self.coordinator.async_get_pss_state(self._capability_id) + response = await self.coordinator.async_get_device_state( + self._capability_id, "onState" + ) if response is None: self._attr_is_on = False self._attr_available = False diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 9cb6878ca5..423be8143b 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import date, datetime, timedelta import logging from typing import Any @@ -33,6 +33,8 @@ from .store import LocalCalendarStore _LOGGER = logging.getLogger(__name__) +PRODID = "-//homeassistant.io//local_calendar 1.0//EN" + async def async_setup_entry( hass: HomeAssistant, @@ -43,6 +45,7 @@ async def async_setup_entry( store = hass.data[DOMAIN][config_entry.entry_id] ics = await store.async_load() calendar = IcsCalendarStream.calendar_from_ics(ics) + calendar.prodid = PRODID name = config_entry.data[CONF_CALENDAR_NAME] entity = LocalCalendarEntity(store, calendar, name, unique_id=config_entry.entry_id) @@ -82,17 +85,16 @@ class LocalCalendarEntity(CalendarEntity): self, hass: HomeAssistant, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all events in a specific time frame.""" - events = self._calendar.timeline_tz(dt_util.DEFAULT_TIME_ZONE).overlapping( - dt_util.as_local(start_date), - dt_util.as_local(end_date), + events = self._calendar.timeline_tz(start_date.tzinfo).overlapping( + start_date, + end_date, ) return [_get_calendar_event(event) for event in events] async def async_update(self) -> None: """Update entity state with the next upcoming event.""" - events = self._calendar.timeline_tz(dt_util.DEFAULT_TIME_ZONE).active_after( - dt_util.now() - ) + now = dt_util.now() + events = self._calendar.timeline_tz(now.tzinfo).active_after(now) if event := next(events, None): self._event = _get_calendar_event(event) else: @@ -184,16 +186,26 @@ def _parse_event(event: dict[str, Any]) -> Event: def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" + start: datetime | date + end: datetime | date + if isinstance(event.start, datetime) and isinstance(event.end, datetime): + start = dt_util.as_local(event.start) + end = dt_util.as_local(event.end) + if (end - start) <= timedelta(seconds=0): + end = start + timedelta(minutes=30) + else: + start = event.start + end = event.end + if (end - start) < timedelta(days=0): + end = start + timedelta(days=1) + return CalendarEvent( summary=event.summary, - start=dt_util.as_local(event.start) - if isinstance(event.start, datetime) - else event.start, - end=dt_util.as_local(event.end) - if isinstance(event.end, datetime) - else event.end, + start=start, + end=end, description=event.description, uid=event.uid, rrule=event.rrule.as_rrule_str() if event.rrule else None, recurrence_id=event.recurrence_id, + location=event.location, ) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 42cd7fcf5a..049f9de03e 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.0"] + "requirements": ["ical==4.5.1"] } diff --git a/homeassistant/components/lock/services.yaml b/homeassistant/components/lock/services.yaml index 5e371f29ab..740d107d62 100644 --- a/homeassistant/components/lock/services.yaml +++ b/homeassistant/components/lock/services.yaml @@ -1,43 +1,5 @@ # Describes the format for available lock services -clear_usercode: - name: Clear usercode - description: Clear a usercode from lock. - fields: - node_id: - name: Node ID - description: Node id of the lock. - selector: - number: - min: 1 - max: 255 - code_slot: - name: Code slot - description: Code slot to clear code from. - selector: - number: - min: 1 - max: 255 - -get_usercode: - name: Get usercode - description: Retrieve a usercode from lock. - fields: - node_id: - name: Node ID - description: Node id of the lock. - selector: - number: - min: 1 - max: 255 - code_slot: - name: Code slot - description: Code slot to retrieve a code from. - selector: - number: - min: 1 - max: 255 - lock: name: Lock description: Lock all or specified locks. @@ -66,29 +28,6 @@ open: selector: text: -set_usercode: - name: Set usercode - description: Set a usercode to lock. - fields: - node_id: - description: Node id of the lock. - selector: - number: - min: 1 - max: 255 - code_slot: - description: Code slot to set the code. - selector: - number: - min: 1 - max: 255 - usercode: - description: Code to set. - required: true - example: 1234 - selector: - text: - unlock: name: Unlock description: Unlock all or specified locks. diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index ab073f296f..86dcfdf82c 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -22,7 +22,7 @@ from homeassistant.util.json import json_loads from homeassistant.util.ulid import ulid_to_bytes -@dataclass +@dataclass(slots=True) class LogbookConfig: """Configuration for the logbook integration.""" @@ -64,12 +64,14 @@ class LazyEventPartialState: self.context_id_bin: bytes | None = self.row.context_id_bin self.context_user_id_bin: bytes | None = self.row.context_user_id_bin self.context_parent_id_bin: bytes | None = self.row.context_parent_id_bin - if data := getattr(row, "data", None): + # We need to explicitly check for the row is EventAsRow as the unhappy path + # to fetch row.data for Row is very expensive + if type(row) is EventAsRow: # pylint: disable=unidiomatic-typecheck # If its an EventAsRow we can avoid the whole # json decode process as we already have the data - self.data = data + self.data = row.data return - source = cast(str, self.row.shared_data or self.row.event_data) + source = cast(str, self.row.event_data) if not source: self.data = {} elif event_data := self._event_data_cache.get(source): @@ -95,7 +97,7 @@ class LazyEventPartialState: return bytes_to_ulid_or_none(self.context_parent_id_bin) -@dataclass(frozen=True) +@dataclass(slots=True, frozen=True) class EventAsRow: """Convert an event to a row.""" @@ -103,17 +105,14 @@ class EventAsRow: context: Context context_id_bin: bytes time_fired_ts: float - state_id: int + row_id: int event_data: str | None = None - old_format_icon: None = None - event_id: None = None entity_id: str | None = None icon: str | None = None context_user_id_bin: bytes | None = None context_parent_id_bin: bytes | None = None event_type: str | None = None state: str | None = None - shared_data: str | None = None context_only: None = None @@ -130,7 +129,7 @@ def async_event_to_row(event: Event) -> EventAsRow: context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id), context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id), time_fired_ts=dt_util.utc_to_timestamp(event.time_fired), - state_id=hash(event), + row_id=hash(event), ) # States are prefiltered so we never get states # that are missing new_state or old_state @@ -146,6 +145,6 @@ def async_event_to_row(event: Event) -> EventAsRow: context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id), context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id), time_fired_ts=dt_util.utc_to_timestamp(new_state.last_updated), - state_id=hash(event), + row_id=hash(event), icon=new_state.attributes.get(ATTR_ICON), ) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 32301e9835..671f8f8f1c 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Generator, Sequence from contextlib import suppress from dataclasses import dataclass from datetime import datetime as dt +import logging from typing import Any from sqlalchemy.engine import Result @@ -14,11 +15,15 @@ from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import ( bytes_to_uuid_hex_or_none, + extract_event_type_ids, extract_metadata_ids, process_datetime_to_timestamp, process_timestamp_to_utc_isoformat, ) -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.util import ( + execute_stmt_lambda_element, + session_scope, +) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( ATTR_DOMAIN, @@ -60,12 +65,14 @@ from .models import EventAsRow, LazyEventPartialState, LogbookConfig, async_even from .queries import statement_for_request from .queries.common import PSEUDO_EVENT_STATE_CHANGED +_LOGGER = logging.getLogger(__name__) -@dataclass + +@dataclass(slots=True) class LogbookRun: """A logbook run which may be a long running event stream or single request.""" - context_lookup: ContextLookup + context_lookup: dict[bytes | None, Row | EventAsRow | None] external_events: dict[ str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] ] @@ -73,6 +80,7 @@ class LogbookRun: entity_name_cache: EntityNameCache include_entity_name: bool format_time: Callable[[Row | EventAsRow], Any] + memoize_new_contexts: bool = True class EventProcessor: @@ -104,7 +112,7 @@ class EventProcessor: _row_time_fired_timestamp if timestamp else _row_time_fired_isoformat ) self.logbook_run = LogbookRun( - context_lookup=ContextLookup(hass), + context_lookup={None: None}, external_events=logbook_config.external_events, event_cache=EventCache({}), entity_name_cache=EntityNameCache(self.hass), @@ -125,6 +133,7 @@ class EventProcessor: """ self.logbook_run.event_cache.clear() self.logbook_run.context_lookup.clear() + self.logbook_run.memoize_new_contexts = False def get_events( self, @@ -132,45 +141,33 @@ class EventProcessor: end_day: dt, ) -> list[dict[str, Any]]: """Get events for a period of time.""" - - def yield_rows(result: Result) -> Sequence[Row] | Result: - """Yield rows from the database.""" - # end_day - start_day intentionally checks .days and not .total_seconds() - # since we don't want to switch over to buffered if they go - # over one day by a few hours since the UI makes it so easy to do that. - if self.limited_select or (end_day - start_day).days <= 1: - return result.all() - # Only buffer rows to reduce memory pressure - # if we expect the result set is going to be very large. - # What is considered very large is going to differ - # based on the hardware Home Assistant is running on. - # - # sqlalchemy suggests that is at least 10k, but for - # even and RPi3 that number seems higher in testing - # so we don't switch over until we request > 1 day+ of data. - # - return result.yield_per(1024) - with session_scope(hass=self.hass, read_only=True) as session: metadata_ids: list[int] | None = None + instance = get_instance(self.hass) if self.entity_ids: - instance = get_instance(self.hass) metadata_ids = extract_metadata_ids( instance.states_meta_manager.get_many( self.entity_ids, session, False ) ) + event_type_ids = tuple( + extract_event_type_ids( + instance.event_type_manager.get_many(self.event_types, session) + ) + ) stmt = statement_for_request( start_day, end_day, - self.event_types, + event_type_ids, self.entity_ids, metadata_ids, self.device_ids, self.filters, self.context_id, ) - return self.humanify(yield_rows(session.execute(stmt))) + return self.humanify( + execute_stmt_lambda_element(session, stmt, orm_rows=False) + ) def humanify( self, rows: Generator[EventAsRow, None, None] | Sequence[Row] | Result @@ -201,13 +198,18 @@ def _humanify( entity_name_cache = logbook_run.entity_name_cache include_entity_name = logbook_run.include_entity_name format_time = logbook_run.format_time + memoize_new_contexts = logbook_run.memoize_new_contexts + memoize_context = context_lookup.setdefault # Process rows for row in rows: - context_id = context_lookup.memorize(row) + context_id_bin: bytes = row.context_id_bin + if memoize_new_contexts: + memoize_context(context_id_bin, row) if row.context_only: continue event_type = row.event_type + if event_type == EVENT_CALL_SERVICE: continue if event_type is PSEUDO_EVENT_STATE_CHANGED: @@ -229,18 +231,24 @@ def _humanify( } if include_entity_name: data[LOGBOOK_ENTRY_NAME] = entity_name_cache.get(entity_id) - if icon := row.icon or row.old_format_icon: + if icon := row.icon: data[LOGBOOK_ENTRY_ICON] = icon - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data elif event_type in external_events: domain, describe_event = external_events[event_type] - data = describe_event(event_cache.get(row)) + try: + data = describe_event(event_cache.get(row)) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Error with %s describe event for %s", domain, event_type + ) + continue data[LOGBOOK_ENTRY_WHEN] = format_time(row) data[LOGBOOK_ENTRY_DOMAIN] = domain - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data elif event_type == EVENT_LOGBOOK_ENTRY: @@ -259,37 +267,10 @@ def _humanify( LOGBOOK_ENTRY_DOMAIN: entry_domain, LOGBOOK_ENTRY_ENTITY_ID: entry_entity_id, } - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data -class ContextLookup: - """A lookup class for context origins.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Memorize context origin.""" - self.hass = hass - self._memorize_new = True - self._lookup: dict[bytes | None, Row | EventAsRow | None] = {None: None} - - def memorize(self, row: Row | EventAsRow) -> bytes | None: - """Memorize a context from the database.""" - if self._memorize_new: - context_id_bin: bytes = row.context_id_bin - self._lookup.setdefault(context_id_bin, row) - return context_id_bin - return None - - def clear(self) -> None: - """Clear the context origins and stop recording new ones.""" - self._lookup.clear() - self._memorize_new = False - - def get(self, context_id_bin: bytes) -> Row | EventAsRow | None: - """Get the context origin.""" - return self._lookup.get(context_id_bin) - - class ContextAugmenter: """Augment data with context trace.""" @@ -302,11 +283,13 @@ class ContextAugmenter: self.include_entity_name = logbook_run.include_entity_name def _get_context_row( - self, context_id: bytes | None, row: Row | EventAsRow + self, context_id_bin: bytes | None, row: Row | EventAsRow ) -> Row | EventAsRow | None: """Get the context row from the id or row context.""" - if context_id: - return self.context_lookup.get(context_id) + if context_id_bin is not None and ( + context_row := self.context_lookup.get(context_id_bin) + ): + return context_row if (context := getattr(row, "context", None)) is not None and ( origin_event := context.origin_event ) is not None: @@ -314,13 +297,13 @@ class ContextAugmenter: return None def augment( - self, data: dict[str, Any], row: Row | EventAsRow, context_id: bytes | None + self, data: dict[str, Any], row: Row | EventAsRow, context_id_bin: bytes | None ) -> None: """Augment data from the row and cache.""" if context_user_id_bin := row.context_user_id_bin: data[CONTEXT_USER_ID] = bytes_to_uuid_hex_or_none(context_user_id_bin) - if not (context_row := self._get_context_row(context_id, row)): + if not (context_row := self._get_context_row(context_id_bin, row)): return if _rows_match(row, context_row): @@ -368,7 +351,11 @@ class ContextAugmenter: data[CONTEXT_EVENT_TYPE] = event_type data[CONTEXT_DOMAIN] = domain event = self.event_cache.get(context_row) - described = describe_event(event) + try: + described = describe_event(event) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error with %s describe event for %s", domain, event_type) + return if name := described.get(LOGBOOK_ENTRY_NAME): data[CONTEXT_NAME] = name if message := described.get(LOGBOOK_ENTRY_MESSAGE): @@ -385,15 +372,9 @@ class ContextAugmenter: def _rows_match(row: Row | EventAsRow, other_row: Row | EventAsRow) -> bool: """Check of rows match by using the same method as Events __hash__.""" - if ( - row is other_row - or (state_id := row.state_id) - and state_id == other_row.state_id - or (event_id := row.event_id) - and event_id == other_row.event_id - ): - return True - return False + return bool( + row is other_row or (row_id := row.row_id) and row_id == other_row.row_id + ) def _row_time_fired_isoformat(row: Row | EventAsRow) -> str: diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index cfef16bf77..29d89a4c22 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -20,7 +20,7 @@ from .entities_and_devices import entities_devices_stmt def statement_for_request( start_day_dt: dt, end_day_dt: dt, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], entity_ids: list[str] | None = None, states_metadata_ids: Collection[int] | None = None, device_ids: list[str] | None = None, @@ -30,20 +30,15 @@ def statement_for_request( """Generate the logbook statement for a logbook request.""" start_day = dt_util.utc_to_timestamp(start_day_dt) end_day = dt_util.utc_to_timestamp(end_day_dt) - context_id_bin = ulid_to_bytes_or_none(context_id) # No entities: logbook sends everything for the timeframe # limited by the context_id and the yaml configured filter if not entity_ids and not device_ids: - states_entity_filter = ( - filters.states_metadata_entity_filter() if filters else None - ) - events_entity_filter = filters.events_entity_filter() if filters else None + context_id_bin = ulid_to_bytes_or_none(context_id) return all_stmt( start_day, end_day, - event_types, - states_entity_filter, - events_entity_filter, + event_type_ids, + filters, context_id_bin, ) @@ -54,34 +49,30 @@ def statement_for_request( # entities and devices: logbook sends everything for the timeframe for the entities and devices if entity_ids and device_ids: - json_quoted_entity_ids = [json_dumps(entity_id) for entity_id in entity_ids] - json_quoted_device_ids = [json_dumps(device_id) for device_id in device_ids] return entities_devices_stmt( start_day, end_day, - event_types, + event_type_ids, states_metadata_ids or [], - json_quoted_entity_ids, - json_quoted_device_ids, + [json_dumps(entity_id) for entity_id in entity_ids], + [json_dumps(device_id) for device_id in device_ids], ) # entities: logbook sends everything for the timeframe for the entities if entity_ids: - json_quoted_entity_ids = [json_dumps(entity_id) for entity_id in entity_ids] return entities_stmt( start_day, end_day, - event_types, + event_type_ids, states_metadata_ids or [], - json_quoted_entity_ids, + [json_dumps(entity_id) for entity_id in entity_ids], ) # devices: logbook sends everything for the timeframe for the devices assert device_ids is not None - json_quoted_device_ids = [json_dumps(device_id) for device_id in device_ids] return devices_stmt( start_day, end_day, - event_types, - json_quoted_device_ids, + event_type_ids, + [json_dumps(device_id) for device_id in device_ids], ) diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index 8c37bf22da..c6196687ac 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -2,7 +2,6 @@ from __future__ import annotations from sqlalchemy import lambda_stmt -from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import Select @@ -11,6 +10,7 @@ from homeassistant.components.recorder.db_schema import ( Events, States, ) +from homeassistant.components.recorder.filters import Filters from .common import apply_states_filters, select_events_without_states, select_states @@ -18,14 +18,13 @@ from .common import apply_states_filters, select_events_without_states, select_s def all_stmt( start_day: float, end_day: float, - event_types: tuple[str, ...], - states_entity_filter: ColumnElement | None = None, - events_entity_filter: ColumnElement | None = None, + event_type_ids: tuple[int, ...], + filters: Filters | None, context_id_bin: bytes | None = None, ) -> StatementLambdaElement: """Generate a logbook query for all entities.""" stmt = lambda_stmt( - lambda: select_events_without_states(start_day, end_day, event_types) + lambda: select_events_without_states(start_day, end_day, event_type_ids) ) if context_id_bin is not None: stmt += lambda s: s.where(Events.context_id_bin == context_id_bin).union_all( @@ -36,19 +35,17 @@ def all_stmt( context_id_bin, # type:ignore[arg-type] ), ) - else: - if events_entity_filter is not None: - stmt += lambda s: s.where(events_entity_filter) - - if states_entity_filter is not None: - stmt += lambda s: s.union_all( + elif filters and filters.has_config: + stmt = stmt.add_criteria( + lambda q: q.filter(filters.events_entity_filter()).union_all( # type: ignore[union-attr] _states_query_for_all(start_day, end_day).where( - # https://github.com/python/mypy/issues/2608 - states_entity_filter # type:ignore[arg-type] + filters.states_metadata_entity_filter() # type: ignore[union-attr] ) - ) - else: - stmt += lambda s: s.union_all(_states_query_for_all(start_day, end_day)) + ), + track_on=[filters], + ) + else: + stmt += lambda s: s.union_all(_states_query_for_all(start_day, end_day)) stmt += lambda s: s.order_by(Events.time_fired_ts) return stmt diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index 08bf1b8ab9..cbbe8724ec 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -14,6 +14,7 @@ from homeassistant.components.recorder.db_schema import ( OLD_FORMAT_ATTRS_JSON, OLD_STATE, SHARED_ATTRS_JSON, + SHARED_DATA_OR_LEGACY_EVENT_DATA, STATES_CONTEXT_ID_BIN_INDEX, EventData, Events, @@ -23,7 +24,6 @@ from homeassistant.components.recorder.db_schema import ( StatesMeta, ) from homeassistant.components.recorder.filters import like_domain_matchers -from homeassistant.components.recorder.queries import select_event_type_ids from ..const import ALWAYS_CONTINUOUS_DOMAINS, CONDITIONALLY_CONTINUOUS_DOMAINS @@ -37,6 +37,11 @@ ALWAYS_CONTINUOUS_ENTITY_ID_LIKE = like_domain_matchers(ALWAYS_CONTINUOUS_DOMAIN UNIT_OF_MEASUREMENT_JSON = '"unit_of_measurement":' UNIT_OF_MEASUREMENT_JSON_LIKE = f"%{UNIT_OF_MEASUREMENT_JSON}%" +ICON_OR_OLD_FORMAT_ICON_JSON = sqlalchemy.case( + (SHARED_ATTRS_JSON["icon"].is_(None), OLD_FORMAT_ATTRS_JSON["icon"].as_string()), + else_=SHARED_ATTRS_JSON["icon"].as_string(), +).label("icon") + PSEUDO_EVENT_STATE_CHANGED: Final = None # Since we don't store event_types and None # and we don't store state_changed in events @@ -46,9 +51,9 @@ PSEUDO_EVENT_STATE_CHANGED: Final = None # in the payload EVENT_COLUMNS = ( - Events.event_id.label("event_id"), + Events.event_id.label("row_id"), EventTypes.event_type.label("event_type"), - Events.event_data.label("event_data"), + SHARED_DATA_OR_LEGACY_EVENT_DATA, Events.time_fired_ts.label("time_fired_ts"), Events.context_id_bin.label("context_id_bin"), Events.context_user_id_bin.label("context_user_id_bin"), @@ -56,23 +61,19 @@ EVENT_COLUMNS = ( ) STATE_COLUMNS = ( - States.state_id.label("state_id"), States.state.label("state"), StatesMeta.entity_id.label("entity_id"), - SHARED_ATTRS_JSON["icon"].as_string().label("icon"), - OLD_FORMAT_ATTRS_JSON["icon"].as_string().label("old_format_icon"), + ICON_OR_OLD_FORMAT_ICON_JSON, ) STATE_CONTEXT_ONLY_COLUMNS = ( - States.state_id.label("state_id"), States.state.label("state"), StatesMeta.entity_id.label("entity_id"), literal(value=None, type_=sqlalchemy.String).label("icon"), - literal(value=None, type_=sqlalchemy.String).label("old_format_icon"), ) EVENT_COLUMNS_FOR_STATE_SELECT = ( - literal(value=None, type_=sqlalchemy.Text).label("event_id"), + States.state_id.label("row_id"), # We use PSEUDO_EVENT_STATE_CHANGED aka None for # state_changed events since it takes up less # space in the response and every row has to be @@ -85,21 +86,17 @@ EVENT_COLUMNS_FOR_STATE_SELECT = ( States.context_id_bin.label("context_id_bin"), States.context_user_id_bin.label("context_user_id_bin"), States.context_parent_id_bin.label("context_parent_id_bin"), - literal(value=None, type_=sqlalchemy.Text).label("shared_data"), ) EMPTY_STATE_COLUMNS = ( - literal(value=0, type_=sqlalchemy.Integer).label("state_id"), literal(value=None, type_=sqlalchemy.String).label("state"), literal(value=None, type_=sqlalchemy.String).label("entity_id"), literal(value=None, type_=sqlalchemy.String).label("icon"), - literal(value=None, type_=sqlalchemy.String).label("old_format_icon"), ) EVENT_ROWS_NO_STATES = ( *EVENT_COLUMNS, - EventData.shared_data.label("shared_data"), *EMPTY_STATE_COLUMNS, ) @@ -112,13 +109,13 @@ NOT_CONTEXT_ONLY = literal(value=None, type_=sqlalchemy.String).label("context_o def select_events_context_id_subquery( start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], ) -> Select: """Generate the select for a context_id subquery.""" return ( select(Events.context_id_bin) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) - .where(Events.event_type_id.in_(select_event_type_ids(event_types))) + .where(Events.event_type_id.in_(event_type_ids)) .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, (Events.data_id == EventData.data_id)) ) @@ -145,13 +142,13 @@ def select_states_context_only() -> Select: def select_events_without_states( - start_day: float, end_day: float, event_types: tuple[str, ...] + start_day: float, end_day: float, event_type_ids: tuple[int, ...] ) -> Select: """Generate an events select that does not join states.""" return ( select(*EVENT_ROWS_NO_STATES, NOT_CONTEXT_ONLY) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) - .where(Events.event_type_id.in_(select_event_type_ids(event_types))) + .where(Events.event_type_id.in_(event_type_ids)) .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, (Events.data_id == EventData.data_id)) ) diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index a5c06dc84c..75604de610 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -31,12 +31,12 @@ from .common import ( def _select_device_id_context_ids_sub_query( start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], json_quotable_device_ids: list[str], ) -> Select: """Generate a subquery to find context ids for multiple devices.""" inner = ( - select_events_context_id_subquery(start_day, end_day, event_types) + select_events_context_id_subquery(start_day, end_day, event_type_ids) .where(apply_event_device_id_matchers(json_quotable_device_ids)) .subquery() ) @@ -47,14 +47,14 @@ def _apply_devices_context_union( sel: Select, start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], json_quotable_device_ids: list[str], ) -> CompoundSelect: """Generate a CTE to find the device context ids and a query to find linked row.""" devices_cte: CTE = _select_device_id_context_ids_sub_query( start_day, end_day, - event_types, + event_type_ids, json_quotable_device_ids, ).cte() return sel.union_all( @@ -77,18 +77,18 @@ def _apply_devices_context_union( def devices_stmt( start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], json_quotable_device_ids: list[str], ) -> StatementLambdaElement: """Generate a logbook query for multiple devices.""" stmt = lambda_stmt( lambda: _apply_devices_context_union( - select_events_without_states(start_day, end_day, event_types).where( + select_events_without_states(start_day, end_day, event_type_ids).where( apply_event_device_id_matchers(json_quotable_device_ids) ), start_day, end_day, - event_types, + event_type_ids, json_quotable_device_ids, ).order_by(Events.time_fired_ts) ) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index ebb56befa5..95c1d56526 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -35,13 +35,13 @@ from .common import ( def _select_entities_context_ids_sub_query( start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> Select: """Generate a subquery to find context ids for multiple entities.""" union = union_all( - select_events_context_id_subquery(start_day, end_day, event_types).where( + select_events_context_id_subquery(start_day, end_day, event_type_ids).where( apply_event_entity_id_matchers(json_quoted_entity_ids) ), apply_entities_hints(select(States.context_id_bin)) @@ -57,7 +57,7 @@ def _apply_entities_context_union( sel: Select, start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> CompoundSelect: @@ -65,7 +65,7 @@ def _apply_entities_context_union( entities_cte: CTE = _select_entities_context_ids_sub_query( start_day, end_day, - event_types, + event_type_ids, states_metadata_ids, json_quoted_entity_ids, ).cte() @@ -95,19 +95,19 @@ def _apply_entities_context_union( def entities_stmt( start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> StatementLambdaElement: """Generate a logbook query for multiple entities.""" return lambda_stmt( lambda: _apply_entities_context_union( - select_events_without_states(start_day, end_day, event_types).where( + select_events_without_states(start_day, end_day, event_type_ids).where( apply_event_entity_id_matchers(json_quoted_entity_ids) ), start_day, end_day, - event_types, + event_type_ids, states_metadata_ids, json_quoted_entity_ids, ).order_by(Events.time_fired_ts) diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index f7ffde4f81..c465a343d6 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -35,14 +35,14 @@ from .entities import ( def _select_entities_device_id_context_ids_sub_query( start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], ) -> Select: """Generate a subquery to find context ids for multiple entities and multiple devices.""" union = union_all( - select_events_context_id_subquery(start_day, end_day, event_types).where( + select_events_context_id_subquery(start_day, end_day, event_type_ids).where( _apply_event_entity_id_device_id_matchers( json_quoted_entity_ids, json_quoted_device_ids ) @@ -60,7 +60,7 @@ def _apply_entities_devices_context_union( sel: Select, start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], @@ -68,7 +68,7 @@ def _apply_entities_devices_context_union( devices_entities_cte: CTE = _select_entities_device_id_context_ids_sub_query( start_day, end_day, - event_types, + event_type_ids, states_metadata_ids, json_quoted_entity_ids, json_quoted_device_ids, @@ -103,7 +103,7 @@ def _apply_entities_devices_context_union( def entities_devices_stmt( start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], @@ -111,14 +111,14 @@ def entities_devices_stmt( """Generate a logbook query for multiple entities.""" stmt = lambda_stmt( lambda: _apply_entities_devices_context_union( - select_events_without_states(start_day, end_day, event_types).where( + select_events_without_states(start_day, end_day, event_type_ids).where( _apply_event_entity_id_device_id_matchers( json_quoted_entity_ids, json_quoted_device_ids ) ), start_day, end_day, - event_types, + event_type_ids, states_metadata_ids, json_quoted_entity_ids, json_quoted_device_ids, diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 6d24285ba1..c4e6b9814f 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -39,7 +39,7 @@ BIG_QUERY_RECENT_HOURS = 24 _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(slots=True) class LogbookLiveStream: """Track a logbook live stream.""" @@ -221,8 +221,6 @@ async def _async_events_consumer( event_processor: EventProcessor, ) -> None: """Stream events from the queue.""" - event_processor.switch_to_live() - while True: events: list[Event] = [await stream_queue.get()] # If the event is older than the last db @@ -430,6 +428,7 @@ async def ws_event_stream( event_processor, partial=False, ) + event_processor.switch_to_live() def _ws_formatted_get_events( diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index d85486a41e..0f1751c1b2 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -66,17 +66,18 @@ def _chattiest_log_level(level1: int, level2: int) -> int: return min(level1, level2) -async def get_integration_loggers(hass: HomeAssistant, domain: str) -> list[str]: +async def get_integration_loggers(hass: HomeAssistant, domain: str) -> set[str]: """Get loggers for an integration.""" - loggers = [f"homeassistant.components.{domain}"] + loggers: set[str] = {f"homeassistant.components.{domain}"} with contextlib.suppress(IntegrationNotFound): integration = await async_get_integration(hass, domain) + loggers.add(integration.pkg_path) if integration.loggers: - loggers.extend(integration.loggers) + loggers.update(integration.loggers) return loggers -@dataclass +@dataclass(slots=True) class LoggerSetting: """Settings for a single module or integration.""" @@ -85,7 +86,7 @@ class LoggerSetting: type: str -@dataclass +@dataclass(slots=True) class LoggerDomainConfig: """Logger domain config.""" @@ -188,7 +189,7 @@ class LoggerSettings: if settings.type == LogSettingsType.INTEGRATION: loggers = await get_integration_loggers(hass, domain) else: - loggers = [domain] + loggers = {domain} combined_logs = {logger: LOGSEVERITY[settings.level] for logger in loggers} # Don't override the log levels with the ones from YAML @@ -203,7 +204,7 @@ class LoggerSettings: if settings.type == LogSettingsType.INTEGRATION: loggers = await get_integration_loggers(hass, domain) else: - loggers = [domain] + loggers = {domain} for logger in loggers: combined_logs[logger] = LOGSEVERITY[settings.level] diff --git a/homeassistant/components/logger/websocket_api.py b/homeassistant/components/logger/websocket_api.py index 1b4e5cb36a..89026a07b8 100644 --- a/homeassistant/components/logger/websocket_api.py +++ b/homeassistant/components/logger/websocket_api.py @@ -27,9 +27,9 @@ def async_load_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, handle_module_log_level) +@callback @websocket_api.websocket_command({vol.Required("type"): "logger/log_info"}) -@websocket_api.async_response -async def handle_integration_log_info( +def handle_integration_log_info( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle integrations logger info.""" diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 2cad8e9a10..8217b3913a 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -26,7 +26,6 @@ DOMAIN = "london_underground" CONF_LINE = "line" -ICON = "mdi:subway" SCAN_INTERVAL = timedelta(seconds=30) @@ -100,6 +99,7 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): """Sensor that reads the status of a line from Tube Data.""" _attr_attribution = "Powered by TfL Open Data" + _attr_icon = "mdi:subway" def __init__(self, coordinator, name): """Initialize the London Underground sensor.""" @@ -116,11 +116,6 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): """Return the state of the sensor.""" return self.coordinator.data[self.name]["State"] - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - @property def extra_state_attributes(self): """Return other details about the sensor state.""" diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index f880f83d76..1412aa085c 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -119,7 +119,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: resource_collection = resources.ResourceStorageCollection(hass, default_config) - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( resource_collection, "lovelace/resources", "resource", @@ -198,7 +198,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: dashboards_collection.async_add_listener(storage_dashboard_changed) await dashboards_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( dashboards_collection, "lovelace/dashboards", "dashboard", diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index ef47ea0b1f..054aaf9b24 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -6,7 +6,6 @@ import logging import os from pathlib import Path import time -from typing import cast import voluptuous as vol @@ -218,7 +217,7 @@ def _config_info(mode, config): } -class DashboardsCollection(collection.StorageCollection): +class DashboardsCollection(collection.DictStorageCollection): """Collection of dashboards.""" CREATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_CREATE_FIELDS) @@ -228,13 +227,12 @@ class DashboardsCollection(collection.StorageCollection): """Initialize the dashboards collection.""" super().__init__( storage.Store(hass, DASHBOARDS_STORAGE_VERSION, DASHBOARDS_STORAGE_KEY), - _LOGGER, ) - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data.""" if (data := await self.store.async_load()) is None: - return cast(dict | None, data) + return data updated = False @@ -246,7 +244,7 @@ class DashboardsCollection(collection.StorageCollection): if updated: await self.store.async_save(data) - return cast(dict | None, data) + return data async def _process_create_data(self, data: dict) -> dict: """Validate the config is valid.""" @@ -263,10 +261,10 @@ class DashboardsCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_URL_PATH] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.UPDATE_SCHEMA(update_data) - updated = {**data, **update_data} + updated = {**item, **update_data} if CONF_ICON in updated and updated[CONF_ICON] is None: updated.pop(CONF_ICON) diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py index e6c4acfdf6..b6d0c939fe 100644 --- a/homeassistant/components/lovelace/resources.py +++ b/homeassistant/components/lovelace/resources.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import cast +from typing import Any import uuid import voluptuous as vol @@ -45,7 +45,7 @@ class ResourceYAMLCollection: return self.data -class ResourceStorageCollection(collection.StorageCollection): +class ResourceStorageCollection(collection.DictStorageCollection): """Collection to store resources.""" loaded = False @@ -56,7 +56,6 @@ class ResourceStorageCollection(collection.StorageCollection): """Initialize the storage collection.""" super().__init__( storage.Store(hass, RESOURCES_STORAGE_VERSION, RESOURCE_STORAGE_KEY), - _LOGGER, ) self.ll_config = ll_config @@ -68,10 +67,10 @@ class ResourceStorageCollection(collection.StorageCollection): return {"resources": len(self.async_items() or [])} - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data.""" - if (data := await self.store.async_load()) is not None: - return cast(dict | None, data) + if (store_data := await self.store.async_load()) is not None: + return store_data # Import it from config. try: @@ -83,20 +82,20 @@ class ResourceStorageCollection(collection.StorageCollection): return None # Remove it from config and save both resources + config - data = conf[CONF_RESOURCES] + resources: list[dict[str, Any]] = conf[CONF_RESOURCES] try: - vol.Schema([RESOURCE_SCHEMA])(data) + vol.Schema([RESOURCE_SCHEMA])(resources) except vol.Invalid as err: _LOGGER.warning("Resource import failed. Data invalid: %s", err) return None conf.pop(CONF_RESOURCES) - for item in data: + for item in resources: item[CONF_ID] = uuid.uuid4().hex - data = {"items": data} + data: collection.SerializedStorageCollection = {"items": resources} await self.store.async_save(data) await self.ll_config.async_save(conf) @@ -114,7 +113,7 @@ class ResourceStorageCollection(collection.StorageCollection): """Return unique ID.""" return uuid.uuid4().hex - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" if not self.loaded: await self.async_load() @@ -124,4 +123,4 @@ class ResourceStorageCollection(collection.StorageCollection): if CONF_RESOURCE_TYPE_WS in update_data: update_data[CONF_TYPE] = update_data.pop(CONF_RESOURCE_TYPE_WS) - return {**data, **update_data} + return {**item, **update_data} diff --git a/homeassistant/components/luci/manifest.json b/homeassistant/components/luci/manifest.json index c5d05fd174..2412aaad0a 100644 --- a/homeassistant/components/luci/manifest.json +++ b/homeassistant/components/luci/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/luci", "iot_class": "local_polling", "loggers": ["openwrt_luci_rpc"], - "requirements": ["openwrt-luci-rpc==1.1.11"] + "requirements": ["openwrt-luci-rpc==1.1.16"] } diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index 6767275970..262a6701f5 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -32,42 +32,42 @@ from .const import ATTR_SENSOR_ID, CONF_SENSOR_ID, DOMAIN SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="temperature", - name="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="humidity", - name="Humidity", + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="pressure", - name="Pressure", + translation_key="pressure", native_unit_of_measurement=UnitOfPressure.PA, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="pressure_at_sealevel", - name="Pressure at sealevel", + translation_key="pressure_at_sealevel", native_unit_of_measurement=UnitOfPressure.PA, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="P1", - name="PM10", + translation_key="pm10", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="P2", - name="PM2.5", + 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 508e12924d..d54bc6d0bd 100644 --- a/homeassistant/components/luftdaten/strings.json +++ b/homeassistant/components/luftdaten/strings.json @@ -13,5 +13,25 @@ "invalid_sensor": "Sensor not available or invalid", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "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%]" + } + } } } diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index f8ca93beb2..7d33a82208 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -107,8 +107,5 @@ class LutronLed(LutronDevice, SwitchEntity): def update(self) -> None: """Call when forcing a refresh of the device.""" - if self._lutron_device.last_state is not None: - return - # The following property getter actually triggers an update in Lutron self._lutron_device.state # pylint: disable=pointless-statement diff --git a/homeassistant/components/magicseaweed/__init__.py b/homeassistant/components/magicseaweed/__init__.py deleted file mode 100644 index 848d02967f..0000000000 --- a/homeassistant/components/magicseaweed/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The magicseaweed component.""" diff --git a/homeassistant/components/magicseaweed/manifest.json b/homeassistant/components/magicseaweed/manifest.json deleted file mode 100644 index 4858e6be4f..0000000000 --- a/homeassistant/components/magicseaweed/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "magicseaweed", - "name": "Magicseaweed", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/magicseaweed", - "iot_class": "cloud_polling", - "loggers": ["magicseaweed"], - "requirements": ["magicseaweed==1.0.3"] -} diff --git a/homeassistant/components/magicseaweed/sensor.py b/homeassistant/components/magicseaweed/sensor.py deleted file mode 100644 index aa59553ef8..0000000000 --- a/homeassistant/components/magicseaweed/sensor.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Support for magicseaweed data from magicseaweed.com.""" -from __future__ import annotations - -from datetime import timedelta -import logging - -import magicseaweed -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.const import CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import METRIC_SYSTEM - -_LOGGER = logging.getLogger(__name__) - -CONF_HOURS = "hours" -CONF_SPOT_ID = "spot_id" -CONF_UNITS = "units" - -DEFAULT_UNIT = "us" -DEFAULT_NAME = "MSW" - -ICON = "mdi:waves" - -HOURS = ["12AM", "3AM", "6AM", "9AM", "12PM", "3PM", "6PM", "9PM"] - -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key="max_breaking_swell", - name="Max", - ), - SensorEntityDescription( - key="min_breaking_swell", - name="Min", - ), - SensorEntityDescription( - key="swell_forecast", - name="Forecast", - ), -) - -SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] - - -UNITS = ["eu", "uk", "us"] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [vol.In(SENSOR_KEYS)] - ), - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_SPOT_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_HOURS, default=None): vol.All( - cv.ensure_list, [vol.In(HOURS)] - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNITS): vol.In(UNITS), - } -) - -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Magicseaweed sensor.""" - create_issue( - hass, - "magicseaweed", - "pending_removal", - breaks_in_ha_version="2023.3.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="pending_removal", - ) - _LOGGER.warning( - "The Magicseaweed integration is deprecated" - " and will be removed in Home Assistant 2023.3" - ) - - name = config.get(CONF_NAME) - spot_id = config[CONF_SPOT_ID] - api_key = config[CONF_API_KEY] - hours = config.get(CONF_HOURS) - - if CONF_UNITS in config: - units = config.get(CONF_UNITS) - elif hass.config.units is METRIC_SYSTEM: - units = UNITS[0] - else: - units = UNITS[2] - - forecast_data = MagicSeaweedData(api_key=api_key, spot_id=spot_id, units=units) - forecast_data.update() - - # If connection failed don't setup platform. - if forecast_data.currently is None or forecast_data.hourly is None: - return - - monitored_conditions = config[CONF_MONITORED_CONDITIONS] - sensors = [ - MagicSeaweedSensor(forecast_data, name, units, description) - for description in SENSOR_TYPES - if description.key in monitored_conditions - ] - if hours is not None: - sensors.extend( - [ - MagicSeaweedSensor(forecast_data, name, units, description, hour) - for description in SENSOR_TYPES - if description.key in monitored_conditions - and "forecast" not in description.key - for hour in hours - ] - ) - add_entities(sensors, True) - - -class MagicSeaweedSensor(SensorEntity): - """Implementation of a MagicSeaweed sensor.""" - - _attr_attribution = "Data provided by magicseaweed.com" - _attr_icon = ICON - - def __init__( - self, - forecast_data, - name, - unit_system, - description: SensorEntityDescription, - hour=None, - ) -> None: - """Initialize the sensor.""" - self.entity_description = description - self.client_name = name - self.data = forecast_data - self.hour = hour - self._unit_system = unit_system - - if hour is None and "forecast" in description.key: - self._attr_name = f"{name} {description.name}" - elif hour is None: - self._attr_name = f"Current {name} {description.name}" - else: - self._attr_name = f"{hour} {name} {description.name}" - - self._attr_extra_state_attributes = {} - - @property - def unit_system(self): - """Return the unit system of this entity.""" - return self._unit_system - - def update(self) -> None: - """Get the latest data from Magicseaweed and updates the states.""" - self.data.update() - if self.hour is None: - forecast = self.data.currently - else: - forecast = self.data.hourly[self.hour] - - self._attr_native_unit_of_measurement = forecast.swell_unit - sensor_type = self.entity_description.key - if sensor_type == "min_breaking_swell": - self._attr_native_value = forecast.swell_minBreakingHeight - elif sensor_type == "max_breaking_swell": - self._attr_native_value = forecast.swell_maxBreakingHeight - elif sensor_type == "swell_forecast": - summary = ( - f"{forecast.swell_minBreakingHeight} -" - f" {forecast.swell_maxBreakingHeight}" - ) - self._attr_native_value = summary - if self.hour is None: - for hour, data in self.data.hourly.items(): - occurs = hour - hr_summary = ( - f"{data.swell_minBreakingHeight} -" - f" {data.swell_maxBreakingHeight} {data.swell_unit}" - ) - self._attr_extra_state_attributes[occurs] = hr_summary - - if sensor_type != "swell_forecast": - self._attr_extra_state_attributes.update(forecast.attrs) - - -class MagicSeaweedData: - """Get the latest data from MagicSeaweed.""" - - def __init__(self, api_key, spot_id, units): - """Initialize the data object.""" - self._msw = magicseaweed.MSW_Forecast(api_key, spot_id, None, units) - self.currently = None - self.hourly = {} - - # Apply throttling to methods using configured interval - self.update = Throttle(MIN_TIME_BETWEEN_UPDATES)(self._update) - - def _update(self): - """Get the latest data from MagicSeaweed.""" - try: - forecasts = self._msw.get_future() - self.currently = forecasts.data[0] - for forecast in forecasts.data[:8]: - hour = dt_util.utc_from_timestamp(forecast.localTimestamp).strftime( - "%-I%p" - ) - self.hourly[hour] = forecast - except ConnectionError: - _LOGGER.error("Unable to retrieve data from Magicseaweed") diff --git a/homeassistant/components/magicseaweed/strings.json b/homeassistant/components/magicseaweed/strings.json deleted file mode 100644 index 0aa8a58419..0000000000 --- a/homeassistant/components/magicseaweed/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "pending_removal": { - "title": "The Magicseaweed integration is being removed", - "description": "The Magicseaweed integration is pending removal from Home Assistant and will no longer be available as of Home Assistant 2023.3.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - } -} diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index f97b2c5337..29f023d0de 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -86,6 +86,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component = EntityComponent[MailboxEntity]( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL ) + component.register_shutdown() await component.async_add_entities([mailbox_entity]) setup_tasks = [ diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index f0436ba1d6..da77aea6c4 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time @@ -285,56 +286,34 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_write_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -383,18 +362,22 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( parse_result=False, from_state=self._state, to_state=state ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index d6b4a58c41..adb251bd71 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -186,13 +187,19 @@ PLATFORM_SCHEMA = vol.Schema( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the manual MQTT alarm platform.""" + # Make sure MQTT integration is enabled and the client is available + # We cannot count on dependencies as the alarm_control_panel platform setup + # also will be triggered when mqtt is loading the `alarm_control_panel` platform + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return add_entities( [ ManualMQTTAlarm( @@ -345,56 +352,34 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_schedule_update_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -436,18 +421,22 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( from_state=self._state, to_state=state, parse_result=False ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index b4d1b867e7..a82614cbcc 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -1,6 +1,8 @@ """Matter binary sensors.""" from __future__ import annotations +from dataclasses import dataclass + from chip.clusters import Objects as clusters from chip.clusters.Objects import uint from chip.clusters.Types import Nullable, NullValue @@ -15,7 +17,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -30,9 +32,18 @@ async def async_setup_entry( matter.register_platform_handler(Platform.BINARY_SENSOR, async_add_entities) +@dataclass +class MatterBinarySensorEntityDescription( + BinarySensorEntityDescription, MatterEntityDescription +): + """Describe Matter binary sensor entities.""" + + class MatterBinarySensor(MatterEntity, BinarySensorEntity): """Representation of a Matter binary sensor.""" + entity_description: MatterBinarySensorEntityDescription + @callback def _update_from_device(self) -> None: """Update from device.""" @@ -40,7 +51,7 @@ class MatterBinarySensor(MatterEntity, BinarySensorEntity): value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value in (None, NullValue): value = None - elif value_convert := self._entity_info.measurement_to_ha: + elif value_convert := self.entity_description.measurement_to_ha: value = value_convert(value) self._attr_is_on = value @@ -51,52 +62,53 @@ DISCOVERY_SCHEMAS = [ # instead of generic occupancy sensor MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + 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, required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), vendor_id=(4107,), product_name=("Hue motion sensor",), - measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + 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, ), entity_class=MatterBinarySensor, required_attributes=(clusters.BooleanState.Attributes.StateValue,), - # value is inverted on matter to what we expect - measurement_to_ha=lambda x: not x, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + 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, ), entity_class=MatterBinarySensor, required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), - # The first bit = if occupied - measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="BatteryChargeLevel", device_class=BinarySensorDeviceClass.BATTERY, name="Battery Status", + measurement_to_ha=lambda x: x + != clusters.PowerSource.Enums.BatChargeLevel.kOk, ), entity_class=MatterBinarySensor, required_attributes=(clusters.PowerSource.Attributes.BatChargeLevel,), # only add binary battery sensor if a regular percentage based is not available absent_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), - measurement_to_ha=lambda x: x != clusters.PowerSource.Enums.BatChargeLevel.kOk, ), ] diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py new file mode 100644 index 0000000000..487594561d --- /dev/null +++ b/homeassistant/components/matter/cover.py @@ -0,0 +1,153 @@ +"""Matter cover.""" +from __future__ import annotations + +from enum import IntEnum +from typing import Any + +from chip.clusters import Objects as clusters + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverEntity, + CoverEntityDescription, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import LOGGER +from .entity import MatterEntity +from .helpers import get_matter +from .models import MatterDiscoverySchema + +# The MASK used for extracting bits 0 to 1 of the byte. +OPERATIONAL_STATUS_MASK = 0b11 + + +class OperationalStatus(IntEnum): + """Currently ongoing operations enumeration for coverings, as defined in the Matter spec.""" + + COVERING_IS_CURRENTLY_NOT_MOVING = 0b00 + COVERING_IS_CURRENTLY_OPENING = 0b01 + COVERING_IS_CURRENTLY_CLOSING = 0b10 + RESERVED = 0b11 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter Cover from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.COVER, async_add_entities) + + +class MatterCover(MatterEntity, CoverEntity): + """Representation of a Matter Cover.""" + + entity_description: CoverEntityDescription + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + @property + def current_cover_position(self) -> int: + """Return the current position of cover.""" + if self._attr_current_cover_position: + current_position = self._attr_current_cover_position + else: + current_position = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage + ) + + assert current_position is not None + + return current_position + + @property + def is_closed(self) -> bool: + """Return true if cover is closed, else False.""" + return self.current_cover_position == 0 + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover movement.""" + await self.send_device_command(clusters.WindowCovering.Commands.StopMotion()) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_device_command(clusters.WindowCovering.Commands.UpOrOpen()) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.send_device_command(clusters.WindowCovering.Commands.DownOrClose()) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Set the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + await self.send_device_command( + clusters.WindowCovering.Commands.GoToLiftValue(position) + ) + + async def send_device_command(self, command: Any) -> None: + """Send device command.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + operational_status = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.OperationalStatus + ) + + assert operational_status is not None + + LOGGER.debug( + "Operational status %s for %s", + f"{operational_status:#010b}", + self.entity_id, + ) + + state = operational_status & OPERATIONAL_STATUS_MASK + match state: + case OperationalStatus.COVERING_IS_CURRENTLY_OPENING: + self._attr_is_opening = True + self._attr_is_closing = False + case OperationalStatus.COVERING_IS_CURRENTLY_CLOSING: + self._attr_is_opening = False + self._attr_is_closing = True + case _: + self._attr_is_opening = False + self._attr_is_closing = False + + self._attr_current_cover_position = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage + ) + LOGGER.debug( + "Current position: %s for %s", + self._attr_current_cover_position, + self.entity_id, + ) + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.COVER, + entity_description=CoverEntityDescription(key="MatterCover"), + entity_class=MatterCover, + required_attributes=( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, + clusters.WindowCovering.Attributes.OperationalStatus, + ), + ), +] diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 36f415dacc..28f5b6b7f9 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -10,6 +10,7 @@ from homeassistant.const import Platform from homeassistant.core import callback from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS +from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS from .models import MatterDiscoverySchema, MatterEntityInfo @@ -18,12 +19,13 @@ from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, + Platform.COVER: COVER_SCHEMAS, Platform.LIGHT: LIGHT_SCHEMAS, Platform.LOCK: LOCK_SCHEMAS, Platform.SENSOR: SENSOR_SCHEMAS, Platform.SWITCH: SWITCH_SCHEMAS, } -SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS.keys()) +SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS) @callback @@ -109,7 +111,6 @@ def async_discover_entities( attributes_to_watch=attributes_to_watch, entity_description=schema.entity_description, entity_class=schema.entity_class, - measurement_to_ha=schema.measurement_to_ha, ) # prevent re-discovery of the same attributes diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index a1d67158ab..bf0a74ef84 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import abstractmethod from collections.abc import Callable +from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast @@ -11,7 +12,7 @@ from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import DOMAIN, ID_TYPE_DEVICE_ID from .helpers import get_device_id @@ -25,6 +26,14 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) +@dataclass +class MatterEntityDescription(EntityDescription): + """Describe the Matter entity.""" + + # convert the value from the primary attribute to the value used by HA + measurement_to_ha: Callable[[Any], Any] | None = None + + class MatterEntity(Entity): """Entity class for Matter devices.""" diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index b81ac2c62b..190bf33dcf 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.1.0"] + "requirements": ["python-matter-server==3.2.0"] } diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 2575b16e8b..3ac7f66b83 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -1,9 +1,7 @@ """Models used for the Matter integration.""" from __future__ import annotations -from collections.abc import Callable -from dataclasses import asdict, dataclass -from typing import TYPE_CHECKING, Any +from dataclasses import dataclass from chip.clusters import Objects as clusters from chip.clusters.Objects import ClusterAttributeDescriptor @@ -13,19 +11,6 @@ from matter_server.client.models.node import MatterEndpoint from homeassistant.const import Platform from homeassistant.helpers.entity import EntityDescription -if TYPE_CHECKING: - from _typeshed import DataclassInstance - - -class DataclassMustHaveAtLeastOne: - """A dataclass that must have at least one input parameter that is not None.""" - - def __post_init__(self: DataclassInstance) -> None: - """Post dataclass initialization.""" - if all(val is None for val in asdict(self).values()): - raise ValueError("At least one input parameter must not be None") - - SensorValueTypes = type[ clusters.uint | int | clusters.Nullable | clusters.float32 | float ] @@ -50,9 +35,6 @@ class MatterEntityInfo: # entity class to use to instantiate the entity entity_class: type - # [optional] function to call to convert the value from the primary attribute - measurement_to_ha: Callable[[SensorValueTypes], SensorValueTypes] | None = None - @property def primary_attribute(self) -> type[ClusterAttributeDescriptor]: """Return Primary Attribute belonging to the entity.""" @@ -63,7 +45,8 @@ class MatterEntityInfo: class MatterDiscoverySchema: """Matter discovery schema. - The Matter endpoint and it's (primary) Attribute for an entity must match these conditions. + The Matter endpoint and its (primary) Attribute + for an entity must match these conditions. """ # specify the hass platform for which this scheme applies (e.g. light, sensor) @@ -108,6 +91,3 @@ class MatterDiscoverySchema: # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False - - # [optional] function to call to convert the value from the primary attribute - measurement_to_ha: Callable[[Any], Any] | None = None diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 34760fbbf1..84e68695d6 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -1,6 +1,8 @@ """Matter sensors.""" from __future__ import annotations +from dataclasses import dataclass + from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue @@ -22,7 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -37,10 +39,16 @@ async def async_setup_entry( matter.register_platform_handler(Platform.SENSOR, async_add_entities) +@dataclass +class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescription): + """Describe Matter sensor entities.""" + + class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" _attr_state_class = SensorStateClass.MEASUREMENT + entity_description: MatterSensorEntityDescription @callback def _update_from_device(self) -> None: @@ -49,7 +57,7 @@ class MatterSensor(MatterEntity, SensorEntity): value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value in (None, NullValue): value = None - elif value_convert := self._entity_info.measurement_to_ha: + elif value_convert := self.entity_description.measurement_to_ha: value = value_convert(value) self._attr_native_value = value @@ -58,77 +66,77 @@ class MatterSensor(MatterEntity, SensorEntity): DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="TemperatureSensor", name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + measurement_to_ha=lambda x: x / 100, ), entity_class=MatterSensor, required_attributes=(clusters.TemperatureMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: x / 100, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="PressureSensor", name="Pressure", native_unit_of_measurement=UnitOfPressure.KPA, device_class=SensorDeviceClass.PRESSURE, + measurement_to_ha=lambda x: x / 10, ), entity_class=MatterSensor, required_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: x / 10, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + 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 ? + measurement_to_ha=lambda x: x / 10, ), entity_class=MatterSensor, required_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: x / 10, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="HumiditySensor", name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, + measurement_to_ha=lambda x: x / 100, ), entity_class=MatterSensor, required_attributes=( clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue, ), - measurement_to_ha=lambda x: x / 100, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + 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), ), entity_class=MatterSensor, required_attributes=(clusters.IlluminanceMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="PowerSource", name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + # value has double precision + measurement_to_ha=lambda x: int(x / 2), ), entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), - # value has double precision - measurement_to_ha=lambda x: int(x / 2), ), ] diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 3938cc64f7..0f827d6073 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1037,7 +1037,7 @@ class MediaPlayerEntity(Entity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Return a BrowseMedia instance. @@ -1138,7 +1138,7 @@ class MediaPlayerImageView(HomeAssistantView): self, request: web.Request, entity_id: str, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> web.Response: """Start a get request.""" diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py index d1328a851d..2b046868f1 100644 --- a/homeassistant/components/media_player/browse_media.py +++ b/homeassistant/components/media_player/browse_media.py @@ -44,7 +44,7 @@ def async_process_play_media_url( return media_content_id else: if media_content_id[0] != "/": - raise ValueError("URL is relative, but does not start with a /") + return media_content_id if parsed.query: logging.getLogger(__name__).debug( diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index d211b878b9..c29794ae8d 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -48,7 +48,10 @@ class LocalSource(MediaSource): @callback def async_full_path(self, source_dir_id: str, location: str) -> Path: """Return full path.""" - return Path(self.hass.config.media_dirs[source_dir_id], location) + base_path = self.hass.config.media_dirs[source_dir_id] + full_path = Path(base_path, location) + full_path.relative_to(base_path) + return full_path @callback def async_parse_identifier(self, item: MediaSourceItem) -> tuple[str, str]: @@ -65,6 +68,9 @@ class LocalSource(MediaSource): except ValueError as err: raise Unresolvable("Invalid path.") from err + if Path(location).is_absolute(): + raise Unresolvable("Invalid path.") + return source_dir_id, location async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index 3bf77daf69..cbe71447a3 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, callback from .const import DOMAIN, URI_SCHEME, URI_SCHEME_REGEX -@dataclass +@dataclass(slots=True) class PlayMedia: """Represents a playable media.""" @@ -36,7 +36,7 @@ class BrowseMediaSource(BrowseMedia): self.identifier = identifier -@dataclass +@dataclass(slots=True) class MediaSourceItem: """A parsed media item.""" diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index c95c3abe05..c676f15336 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -18,15 +18,12 @@ from homeassistant.const import ( CONF_LONGITUDE, EVENT_CORE_CONFIG_UPDATE, Platform, - UnitOfLength, ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from homeassistant.util.unit_conversion import DistanceConverter -from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( CONF_TRACK_HOME, @@ -102,9 +99,7 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator["MetWeatherData"]): def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize global Met data updater.""" self._unsub_track_home: Callable[[], None] | None = None - self.weather = MetWeatherData( - hass, config_entry.data, hass.config.units is METRIC_SYSTEM - ) + self.weather = MetWeatherData(hass, config_entry.data) self.weather.set_coordinates() update_interval = timedelta(minutes=randrange(55, 65)) @@ -142,13 +137,10 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator["MetWeatherData"]): class MetWeatherData: """Keep data for Met.no weather entities.""" - def __init__( - self, hass: HomeAssistant, config: MappingProxyType[str, Any], is_metric: bool - ) -> None: + def __init__(self, hass: HomeAssistant, config: MappingProxyType[str, Any]) -> None: """Initialise the weather entity data.""" self.hass = hass self._config = config - self._is_metric = is_metric self._weather_data: metno.MetWeatherData self.current_weather_data: dict = {} self.daily_forecast: list[dict] = [] @@ -165,14 +157,6 @@ class MetWeatherData: latitude = self._config[CONF_LATITUDE] longitude = self._config[CONF_LONGITUDE] elevation = self._config[CONF_ELEVATION] - if not self._is_metric: - elevation = int( - round( - DistanceConverter.convert( - elevation, UnitOfLength.FEET, UnitOfLength.METERS - ) - ) - ) coordinates = { "lat": str(latitude), diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 453c0a9cee..d8cb31077c 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -6,10 +6,21 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import ( + CONF_ELEVATION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + UnitOfLength, +) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, +) from .const import ( CONF_TRACK_HOME, @@ -47,7 +58,14 @@ def _get_data_schema( vol.Required( CONF_LONGITUDE, default=hass.config.longitude ): cv.longitude, - vol.Required(CONF_ELEVATION, default=hass.config.elevation): int, + vol.Required( + CONF_ELEVATION, default=hass.config.elevation + ): NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + unit_of_measurement=UnitOfLength.METERS, + ) + ), } ) # Not tracking home, default values come from config entry @@ -62,7 +80,12 @@ def _get_data_schema( ): cv.longitude, vol.Required( CONF_ELEVATION, default=config_entry.data.get(CONF_ELEVATION) - ): int, + ): NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + unit_of_measurement=UnitOfLength.METERS, + ) + ), } ) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index f507cf8cf3..a6dcb23cc4 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -35,9 +35,6 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from . import MetDataUpdateCoordinator from .const import ATTR_MAP, CONDITIONS_MAP, CONF_TRACK_HOME, DOMAIN, FORECAST_MAP -ATTRIBUTION = ( - "Weather forecast from met.no, delivered by the Norwegian Meteorological Institute." -) DEFAULT_NAME = "Met.no" @@ -74,6 +71,10 @@ def format_condition(condition: str) -> str: class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): """Implementation of a Met.no weather condition.""" + _attr_attribution = ( + "Weather forecast from met.no, delivered by the Norwegian " + "Meteorological Institute." + ) _attr_has_entity_name = True _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS @@ -173,11 +174,6 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): ATTR_MAP[ATTR_WEATHER_WIND_BEARING] ) - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" diff --git a/homeassistant/components/met_eireann/const.py b/homeassistant/components/met_eireann/const.py index efe80cb9d1..1cab9c9099 100644 --- a/homeassistant/components/met_eireann/const.py +++ b/homeassistant/components/met_eireann/const.py @@ -20,8 +20,6 @@ from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, ) -ATTRIBUTION = "Data provided by Met Éireann" - DEFAULT_NAME = "Met Éireann" DOMAIN = "met_eireann" diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index c4d8763efa..cce35731c7 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import ATTRIBUTION, CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP +from .const import CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP _LOGGER = logging.getLogger(__name__) @@ -55,6 +55,7 @@ async def async_setup_entry( class MetEireannWeather(CoordinatorEntity, WeatherEntity): """Implementation of a Met Éireann weather condition.""" + _attr_attribution = "Data provided by Met Éireann" _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS @@ -125,11 +126,6 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): """Return the wind direction.""" return self.coordinator.data.current_weather_data.get("wind_bearing") - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - @property def forecast(self): """Return the forecast array.""" diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 95972a95bb..e1a530eef9 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -83,6 +83,7 @@ class MeteoFranceWeather( ): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA @@ -203,8 +204,3 @@ class MeteoFranceWeather( } ) return forecast_data - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py index 14b953663d..11346ab18f 100644 --- a/homeassistant/components/meteoclimatic/weather.py +++ b/homeassistant/components/meteoclimatic/weather.py @@ -38,6 +38,7 @@ async def async_setup_entry( class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR @@ -98,8 +99,3 @@ class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): def wind_bearing(self): """Return the wind bearing.""" return self.coordinator.data["weather"].wind_bearing - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 911d348365..4354b9b06b 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -24,8 +24,11 @@ ARP: Final = "arp" CAPSMAN: Final = "capsman" DHCP: Final = "dhcp" WIRELESS: Final = "wireless" +WIFIWAVE2: Final = "wifiwave2" IS_WIRELESS: Final = "is_wireless" IS_CAPSMAN: Final = "is_capsman" +IS_WIFIWAVE2: Final = "is_wifiwave2" + MIKROTIK_SERVICES: Final = { ARP: "/ip/arp/getall", @@ -34,8 +37,10 @@ MIKROTIK_SERVICES: Final = { IDENTITY: "/system/identity/getall", INFO: "/system/routerboard/getall", WIRELESS: "/interface/wireless/registration-table/getall", + WIFIWAVE2: "/interface/wifiwave2/registration-table/print", IS_WIRELESS: "/interface/wireless/print", IS_CAPSMAN: "/caps-man/interface/print", + IS_WIFIWAVE2: "/interface/wifiwave2/print", } diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 26a5894862..9e0a610c77 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -31,9 +31,11 @@ from .const import ( IDENTITY, INFO, IS_CAPSMAN, + IS_WIFIWAVE2, IS_WIRELESS, MIKROTIK_SERVICES, NAME, + WIFIWAVE2, WIRELESS, ) from .device import Device @@ -57,6 +59,7 @@ class MikrotikData: self.devices: dict[str, Device] = {} self.support_capsman: bool = False self.support_wireless: bool = False + self.support_wifiwave2: bool = False self.hostname: str = "" self.model: str = "" self.firmware: str = "" @@ -97,6 +100,7 @@ class MikrotikData: self.serial_number = self.get_info(ATTR_SERIAL_NUMBER) self.support_capsman = bool(self.command(MIKROTIK_SERVICES[IS_CAPSMAN])) self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) + self.support_wifiwave2 = bool(self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2])) def get_list_from_interface(self, interface: str) -> dict[str, dict[str, Any]]: """Get devices from interface.""" @@ -121,6 +125,9 @@ class MikrotikData: elif self.support_wireless: _LOGGER.debug("Hub supports wireless Interface") device_list = wireless_devices = self.get_list_from_interface(WIRELESS) + elif self.support_wifiwave2: + _LOGGER.debug("Hub supports wifiwave2 Interface") + device_list = wireless_devices = self.get_list_from_interface(WIFIWAVE2) if not device_list or self.force_dhcp: device_list = self.all_devices diff --git a/homeassistant/components/monessen/__init__.py b/homeassistant/components/monessen/__init__.py new file mode 100644 index 0000000000..6ae0f37d2c --- /dev/null +++ b/homeassistant/components/monessen/__init__.py @@ -0,0 +1 @@ +"""Virtual integration for Monessen Fireplace.""" diff --git a/homeassistant/components/monessen/manifest.json b/homeassistant/components/monessen/manifest.json new file mode 100644 index 0000000000..26eef41cfd --- /dev/null +++ b/homeassistant/components/monessen/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "monessen", + "name": "Monessen", + "integration_type": "virtual", + "supported_by": "intellifire" +} diff --git a/homeassistant/components/motioneye/strings.json b/homeassistant/components/motioneye/strings.json index 742a7ec59a..f92fa11cd7 100644 --- a/homeassistant/components/motioneye/strings.json +++ b/homeassistant/components/motioneye/strings.json @@ -4,10 +4,10 @@ "user": { "data": { "url": "[%key:common::config_flow::data::url%]", - "admin_username": "Admin [%key:common::config_flow::data::username%]", - "admin_password": "Admin [%key:common::config_flow::data::password%]", - "surveillance_username": "Surveillance [%key:common::config_flow::data::username%]", - "surveillance_password": "Surveillance [%key:common::config_flow::data::password%]" + "admin_username": "Admin username", + "admin_password": "Admin password", + "surveillance_username": "Surveillance username", + "surveillance_password": "Surveillance password" } }, "hassio_confirm": { diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 7395777320..457f905824 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -509,7 +509,9 @@ class MpdDevice(MediaPlayerEntity): await self._client.seekcur(position) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 5a9eb7c3fc..5826083355 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -10,7 +10,7 @@ from typing import Any, cast import jinja2 import voluptuous as vol -from homeassistant import config as conf_util, config_entries +from homeassistant import config as conf_util from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -25,16 +25,10 @@ from homeassistant.const import ( ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import TemplateError, Unauthorized -from homeassistant.helpers import ( - config_validation as cv, - discovery_flow, - event, - template, -) +from homeassistant.helpers import config_validation as cv, event, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.reload import ( async_integration_yaml_config, async_reload_integration_platforms, @@ -51,13 +45,7 @@ from .client import ( # noqa: F401 publish, subscribe, ) -from .config_integration import ( - CONFIG_SCHEMA_BASE, - CONFIG_SCHEMA_ENTRY, - DEFAULT_VALUES, - DEPRECATED_CERTIFICATE_CONFIG_KEYS, - DEPRECATED_CONFIG_KEYS, -) +from .config_integration import PLATFORM_CONFIG_SCHEMA_BASE from .const import ( # noqa: F401 ATTR_PAYLOAD, ATTR_QOS, @@ -80,7 +68,10 @@ from .const import ( # noqa: F401 CONF_WS_HEADERS, CONF_WS_PATH, DATA_MQTT, + DATA_MQTT_AVAILABLE, + DEFAULT_DISCOVERY, DEFAULT_ENCODING, + DEFAULT_PREFIX, DEFAULT_QOS, DEFAULT_RETAIN, DOMAIN, @@ -91,15 +82,16 @@ from .const import ( # noqa: F401 ) from .models import ( # noqa: F401 MqttCommandTemplate, + MqttData, MqttValueTemplate, PublishPayloadType, ReceiveMessage, ReceivePayloadType, ) -from .util import ( +from .util import ( # noqa: F401 async_create_certificate_temp_files, + async_wait_for_mqtt_client, get_mqtt_data, - migrate_certificate_file_to_content, mqtt_config_entry_enabled, valid_publish_topic, valid_qos_schema, @@ -111,8 +103,6 @@ _LOGGER = logging.getLogger(__name__) SERVICE_PUBLISH = "publish" SERVICE_DUMP = "dump" -MANDATORY_DEFAULT_VALUES = (CONF_PORT, CONF_DISCOVERY_PREFIX) - ATTR_TOPIC_TEMPLATE = "topic_template" ATTR_PAYLOAD_TEMPLATE = "payload_template" @@ -146,22 +136,22 @@ CONFIG_ENTRY_CONFIG_KEYS = [ CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( - cv.deprecated(CONF_BIRTH_MESSAGE), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_BROKER), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_CERTIFICATE), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_CLIENT_ID), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_CLIENT_CERT), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_CLIENT_KEY), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_DISCOVERY), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_DISCOVERY_PREFIX), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_KEEPALIVE), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_PASSWORD), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_PORT), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_PROTOCOL), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_TLS_INSECURE), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_USERNAME), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_WILL_MESSAGE), # Deprecated in HA Core 2022.3 - CONFIG_SCHEMA_BASE, + cv.removed(CONF_BIRTH_MESSAGE), # Removed in HA Core 2023.4 + cv.removed(CONF_BROKER), # Removed in HA Core 2023.4 + cv.removed(CONF_CERTIFICATE), # Removed in HA Core 2023.4 + cv.removed(CONF_CLIENT_ID), # Removed in HA Core 2023.4 + cv.removed(CONF_CLIENT_CERT), # Removed in HA Core 2023.4 + cv.removed(CONF_CLIENT_KEY), # Removed in HA Core 2023.4 + cv.removed(CONF_DISCOVERY), # Removed in HA Core 2022.3 + cv.removed(CONF_DISCOVERY_PREFIX), # Removed in HA Core 2023.4 + cv.removed(CONF_KEEPALIVE), # Removed in HA Core 2023.4 + cv.removed(CONF_PASSWORD), # Removed in HA Core 2023.4 + cv.removed(CONF_PORT), # Removed in HA Core 2023.4 + cv.removed(CONF_PROTOCOL), # Removed in HA Core 2023.4 + cv.removed(CONF_TLS_INSECURE), # Removed in HA Core 2023.4 + cv.removed(CONF_USERNAME), # Removed in HA Core 2023.4 + cv.removed(CONF_WILL_MESSAGE), # Removed in HA Core 2023.4 + PLATFORM_CONFIG_SCHEMA_BASE, ) }, extra=vol.ALLOW_EXTRA, @@ -185,108 +175,6 @@ MQTT_PUBLISH_SCHEMA = vol.All( ) -async def _async_setup_discovery( - hass: HomeAssistant, conf: ConfigType, config_entry: ConfigEntry -) -> None: - """Try to start the discovery of MQTT devices. - - This method is a coroutine. - """ - await discovery.async_start(hass, conf[CONF_DISCOVERY_PREFIX], config_entry) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the MQTT protocol service.""" - mqtt_data = get_mqtt_data(hass, True) - - conf: ConfigType | None = config.get(DOMAIN) - - websocket_api.async_register_command(hass, websocket_subscribe) - websocket_api.async_register_command(hass, websocket_mqtt_info) - - if conf: - conf = dict(conf) - mqtt_data.config = conf - - if (mqtt_entry_status := mqtt_config_entry_enabled(hass)) is None: - # Create an import flow if the user has yaml configured entities etc. - # but no broker configuration. Note: The intention is not for this to - # import broker configuration from YAML because that has been deprecated. - discovery_flow.async_create_flow( - hass, - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={}, - ) - mqtt_data.reload_needed = True - elif mqtt_entry_status is False: - _LOGGER.info( - "MQTT will be not available until the config entry is enabled", - ) - mqtt_data.reload_needed = True - - return True - - -def _filter_entry_config(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Remove unknown keys from config entry data. - - Extra keys may have been added when importing MQTT yaml configuration. - """ - filtered_data = { - k: entry.data[k] for k in CONFIG_ENTRY_CONFIG_KEYS if k in entry.data - } - if entry.data.keys() != filtered_data.keys(): - _LOGGER.warning( - ( - "The following unsupported configuration options were removed from the " - "MQTT config entry: %s" - ), - entry.data.keys() - filtered_data.keys(), - ) - hass.config_entries.async_update_entry(entry, data=filtered_data) - - -async def _async_merge_basic_config( - hass: HomeAssistant, entry: ConfigEntry, yaml_config: dict[str, Any] -) -> None: - """Merge basic options in configuration.yaml config with config entry. - - This mends incomplete migration from old version of HA Core. - """ - entry_updated = False - entry_config = {**entry.data} - for key in DEPRECATED_CERTIFICATE_CONFIG_KEYS: - if key in yaml_config and key not in entry_config: - if ( - content := await hass.async_add_executor_job( - migrate_certificate_file_to_content, yaml_config[key] - ) - ) is not None: - entry_config[key] = content - entry_updated = True - - for key in DEPRECATED_CONFIG_KEYS: - if key in yaml_config and key not in entry_config: - entry_config[key] = yaml_config[key] - entry_updated = True - - for key in MANDATORY_DEFAULT_VALUES: - if key not in entry_config: - entry_config[key] = DEFAULT_VALUES[key] - entry_updated = True - - if entry_updated: - hass.config_entries.async_update_entry(entry, data=entry_config) - - -def _merge_extended_config(entry: ConfigEntry, conf: ConfigType) -> dict[str, Any]: - """Merge advanced options in configuration.yaml config with config entry.""" - # Add default values - conf = {**DEFAULT_VALUES, **conf} - return {**conf, **entry.data} - - async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle signals of config entry being updated. @@ -295,87 +183,56 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) - await hass.config_entries.async_reload(entry.entry_id) -async def async_fetch_config( - hass: HomeAssistant, entry: ConfigEntry -) -> dict[str, Any] | None: - """Fetch fresh MQTT yaml config from the hass config when (re)loading the entry.""" - mqtt_data = get_mqtt_data(hass) - if mqtt_data.reload_entry: - hass_config = await conf_util.async_hass_config_yaml(hass) - mqtt_data.config = CONFIG_SCHEMA_BASE(hass_config.get(DOMAIN, {})) - - # Remove unknown keys from config entry data - _filter_entry_config(hass, entry) - - # Merge basic configuration, and add missing defaults for basic options - await _async_merge_basic_config(hass, entry, mqtt_data.config or {}) - # Bail out if broker setting is missing - if CONF_BROKER not in entry.data: - _LOGGER.error("MQTT broker is not configured, please configure it") - return None - - # If user doesn't have configuration.yaml config, generate default values - # for options not in config entry data - if (conf := mqtt_data.config) is None: - conf = CONFIG_SCHEMA_ENTRY(dict(entry.data)) - - # User has configuration.yaml config, warn about config entry overrides - elif any(key in conf for key in entry.data): - shared_keys = conf.keys() & entry.data.keys() - override = {k: entry.data[k] for k in shared_keys if conf[k] != entry.data[k]} - if CONF_PASSWORD in override: - override[CONF_PASSWORD] = "********" - if CONF_CLIENT_KEY in override: - override[CONF_CLIENT_KEY] = "-----PRIVATE KEY-----" - if override: - _LOGGER.warning( - ( - "Deprecated configuration settings found in configuration.yaml. " - "These settings from your configuration entry will override: %s" - ), - override, - ) - # Register a repair issue - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_broker_settings", - breaks_in_ha_version="2023.4.0", # Warning first added in 2022.11.0 - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_broker_settings", - translation_placeholders={ - "more_info_url": "https://www.home-assistant.io/integrations/mqtt/", - "deprecated_settings": str(shared_keys)[1:-1], - }, - ) - - # Merge advanced configuration values from configuration.yaml - conf = _merge_extended_config(entry, conf) - return conf - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" - mqtt_data = get_mqtt_data(hass, True) + conf: dict[str, Any] + mqtt_data: MqttData - # Merge basic configuration, and add missing defaults for basic options - if (conf := await async_fetch_config(hass, entry)) is None: - # Bail out - return False - await async_create_certificate_temp_files(hass, dict(entry.data)) - mqtt_data.client = MQTT(hass, entry, conf) - # Restore saved subscriptions - if mqtt_data.subscriptions_to_restore: - mqtt_data.client.async_restore_tracked_subscriptions( - mqtt_data.subscriptions_to_restore + async def _setup_client() -> tuple[MqttData, dict[str, Any]]: + """Set up the MQTT client.""" + # Fetch configuration + conf = dict(entry.data) + hass_config = await conf_util.async_hass_config_yaml(hass) + mqtt_yaml = PLATFORM_CONFIG_SCHEMA_BASE(hass_config.get(DOMAIN, {})) + client = MQTT(hass, entry, conf) + if DOMAIN in hass.data: + mqtt_data = get_mqtt_data(hass) + mqtt_data.config = mqtt_yaml + mqtt_data.client = client + else: + # Initial setup + websocket_api.async_register_command(hass, websocket_subscribe) + websocket_api.async_register_command(hass, websocket_mqtt_info) + hass.data[DATA_MQTT] = mqtt_data = MqttData(config=mqtt_yaml, client=client) + client.start(mqtt_data) + + await async_create_certificate_temp_files(hass, dict(entry.data)) + # Restore saved subscriptions + if mqtt_data.subscriptions_to_restore: + mqtt_data.client.async_restore_tracked_subscriptions( + mqtt_data.subscriptions_to_restore + ) + mqtt_data.subscriptions_to_restore = [] + mqtt_data.reload_dispatchers.append( + entry.add_update_listener(_async_config_entry_updated) ) - mqtt_data.subscriptions_to_restore = [] - mqtt_data.reload_dispatchers.append( - entry.add_update_listener(_async_config_entry_updated) - ) - await mqtt_data.client.async_connect() + await mqtt_data.client.async_connect() + return (mqtt_data, conf) + + client_available: asyncio.Future[bool] + if DATA_MQTT_AVAILABLE not in hass.data: + client_available = hass.data[DATA_MQTT_AVAILABLE] = asyncio.Future() + else: + client_available = hass.data[DATA_MQTT_AVAILABLE] + + setup_ok: bool = False + try: + mqtt_data, conf = await _setup_client() + setup_ok = True + finally: + if not client_available.done(): + client_available.set_result(setup_ok) async def async_publish_service(call: ServiceCall) -> None: """Handle MQTT publish service calls.""" @@ -430,7 +287,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return - assert mqtt_data.client is not None and msg_topic is not None + assert msg_topic is not None await mqtt_data.client.async_publish(msg_topic, payload, qos, retain) hass.services.async_register( @@ -480,6 +337,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _reload_config(call: ServiceCall) -> None: """Reload the platforms.""" + # Fetch updated manual configured items and validate + config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} + mqtt_data.updated_config = config_yaml.get(DOMAIN, {}) + # Reload the modern yaml platforms mqtt_platforms = async_get_platforms(hass, DOMAIN) tasks = [ @@ -493,8 +354,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ] await asyncio.gather(*tasks) - config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} - mqtt_data.updated_config = config_yaml.get(DOMAIN, {}) await asyncio.gather( *( [ @@ -534,8 +393,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) # Setup discovery - if conf.get(CONF_DISCOVERY): - await _async_setup_discovery(hass, conf, entry) + if conf.get(CONF_DISCOVERY, DEFAULT_DISCOVERY): + await discovery.async_start( + hass, conf.get(CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX), entry + ) # Setup reload service after all platforms have loaded await async_setup_reload_service() # When the entry is reloaded, also reload manual set up items to enable MQTT @@ -664,7 +525,6 @@ def async_subscribe_connection_status( def is_connected(hass: HomeAssistant) -> bool: """Return if MQTT client is connected.""" mqtt_data = get_mqtt_data(hass) - assert mqtt_data.client is not None return mqtt_data.client.connected @@ -682,7 +542,6 @@ async def async_remove_config_entry_device( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload MQTT dump and publish service when the config entry is unloaded.""" mqtt_data = get_mqtt_data(hass) - assert mqtt_data.client is not None mqtt_client = mqtt_data.client # Unload publish and dump services. @@ -728,6 +587,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: registry_hooks.popitem()[1]() # Wait for all ACKs and stop the loop await mqtt_client.async_disconnect() + + # Cleanup MQTT client availability + hass.data.pop(DATA_MQTT_AVAILABLE, None) # Store remaining subscriptions to be able to restore or reload them # when the entry is set up again if subscriptions := mqtt_client.subscriptions: diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index cc1f86d285..de593385c1 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -50,6 +50,10 @@ ABBREVIATIONS = { "curr_temp_tpl": "current_temperature_template", "dev": "device", "dev_cla": "device_class", + "dir_cmd_t": "direction_command_topic", + "dir_cmd_tpl": "direction_command_template", + "dir_stat_t": "direction_state_topic", + "dir_val_tpl": "direction_value_template", "dock_t": "docked_topic", "dock_tpl": "docked_template", "e": "encoding", diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 5585a6cee5..55db1da6ff 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine, Iterable from functools import lru_cache -import inspect from itertools import chain, groupby import logging from operator import attrgetter @@ -44,7 +43,6 @@ from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.logging import catch_log_exception from .const import ( - ATTR_TOPIC, CONF_BIRTH_MESSAGE, CONF_BROKER, CONF_CERTIFICATE, @@ -56,10 +54,16 @@ from .const import ( CONF_WILL_MESSAGE, CONF_WS_HEADERS, CONF_WS_PATH, + DEFAULT_BIRTH, DEFAULT_ENCODING, + DEFAULT_KEEPALIVE, + DEFAULT_PORT, DEFAULT_PROTOCOL, DEFAULT_QOS, DEFAULT_TRANSPORT, + DEFAULT_WILL, + DEFAULT_WS_HEADERS, + DEFAULT_WS_PATH, MQTT_CONNECTED, MQTT_DISCONNECTED, PROTOCOL_5, @@ -69,6 +73,7 @@ from .const import ( from .models import ( AsyncMessageCallbackType, MessageCallbackType, + MqttData, PublishMessage, PublishPayloadType, ReceiveMessage, @@ -111,11 +116,11 @@ async def async_publish( encoding: str | None = DEFAULT_ENCODING, ) -> None: """Publish message to a MQTT topic.""" - mqtt_data = get_mqtt_data(hass, True) - if mqtt_data.client is None or not mqtt_config_entry_enabled(hass): + if not mqtt_config_entry_enabled(hass): raise HomeAssistantError( f"Cannot publish to topic '{topic}', MQTT is not enabled" ) + mqtt_data = get_mqtt_data(hass) outgoing_payload = payload if not isinstance(payload, bytes): if not encoding: @@ -161,30 +166,11 @@ async def async_subscribe( Call the return value to unsubscribe. """ - mqtt_data = get_mqtt_data(hass, True) - if mqtt_data.client is None or not mqtt_config_entry_enabled(hass): + if not mqtt_config_entry_enabled(hass): raise HomeAssistantError( f"Cannot subscribe to topic '{topic}', MQTT is not enabled" ) - # Support for a deprecated callback type was removed with HA core 2023.3.0 - # The signature validation code can be removed from HA core 2023.5.0 - non_default = 0 - if msg_callback: - non_default = sum( - p.default == inspect.Parameter.empty - for _, p in inspect.signature(msg_callback).parameters.items() - ) - - # Check for not supported callback signatures - # Can be removed from HA core 2023.5.0 - if non_default != 1: - module = inspect.getmodule(msg_callback) - raise HomeAssistantError( - "Signature for MQTT msg_callback '{}.{}' is not supported".format( - module.__name__ if module else "", msg_callback.__name__ - ) - ) - + mqtt_data = get_mqtt_data(hass) async_remove = await mqtt_data.client.async_subscribe( topic, catch_log_exception( @@ -272,8 +258,8 @@ class MqttClientSetup: client_cert = get_file_path(CONF_CLIENT_CERT, config.get(CONF_CLIENT_CERT)) tls_insecure = config.get(CONF_TLS_INSECURE) if transport == TRANSPORT_WEBSOCKETS: - ws_path: str = config[CONF_WS_PATH] - ws_headers: dict[str, str] = config[CONF_WS_HEADERS] + ws_path: str = config.get(CONF_WS_PATH, DEFAULT_WS_PATH) + ws_headers: dict[str, str] = config.get(CONF_WS_HEADERS, DEFAULT_WS_HEADERS) self._client.ws_set_options(ws_path, ws_headers) if certificate is not None: self._client.tls_set( @@ -377,19 +363,16 @@ class MQTT: _mqttc: mqtt.Client _last_subscribe: float + _mqtt_data: MqttData def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - conf: ConfigType, + self, hass: HomeAssistant, config_entry: ConfigEntry, conf: ConfigType ) -> None: """Initialize Home Assistant MQTT client.""" - self._mqtt_data = get_mqtt_data(hass) - self.hass = hass self.config_entry = config_entry self.conf = conf + self._simple_subscriptions: dict[str, list[Subscription]] = {} self._wildcard_subscriptions: list[Subscription] = [] self.connected = False @@ -415,8 +398,6 @@ class MQTT: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, ha_started) - self.init_client() - async def async_stop_mqtt(_event: Event) -> None: """Stop MQTT component.""" await self.async_disconnect() @@ -425,6 +406,14 @@ class MQTT: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_mqtt) ) + def start( + self, + mqtt_data: MqttData, + ) -> None: + """Start Home Assistant MQTT client.""" + self._mqtt_data = mqtt_data + self.init_client() + @property def subscriptions(self) -> list[Subscription]: """Return the tracked subscriptions.""" @@ -448,15 +437,8 @@ class MQTT: self._mqttc.on_subscribe = self._mqtt_on_callback self._mqttc.on_unsubscribe = self._mqtt_on_callback - if ( - CONF_WILL_MESSAGE in self.conf - and ATTR_TOPIC in self.conf[CONF_WILL_MESSAGE] - ): - will_message = PublishMessage(**self.conf[CONF_WILL_MESSAGE]) - else: - will_message = None - - if will_message is not None: + if will := self.conf.get(CONF_WILL_MESSAGE, DEFAULT_WILL): + will_message = PublishMessage(**will) self._mqttc.will_set( topic=will_message.topic, payload=will_message.payload, @@ -499,8 +481,8 @@ class MQTT: result = await self.hass.async_add_executor_job( self._mqttc.connect, self.conf[CONF_BROKER], - self.conf[CONF_PORT], - self.conf[CONF_KEEPALIVE], + self.conf.get(CONF_PORT, DEFAULT_PORT), + self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE), ) except OSError as err: _LOGGER.error("Failed to connect to MQTT server due to exception: %s", err) @@ -734,16 +716,13 @@ class MQTT: _LOGGER.info( "Connected to MQTT server %s:%s (%s)", self.conf[CONF_BROKER], - self.conf[CONF_PORT], + self.conf.get(CONF_PORT, DEFAULT_PORT), result_code, ) self.hass.create_task(self._async_resubscribe()) - if ( - CONF_BIRTH_MESSAGE in self.conf - and ATTR_TOPIC in self.conf[CONF_BIRTH_MESSAGE] - ): + if birth := self.conf.get(CONF_BIRTH_MESSAGE, DEFAULT_BIRTH): async def publish_birth_message(birth_message: PublishMessage) -> None: await self._ha_started.wait() # Wait for Home Assistant to start @@ -757,7 +736,7 @@ class MQTT: retain=birth_message.retain, ) - birth_message = PublishMessage(**self.conf[CONF_BIRTH_MESSAGE]) + birth_message = PublishMessage(**birth) asyncio.run_coroutine_threadsafe( publish_birth_message(birth_message), self.hass.loop ) @@ -876,7 +855,7 @@ class MQTT: _LOGGER.warning( "Disconnected from MQTT server %s:%s (%s)", self.conf[CONF_BROKER], - self.conf[CONF_PORT], + self.conf.get(CONF_PORT, DEFAULT_PORT), result_code, ) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 66424f2c3d..bea8a900a8 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -12,9 +12,9 @@ from cryptography.hazmat.primitives.serialization import load_pem_private_key from cryptography.x509 import load_pem_x509_certificate import voluptuous as vol -from homeassistant import config_entries from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import ( CONF_CLIENT_ID, CONF_DISCOVERY, @@ -25,7 +25,7 @@ from homeassistant.const import ( CONF_PROTOCOL, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import json_dumps @@ -47,7 +47,6 @@ from homeassistant.helpers.selector import ( from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from .client import MqttClientSetup -from .config_integration import CONFIG_SCHEMA_ENTRY from .const import ( ATTR_PAYLOAD, ATTR_QOS, @@ -155,7 +154,7 @@ CERT_UPLOAD_SELECTOR = FileSelector( KEY_UPLOAD_SELECTOR = FileSelector(FileSelectorConfig(accept=".key,application/pkcs8")) -class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): +class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 1 @@ -165,7 +164,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: config_entries.ConfigEntry, + config_entry: ConfigEntry, ) -> MQTTOptionsFlowHandler: """Get the options flow for this handler.""" return MQTTOptionsFlowHandler(config_entry) @@ -187,7 +186,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): fields: OrderedDict[Any, Any] = OrderedDict() validated_user_input: dict[str, Any] = {} if await async_get_broker_settings( - self.hass, + self, fields, None, user_input, @@ -256,10 +255,10 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) -class MQTTOptionsFlowHandler(config_entries.OptionsFlow): +class MQTTOptionsFlowHandler(OptionsFlow): """Handle MQTT options.""" - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize MQTT options flow.""" self.config_entry = config_entry self.broker_config: dict[str, str | int] = {} @@ -277,7 +276,7 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow): fields: OrderedDict[Any, Any] = OrderedDict() validated_user_input: dict[str, Any] = {} if await async_get_broker_settings( - self.hass, + self, fields, self.config_entry.data, user_input, @@ -369,7 +368,6 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow): updated_config = {} updated_config.update(self.broker_config) updated_config.update(options_config) - CONFIG_SCHEMA_ENTRY(updated_config) self.hass.config_entries.async_update_entry( self.config_entry, data=updated_config, @@ -450,7 +448,7 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow): async def async_get_broker_settings( - hass: HomeAssistant, + flow: ConfigFlow | OptionsFlow, fields: OrderedDict[Any, Any], entry_config: MappingProxyType[str, Any] | None, user_input: dict[str, Any] | None, @@ -463,6 +461,7 @@ async def async_get_broker_settings( or when the advanced_broker_options checkbox was selected. Returns True when settings are collected successfully. """ + hass = flow.hass advanced_broker_options: bool = False user_input_basic: dict[str, Any] = {} current_config: dict[str, Any] = ( @@ -588,7 +587,7 @@ async def async_get_broker_settings( current_user = user_input_basic.get(CONF_USERNAME) current_pass = user_input_basic.get(CONF_PASSWORD) else: - # Get default settings from entry or yaml (if any) + # Get default settings from entry (if any) current_broker = current_config.get(CONF_BROKER) current_port = current_config.get(CONF_PORT, DEFAULT_PORT) current_user = current_config.get(CONF_USERNAME) @@ -641,9 +640,12 @@ async def async_get_broker_settings( description={"suggested_value": current_pass}, ) ] = PASSWORD_SELECTOR - # show advanced options checkbox if requested + # show advanced options checkbox if requested and + # advanced options are enabled # or when the defaults of advanced options are overridden if not advanced_broker_options: + if not flow.show_advanced_options: + return False fields[ vol.Optional( ADVANCED_OPTIONS, diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 47f8a7cf49..469f52e148 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -45,37 +45,11 @@ from .const import ( CONF_DISCOVERY_PREFIX, CONF_KEEPALIVE, CONF_TLS_INSECURE, - CONF_TRANSPORT, CONF_WILL_MESSAGE, - CONF_WS_HEADERS, - CONF_WS_PATH, - DEFAULT_BIRTH, - DEFAULT_DISCOVERY, - DEFAULT_KEEPALIVE, - DEFAULT_PORT, - DEFAULT_PREFIX, - DEFAULT_PROTOCOL, - DEFAULT_TRANSPORT, - DEFAULT_WILL, - SUPPORTED_PROTOCOLS, - TRANSPORT_TCP, - TRANSPORT_WEBSOCKETS, ) -from .util import valid_birth_will, valid_publish_topic DEFAULT_TLS_PROTOCOL = "auto" -DEFAULT_VALUES = { - CONF_BIRTH_MESSAGE: DEFAULT_BIRTH, - CONF_DISCOVERY: DEFAULT_DISCOVERY, - CONF_DISCOVERY_PREFIX: DEFAULT_PREFIX, - CONF_PORT: DEFAULT_PORT, - CONF_PROTOCOL: DEFAULT_PROTOCOL, - CONF_TRANSPORT: DEFAULT_TRANSPORT, - CONF_WILL_MESSAGE: DEFAULT_WILL, - CONF_KEEPALIVE: DEFAULT_KEEPALIVE, -} - PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( { Platform.ALARM_CONTROL_PANEL.value: vol.All( @@ -166,61 +140,6 @@ CLIENT_KEY_AUTH_MSG = ( "client_key and client_cert must both be present in the MQTT broker configuration" ) -CONFIG_SCHEMA_ENTRY = vol.Schema( - { - vol.Optional(CONF_CLIENT_ID): cv.string, - vol.Optional(CONF_KEEPALIVE): vol.All(vol.Coerce(int), vol.Range(min=15)), - vol.Optional(CONF_BROKER): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_CERTIFICATE): str, - vol.Inclusive(CONF_CLIENT_KEY, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG): str, - vol.Inclusive( - CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG - ): str, - vol.Optional(CONF_TLS_INSECURE): cv.boolean, - vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(SUPPORTED_PROTOCOLS)), - vol.Optional(CONF_WILL_MESSAGE): valid_birth_will, - vol.Optional(CONF_BIRTH_MESSAGE): valid_birth_will, - vol.Optional(CONF_DISCOVERY): cv.boolean, - # discovery_prefix must be a valid publish topic because if no - # state topic is specified, it will be created with the given prefix. - vol.Optional(CONF_DISCOVERY_PREFIX): valid_publish_topic, - vol.Optional(CONF_TRANSPORT, default=DEFAULT_TRANSPORT): vol.All( - cv.string, vol.In([TRANSPORT_TCP, TRANSPORT_WEBSOCKETS]) - ), - vol.Optional(CONF_WS_PATH, default="/"): cv.string, - vol.Optional(CONF_WS_HEADERS, default={}): {cv.string: cv.string}, - } -) - -CONFIG_SCHEMA_BASE = PLATFORM_CONFIG_SCHEMA_BASE.extend( - { - vol.Optional(CONF_CLIENT_ID): cv.string, - vol.Optional(CONF_KEEPALIVE): vol.All(vol.Coerce(int), vol.Range(min=15)), - vol.Optional(CONF_BROKER): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_CERTIFICATE): vol.Any("auto", cv.isfile), - vol.Inclusive( - CONF_CLIENT_KEY, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG - ): cv.isfile, - vol.Inclusive( - CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG - ): cv.isfile, - vol.Optional(CONF_TLS_INSECURE): cv.boolean, - vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(SUPPORTED_PROTOCOLS)), - vol.Optional(CONF_WILL_MESSAGE): valid_birth_will, - vol.Optional(CONF_BIRTH_MESSAGE): valid_birth_will, - vol.Optional(CONF_DISCOVERY): cv.boolean, - # discovery_prefix must be a valid publish topic because if no - # state topic is specified, it will be created with the given prefix. - vol.Optional(CONF_DISCOVERY_PREFIX): valid_publish_topic, - } -) - DEPRECATED_CONFIG_KEYS = [ CONF_BIRTH_MESSAGE, CONF_BROKER, diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index bb6b8ed497..c91c54a79a 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -35,6 +35,7 @@ CONF_CLIENT_CERT = "client_cert" CONF_TLS_INSECURE = "tls_insecure" DATA_MQTT = "mqtt" +DATA_MQTT_AVAILABLE = "mqtt_client_available" DEFAULT_PREFIX = "homeassistant" DEFAULT_BIRTH_WILL_TOPIC = DEFAULT_PREFIX + "/status" @@ -46,6 +47,7 @@ DEFAULT_PAYLOAD_AVAILABLE = "online" DEFAULT_PAYLOAD_NOT_AVAILABLE = "offline" DEFAULT_PORT = 1883 DEFAULT_RETAIN = False +DEFAULT_WS_HEADERS: dict[str, str] = {} DEFAULT_WS_PATH = "/" PROTOCOL_31 = "3.1" @@ -113,6 +115,7 @@ RELOADABLE_PLATFORMS = [ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.DEVICE_TRACKER, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a764b24b2e..342e7d121f 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -218,7 +218,8 @@ async def async_start( # noqa: C901 discovery_hash = (component, discovery_id) if discovery_hash in mqtt_data.discovery_already_discovered or payload: - async def discovery_done(_: Any) -> None: + @callback + def discovery_done(_: Any) -> None: pending = mqtt_data.discovery_pending_discovered[discovery_hash][ "pending" ] @@ -310,10 +311,7 @@ async def async_start( # noqa: C901 and result["reason"] in ("already_configured", "single_instance_allowed") ): - unsub = mqtt_data.integration_unsubscribe.pop(key, None) - if unsub is None: - return - unsub() + mqtt_data.integration_unsubscribe.pop(key)() for topic in topics: key = f"{integration}_{topic}" diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 74290abb75..e8259c6080 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components import fan from homeassistant.components.fan import ( + ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, ATTR_PRESET_MODE, @@ -56,6 +57,7 @@ from .mixins import ( warn_for_legacy_schema, ) from .models import ( + MessageCallbackType, MqttCommandTemplate, MqttValueTemplate, PublishPayloadType, @@ -64,6 +66,10 @@ from .models import ( ) from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" +CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic" +CONF_DIRECTION_VALUE_TEMPLATE = "direction_value_template" +CONF_DIRECTION_COMMAND_TEMPLATE = "direction_command_template" CONF_PERCENTAGE_STATE_TOPIC = "percentage_state_topic" CONF_PERCENTAGE_COMMAND_TOPIC = "percentage_command_topic" CONF_PERCENTAGE_VALUE_TEMPLATE = "percentage_value_template" @@ -128,6 +134,10 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_DIRECTION_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_DIRECTION_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_DIRECTION_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_DIRECTION_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_OSCILLATION_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_OSCILLATION_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_OSCILLATION_STATE_TOPIC): valid_subscribe_topic, @@ -225,6 +235,7 @@ class MqttFan(MqttEntity, FanEntity): _feature_preset_mode: bool _topic: dict[str, Any] _optimistic: bool + _optimistic_direction: bool _optimistic_oscillation: bool _optimistic_percentage: bool _optimistic_preset_mode: bool @@ -260,6 +271,8 @@ class MqttFan(MqttEntity, FanEntity): for key in ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, + CONF_DIRECTION_STATE_TOPIC, + CONF_DIRECTION_COMMAND_TOPIC, CONF_PERCENTAGE_STATE_TOPIC, CONF_PERCENTAGE_COMMAND_TOPIC, CONF_PRESET_MODE_STATE_TOPIC, @@ -292,6 +305,9 @@ class MqttFan(MqttEntity, FanEntity): optimistic = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + self._optimistic_direction = ( + optimistic or self._topic[CONF_DIRECTION_STATE_TOPIC] is None + ) self._optimistic_oscillation = ( optimistic or self._topic[CONF_OSCILLATION_STATE_TOPIC] is None ) @@ -307,6 +323,10 @@ class MqttFan(MqttEntity, FanEntity): self._topic[CONF_OSCILLATION_COMMAND_TOPIC] is not None and FanEntityFeature.OSCILLATE ) + self._attr_supported_features |= ( + self._topic[CONF_DIRECTION_COMMAND_TOPIC] is not None + and FanEntityFeature.DIRECTION + ) if self._feature_percentage: self._attr_supported_features |= FanEntityFeature.SET_SPEED if self._feature_preset_mode: @@ -314,6 +334,7 @@ class MqttFan(MqttEntity, FanEntity): command_templates: dict[str, Template | None] = { CONF_STATE: config.get(CONF_COMMAND_TEMPLATE), + ATTR_DIRECTION: config.get(CONF_DIRECTION_COMMAND_TEMPLATE), ATTR_PERCENTAGE: config.get(CONF_PERCENTAGE_COMMAND_TEMPLATE), ATTR_PRESET_MODE: config.get(CONF_PRESET_MODE_COMMAND_TEMPLATE), ATTR_OSCILLATING: config.get(CONF_OSCILLATION_COMMAND_TEMPLATE), @@ -327,6 +348,7 @@ class MqttFan(MqttEntity, FanEntity): self._value_templates = {} value_templates: dict[str, Template | None] = { CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), + ATTR_DIRECTION: config.get(CONF_DIRECTION_VALUE_TEMPLATE), ATTR_PERCENTAGE: config.get(CONF_PERCENTAGE_VALUE_TEMPLATE), ATTR_PRESET_MODE: config.get(CONF_PRESET_MODE_VALUE_TEMPLATE), ATTR_OSCILLATING: config.get(CONF_OSCILLATION_VALUE_TEMPLATE), @@ -341,6 +363,17 @@ class MqttFan(MqttEntity, FanEntity): """(Re)Subscribe to topics.""" topics: dict[str, Any] = {} + def add_subscribe_topic(topic: str, msg_callback: MessageCallbackType) -> bool: + """Add a topic to subscribe to.""" + if has_topic := self._topic[topic] is not None: + topics[topic] = { + "topic": self._topic[topic], + "msg_callback": msg_callback, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + return has_topic + @callback @log_messages(self.hass, self.entity_id) def state_received(msg: ReceiveMessage) -> None: @@ -357,13 +390,7 @@ class MqttFan(MqttEntity, FanEntity): self._attr_is_on = None get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_STATE_TOPIC] is not None: - topics[CONF_STATE_TOPIC] = { - "topic": self._topic[CONF_STATE_TOPIC], - "msg_callback": state_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } + add_subscribe_topic(CONF_STATE_TOPIC, state_received) @callback @log_messages(self.hass, self.entity_id) @@ -408,14 +435,7 @@ class MqttFan(MqttEntity, FanEntity): self._attr_percentage = percentage get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_PERCENTAGE_STATE_TOPIC] is not None: - topics[CONF_PERCENTAGE_STATE_TOPIC] = { - "topic": self._topic[CONF_PERCENTAGE_STATE_TOPIC], - "msg_callback": percentage_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - self._attr_percentage = None + add_subscribe_topic(CONF_PERCENTAGE_STATE_TOPIC, percentage_received) @callback @log_messages(self.hass, self.entity_id) @@ -441,14 +461,7 @@ class MqttFan(MqttEntity, FanEntity): self._attr_preset_mode = preset_mode get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_PRESET_MODE_STATE_TOPIC] is not None: - topics[CONF_PRESET_MODE_STATE_TOPIC] = { - "topic": self._topic[CONF_PRESET_MODE_STATE_TOPIC], - "msg_callback": preset_mode_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - self._attr_preset_mode = None + add_subscribe_topic(CONF_PRESET_MODE_STATE_TOPIC, preset_mode_received) @callback @log_messages(self.hass, self.entity_id) @@ -464,15 +477,22 @@ class MqttFan(MqttEntity, FanEntity): self._attr_oscillating = False get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None: - topics[CONF_OSCILLATION_STATE_TOPIC] = { - "topic": self._topic[CONF_OSCILLATION_STATE_TOPIC], - "msg_callback": oscillation_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } + if add_subscribe_topic(CONF_OSCILLATION_STATE_TOPIC, oscillation_received): self._attr_oscillating = False + @callback + @log_messages(self.hass, self.entity_id) + def direction_received(msg: ReceiveMessage) -> None: + """Handle new received MQTT message for the direction.""" + direction = self._value_templates[ATTR_DIRECTION](msg.payload) + if not direction: + _LOGGER.debug("Ignoring empty direction from '%s'", msg.topic) + return + self._attr_current_direction = str(direction) + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + add_subscribe_topic(CONF_DIRECTION_STATE_TOPIC, direction_received) + self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics ) @@ -602,3 +622,22 @@ class MqttFan(MqttEntity, FanEntity): if self._optimistic_oscillation: self._attr_oscillating = oscillating self.async_write_ha_state() + + async def async_set_direction(self, direction: str) -> None: + """Set direction. + + This method is a coroutine. + """ + mqtt_payload = self._command_templates[ATTR_DIRECTION](direction) + + await self.async_publish( + self._topic[CONF_DIRECTION_COMMAND_TOPIC], + mqtt_payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + + if self._optimistic_direction: + self._attr_current_direction = direction + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 93069791a7..2c6dae54f4 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -125,7 +125,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( vol.Optional( CONF_DEVICE_CLASS, default=HumidifierDeviceClass.HUMIDIFIER ): vol.In( - [HumidifierDeviceClass.HUMIDIFIER, HumidifierDeviceClass.DEHUMIDIFIER] + [HumidifierDeviceClass.HUMIDIFIER, HumidifierDeviceClass.DEHUMIDIFIER, None] ), vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_MODE_STATE_TOPIC): valid_subscribe_topic, diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 358a97ed30..b3659a67e6 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -468,6 +468,10 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): return device_value = float(payload) + if device_value == 0: + _LOGGER.debug("Ignoring zero brightness from '%s'", msg.topic) + return + percent_bright = device_value / self._config[CONF_BRIGHTNESS_SCALE] self._attr_brightness = min(round(percent_bright * 255), 255) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index e0b20436fe..c40dae659b 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -378,11 +378,18 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): if brightness_supported(self.supported_color_modes): try: - self._attr_brightness = int( - values["brightness"] # type: ignore[operator] - / float(self._config[CONF_BRIGHTNESS_SCALE]) - * 255 - ) + if brightness := values["brightness"]: + self._attr_brightness = int( + brightness # type: ignore[operator] + / float(self._config[CONF_BRIGHTNESS_SCALE]) + * 255 + ) + else: + _LOGGER.debug( + "Ignoring zero brightness value for entity %s", + self.entity_id, + ) + except KeyError: pass except (TypeError, ValueError): diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index d0eaa31548..c2b4de289f 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -236,11 +236,20 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): if CONF_BRIGHTNESS_TEMPLATE in self._config: try: - self._attr_brightness = int( + if brightness := int( self._value_templates[CONF_BRIGHTNESS_TEMPLATE](msg.payload) - ) + ): + self._attr_brightness = brightness + else: + _LOGGER.debug( + "Ignoring zero brightness value for entity %s", + self.entity_id, + ) + except ValueError: - _LOGGER.warning("Invalid brightness value received") + _LOGGER.warning( + "Invalid brightness value received from %s", msg.topic + ) if CONF_COLOR_TEMP_TEMPLATE in self._config: try: diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index b52c57ce24..3882643809 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -247,7 +247,7 @@ def warn_for_legacy_schema(domain: str) -> Callable[[ConfigType], ConfigType]: ( "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/#new_format" + "https://www.home-assistant.io/integrations/%s.mqtt/" ), domain, domain, @@ -562,7 +562,6 @@ class MqttAvailability(Entity): def available(self) -> bool: """Return if the device is available.""" mqtt_data = get_mqtt_data(self.hass) - assert mqtt_data.client is not None client = mqtt_data.client if not client.connected and not self.hass.is_stopping: return False @@ -833,8 +832,37 @@ class MqttDiscoveryUpdate(Entity): else: await self.async_remove(force_remove=True) - async def discovery_callback(payload: MQTTDiscoveryPayload) -> None: - """Handle discovery update.""" + async def _async_process_discovery_update( + payload: MQTTDiscoveryPayload, + discovery_update: Callable[ + [MQTTDiscoveryPayload], Coroutine[Any, Any, None] + ], + discovery_data: DiscoveryInfoType, + ) -> None: + """Process discovery update.""" + try: + await discovery_update(payload) + finally: + send_discovery_done(self.hass, discovery_data) + + async def _async_process_discovery_update_and_remove( + payload: MQTTDiscoveryPayload, discovery_data: DiscoveryInfoType + ) -> None: + """Process discovery update and remove entity.""" + self._cleanup_discovery_on_remove() + await _async_remove_state_and_registry_entry(self) + send_discovery_done(self.hass, discovery_data) + + @callback + def discovery_callback(payload: MQTTDiscoveryPayload) -> None: + """Handle discovery update. + + If the payload has changed we will create a task to + do the discovery update. + + As this callback can fire when nothing has changed, this + is a normal function to avoid task creation until it is needed. + """ _LOGGER.debug( "Got update for entity with hash: %s '%s'", discovery_hash, @@ -847,17 +875,20 @@ class MqttDiscoveryUpdate(Entity): if not payload: # Empty payload: Remove component _LOGGER.info("Removing component: %s", self.entity_id) - self._cleanup_discovery_on_remove() - await _async_remove_state_and_registry_entry(self) - send_discovery_done(self.hass, self._discovery_data) + self.hass.async_create_task( + _async_process_discovery_update_and_remove( + payload, self._discovery_data + ) + ) elif self._discovery_update: if old_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD]: # Non-empty, changed payload: Notify component _LOGGER.info("Updating component: %s", self.entity_id) - try: - await self._discovery_update(payload) - finally: - send_discovery_done(self.hass, self._discovery_data) + self.hass.async_create_task( + _async_process_discovery_update( + payload, self._discovery_update, self._discovery_data + ) + ) else: # Non-empty, unchanged payload: Ignore to avoid changing states _LOGGER.debug("Ignoring unchanged update for: %s", self.entity_id) @@ -1162,10 +1193,9 @@ def async_removed_from_device( if "config_entries" not in event.data["changes"]: return False device_registry = dr.async_get(hass) - if not (device_entry := device_registry.async_get(device_id)): - # The device is already removed, do cleanup when we get "remove" event - return False - if config_entry_id in device_entry.config_entries: + if ( + device_entry := device_registry.async_get(device_id) + ) and config_entry_id in device_entry.config_entries: # Not removed from device return False diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 84735c55e0..eac333e2a7 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -288,8 +288,8 @@ class EntityTopicState: class MqttData: """Keep the MQTT entry data.""" - client: MQTT | None = None - config: ConfigType | None = None + client: MQTT + config: ConfigType debug_info_entities: dict[str, EntityDebugInfo] = field(default_factory=dict) debug_info_triggers: dict[tuple[str, str], TriggerDebugInfo] = field( default_factory=dict diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 62bb9123a7..1ab14b2b4f 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -97,7 +97,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( vol.Optional(CONF_STEP, default=DEFAULT_STEP): vol.All( vol.Coerce(float), vol.Range(min=1e-3) ), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.Any(cv.string, None), vol.Optional(CONF_VALUE_TEMPLATE): cv.template, }, ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index aea357bea6..9de442926a 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -107,7 +107,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_SUGGESTED_DISPLAY_PRECISION): cv.positive_int, vol.Optional(CONF_STATE_CLASS): vol.Any(STATE_CLASSES_SCHEMA, None), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.Any(cv.string, None), } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 29beafdbe8..7ccc31bd33 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -124,11 +124,9 @@ def async_prepare_subscribe_topics( async def async_subscribe_topics( hass: HomeAssistant, - sub_state: dict[str, EntitySubscription] | None, + sub_state: dict[str, EntitySubscription], ) -> None: """(Re)Subscribe to a set of MQTT topics.""" - if sub_state is None: - return for sub in sub_state.values(): await sub.subscribe() diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 44deb0781c..896ba21f80 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -2,13 +2,16 @@ from __future__ import annotations +import asyncio import os from pathlib import Path import tempfile from typing import Any +import async_timeout import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.typing import ConfigType @@ -22,6 +25,7 @@ from .const import ( CONF_CLIENT_CERT, CONF_CLIENT_KEY, DATA_MQTT, + DATA_MQTT_AVAILABLE, DEFAULT_ENCODING, DEFAULT_QOS, DEFAULT_RETAIN, @@ -29,6 +33,8 @@ from .const import ( ) from .models import MqttData +AVAILABILITY_TIMEOUT = 30.0 + TEMP_DIR_NAME = f"home-assistant-{DOMAIN}" _VALID_QOS_SCHEMA = vol.All(vol.Coerce(int), vol.In([0, 1, 2])) @@ -41,6 +47,37 @@ def mqtt_config_entry_enabled(hass: HomeAssistant) -> bool | None: return not bool(hass.config_entries.async_entries(DOMAIN)[0].disabled_by) +async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: + """Wait for the MQTT client to become available. + + Waits when mqtt set up is in progress, + It is not needed that the client is connected. + Returns True if the mqtt client is available. + Returns False when the client is not available. + """ + if not mqtt_config_entry_enabled(hass): + return False + + entry = hass.config_entries.async_entries(DOMAIN)[0] + if entry.state == ConfigEntryState.LOADED: + return True + + state_reached_future: asyncio.Future[bool] + if DATA_MQTT_AVAILABLE not in hass.data: + hass.data[DATA_MQTT_AVAILABLE] = state_reached_future = asyncio.Future() + else: + state_reached_future = hass.data[DATA_MQTT_AVAILABLE] + if state_reached_future.done(): + return state_reached_future.result() + + try: + async with async_timeout.timeout(AVAILABILITY_TIMEOUT): + # Await the client setup or an error state was received + return await state_reached_future + except asyncio.TimeoutError: + return False + + def valid_topic(topic: Any) -> str: """Validate that this is a valid topic name/filter.""" validated_topic = cv.string(topic) @@ -136,12 +173,9 @@ def valid_birth_will(config: ConfigType) -> ConfigType: return config -def get_mqtt_data(hass: HomeAssistant, ensure_exists: bool = False) -> MqttData: +def get_mqtt_data(hass: HomeAssistant) -> MqttData: """Return typed MqttData from hass.data[DATA_MQTT].""" mqtt_data: MqttData - if ensure_exists: - mqtt_data = hass.data.setdefault(DATA_MQTT, MqttData()) - return mqtt_data mqtt_data = hass.data[DATA_MQTT] return mqtt_data diff --git a/homeassistant/components/mqtt_json/device_tracker.py b/homeassistant/components/mqtt_json/device_tracker.py index 2c67751551..2b355eb68e 100644 --- a/homeassistant/components/mqtt_json/device_tracker.py +++ b/homeassistant/components/mqtt_json/device_tracker.py @@ -47,6 +47,13 @@ async def async_setup_scanner( discovery_info: DiscoveryInfoType | None = None, ) -> bool: """Set up the MQTT JSON tracker.""" + # Make sure MQTT integration is enabled and the client is available + # We cannot count on dependencies as the device_tracker platform setup + # also will be triggered when mqtt is loading the `device_tracker` platform + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return False + devices = config[CONF_DEVICES] qos = config[CONF_QOS] diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index b1b52e42fc..00441690b4 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -68,6 +68,12 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up MQTT room Sensor.""" + # Make sure MQTT integration is enabled and the client is available + # We cannot count on dependencies as the sensor platform setup + # also will be triggered when mqtt is loading the `sensor` platform + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return async_add_entities( [ MQTTRoomSensor( diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py index 0142573754..aa4c2c628b 100644 --- a/homeassistant/components/mqtt_statestream/__init__.py +++ b/homeassistant/components/mqtt_statestream/__init__.py @@ -41,15 +41,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MQTT state feed.""" - # Make sure MQTT is available and the entry is loaded - if not hass.config_entries.async_entries( - mqtt.DOMAIN - ) or not await hass.config_entries.async_wait_component( - hass.config_entries.async_entries(mqtt.DOMAIN)[0] - ): - _LOGGER.error("MQTT integration is not available") - return False - conf: ConfigType = config[DOMAIN] publish_filter = convert_include_exclude_filter(conf) base_topic: str = conf[CONF_BASE_TOPIC] diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index d8c3debe7e..5b8154e17a 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -1,8 +1,7 @@ """Connect to a MySensors gateway via pymysensors API.""" from __future__ import annotations -from collections.abc import Callable -from functools import partial +from collections.abc import Callable, Mapping import logging from mysensors import BaseAsyncGateway @@ -12,24 +11,19 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_DEVICES, DOMAIN, - MYSENSORS_DISCOVERY, MYSENSORS_GATEWAYS, MYSENSORS_ON_UNLOAD, - PLATFORMS_WITH_ENTRY_SUPPORT, + PLATFORMS, DevId, DiscoveryInfo, SensorType, ) -from .device import MySensorsDevice, get_mysensors_devices +from .device import MySensorsEntity, get_mysensors_devices from .gateway import finish_setup, gw_stop, setup_gateway -from .helpers import on_unload _LOGGER = logging.getLogger(__name__) @@ -39,14 +33,6 @@ DATA_HASS_CONFIG = "hass_config" CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the MySensors component.""" - # This is needed to set up the notify platform via discovery. - hass.data[DOMAIN] = {DATA_HASS_CONFIG: config} - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an instance of the MySensors integration. @@ -58,33 +44,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Gateway setup failed for %s", entry.data) return False - if MYSENSORS_GATEWAYS not in hass.data[DOMAIN]: - hass.data[DOMAIN][MYSENSORS_GATEWAYS] = {} - hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] = gateway + mysensors_data = hass.data.setdefault(DOMAIN, {}) + if MYSENSORS_GATEWAYS not in mysensors_data: + mysensors_data[MYSENSORS_GATEWAYS] = {} + mysensors_data[MYSENSORS_GATEWAYS][entry.entry_id] = gateway - # Connect notify discovery as that integration doesn't support entry forwarding. - - load_discovery_platform = partial( - async_load_platform, - hass, - Platform.NOTIFY, - DOMAIN, - hass_config=hass.data[DOMAIN][DATA_HASS_CONFIG], - ) - - on_unload( - hass, - entry.entry_id, - async_dispatcher_connect( - hass, - MYSENSORS_DISCOVERY.format(entry.entry_id, Platform.NOTIFY), - load_discovery_platform, - ), - ) - - await hass.config_entries.async_forward_entry_setups( - entry, PLATFORMS_WITH_ENTRY_SUPPORT - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await finish_setup(hass, entry, gateway) return True @@ -95,9 +60,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] - unload_ok = await hass.config_entries.async_unload_platforms( - entry, PLATFORMS_WITH_ENTRY_SUPPORT - ) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if not unload_ok: return False @@ -136,12 +99,12 @@ def setup_mysensors_platform( hass: HomeAssistant, domain: Platform, # hass platform name discovery_info: DiscoveryInfo, - device_class: type[MySensorsDevice] | dict[SensorType, type[MySensorsDevice]], + device_class: type[MySensorsEntity] | Mapping[SensorType, type[MySensorsEntity]], device_args: ( None | tuple ) = None, # extra arguments that will be given to the entity constructor async_add_entities: Callable | None = None, -) -> list[MySensorsDevice] | None: +) -> list[MySensorsEntity] | None: """Set up a MySensors platform. Sets up a bunch of instances of a single platform that is supported by this @@ -155,10 +118,10 @@ def setup_mysensors_platform( """ if device_args is None: device_args = () - new_devices: list[MySensorsDevice] = [] + new_devices: list[MySensorsEntity] = [] new_dev_ids: list[DevId] = discovery_info[ATTR_DEVICES] for dev_id in new_dev_ids: - devices: dict[DevId, MySensorsDevice] = get_mysensors_devices(hass, domain) + devices: dict[DevId, MySensorsEntity] = get_mysensors_devices(hass, domain) if dev_id in devices: _LOGGER.debug( "Skipping setup of %s for platform %s as it already exists", diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index 5368f65b83..7f9326091f 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -40,7 +40,6 @@ class DiscoveryInfo(TypedDict): """Represent the discovery info type for mysensors platforms.""" devices: list[DevId] - name: str # CONF_NAME is used in the notify base integration. gateway_id: GatewayId @@ -92,8 +91,6 @@ LIGHT_TYPES: dict[SensorType, set[ValueType]] = { "S_RGBW_LIGHT": {"V_RGBW"}, } -NOTIFY_TYPES: dict[SensorType, set[ValueType]] = {"S_INFO": {"V_TEXT"}} - REMOTE_TYPES: dict[SensorType, set[ValueType]] = {"S_IR": {"V_IR_SEND"}} SENSOR_TYPES: dict[SensorType, set[ValueType]] = { @@ -135,7 +132,6 @@ SWITCH_TYPES: dict[SensorType, set[ValueType]] = { "S_SOUND": {"V_ARMED"}, "S_VIBRATION": {"V_ARMED"}, "S_MOISTURE": {"V_ARMED"}, - "S_IR": {"V_IR_SEND"}, "S_LOCK": {"V_LOCK_STATUS"}, "S_WATER_QUALITY": {"V_STATUS"}, } @@ -148,7 +144,6 @@ PLATFORM_TYPES: dict[Platform, dict[SensorType, set[ValueType]]] = { Platform.COVER: COVER_TYPES, Platform.DEVICE_TRACKER: DEVICE_TRACKER_TYPES, Platform.LIGHT: LIGHT_TYPES, - Platform.NOTIFY: NOTIFY_TYPES, Platform.REMOTE: REMOTE_TYPES, Platform.SENSOR: SENSOR_TYPES, Platform.SWITCH: SWITCH_TYPES, @@ -167,6 +162,4 @@ for platform, platform_types in PLATFORM_TYPES.items(): for s_type_name in platform_types: TYPE_TO_PLATFORMS[s_type_name].append(platform) -PLATFORMS_WITH_ENTRY_SUPPORT = set(PLATFORM_TYPES.keys()) - { - Platform.NOTIFY, -} +PLATFORMS = tuple(PLATFORM_TYPES) diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index de4cbff9b9..d7405dba18 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -202,11 +202,11 @@ class MySensorsDevice(ABC): def get_mysensors_devices( hass: HomeAssistant, domain: Platform -) -> dict[DevId, MySensorsDevice]: +) -> dict[DevId, MySensorsEntity]: """Return MySensors devices for a hass platform name.""" if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data[DOMAIN]: hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} - devices: dict[DevId, MySensorsDevice] = hass.data[DOMAIN][ + devices: dict[DevId, MySensorsEntity] = hass.data[DOMAIN][ MYSENSORS_PLATFORM_DEVICES.format(domain) ] return devices diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index e83002ed87..213e268696 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -19,7 +19,7 @@ from homeassistant.util.color import rgb_hex_to_rgb_list from .. import mysensors from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType -from .device import MySensorsDevice +from .device import MySensorsEntity from .helpers import on_unload @@ -29,7 +29,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" - device_class_map: dict[SensorType, type[MySensorsDevice]] = { + device_class_map: dict[SensorType, type[MySensorsEntity]] = { "S_DIMMER": MySensorsLightDimmer, "S_RGB_LIGHT": MySensorsLightRGB, "S_RGBW_LIGHT": MySensorsLightRGBW, @@ -172,7 +172,7 @@ class MySensorsLightRGB(MySensorsLight): new_rgb: tuple[int, int, int] | None = kwargs.get(ATTR_RGB_COLOR) if new_rgb is None: return - hex_color = "%02x%02x%02x" % new_rgb + hex_color = "{:02x}{:02x}{:02x}".format(*new_rgb) self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) @@ -219,7 +219,7 @@ class MySensorsLightRGBW(MySensorsLightRGB): new_rgbw: tuple[int, int, int, int] | None = kwargs.get(ATTR_RGBW_COLOR) if new_rgbw is None: return - hex_color = "%02x%02x%02x%02x" % new_rgbw + hex_color = "{:02x}{:02x}{:02x}{:02x}".format(*new_rgbw) self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) diff --git a/homeassistant/components/mysensors/notify.py b/homeassistant/components/mysensors/notify.py deleted file mode 100644 index 97d4175a6f..0000000000 --- a/homeassistant/components/mysensors/notify.py +++ /dev/null @@ -1,100 +0,0 @@ -"""MySensors notification service.""" -from __future__ import annotations - -from typing import Any, cast - -from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify - -from .. import mysensors -from .const import DOMAIN, DevId, DiscoveryInfo - - -async def async_get_service( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> BaseNotificationService | None: - """Get the MySensors notification service.""" - if not discovery_info: - return None - - new_devices = mysensors.setup_mysensors_platform( - hass, - Platform.NOTIFY, - cast(DiscoveryInfo, discovery_info), - MySensorsNotificationDevice, - ) - if not new_devices: - return None - return MySensorsNotificationService(hass) - - -class MySensorsNotificationDevice(mysensors.device.MySensorsDevice): - """Represent a MySensors Notification device.""" - - @callback - def _async_update_callback(self) -> None: - """Update the device.""" - self._async_update() - - def send_msg(self, msg: str) -> None: - """Send a message.""" - for sub_msg in [msg[i : i + 25] for i in range(0, len(msg), 25)]: - # Max mysensors payload is 25 bytes. - self.gateway.set_child_value( - self.node_id, self.child_id, self.value_type, sub_msg - ) - - def __repr__(self) -> str: - """Return the representation.""" - return f"" - - -class MySensorsNotificationService(BaseNotificationService): - """Implement a MySensors notification service.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the service.""" - self.devices: dict[ - DevId, MySensorsNotificationDevice - ] = mysensors.get_mysensors_devices( - hass, Platform.NOTIFY - ) # type: ignore[assignment] - self.hass = hass - - async def async_send_message(self, message: str = "", **kwargs: Any) -> None: - """Send a message to a user.""" - target_devices = kwargs.get(ATTR_TARGET) - devices = [ - device - for device in self.devices.values() - if target_devices is None or device.name in target_devices - ] - - placeholders = { - "alternate_service": "text.set_value", - "deprecated_service": f"notify.{self._service_name}", - "alternate_target": str( - [f"text.{slugify(device.name)}" for device in devices] - ), - } - - async_create_issue( - self.hass, - DOMAIN, - "deprecated_notify_service", - breaks_in_ha_version="2023.4.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_service", - translation_placeholders=placeholders, - ) - - for device in devices: - device.send_msg(message) diff --git a/homeassistant/components/mysensors/services.yaml b/homeassistant/components/mysensors/services.yaml deleted file mode 100644 index 7293a676a7..0000000000 --- a/homeassistant/components/mysensors/services.yaml +++ /dev/null @@ -1,18 +0,0 @@ -send_ir_code: - name: Send IR code - description: Set an IR code as a state attribute for a MySensors IR device switch and turn the switch on. - fields: - entity_id: - name: Entity - description: Name of entity that should have the IR code set and be turned on. Platform dependent. - selector: - entity: - integration: mysensors - domain: switch - V_IR_SEND: - name: IR send - description: IR code to send. - required: true - example: "0xC284" - selector: - text: diff --git a/homeassistant/components/mysensors/strings.json b/homeassistant/components/mysensors/strings.json index c192db7549..dc5dc76c7a 100644 --- a/homeassistant/components/mysensors/strings.json +++ b/homeassistant/components/mysensors/strings.json @@ -83,29 +83,5 @@ "port_out_of_range": "Port number must be at least 1 and at most 65535", "unknown": "[%key:common::config_flow::error::unknown%]" } - }, - "issues": { - "deprecated_entity": { - "title": "The {deprecated_entity} entity will be removed", - "fix_flow": { - "step": { - "confirm": { - "title": "The {deprecated_entity} entity will be removed", - "description": "Update any automations or scripts that use this entity in service calls using the `{deprecated_service}` service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`." - } - } - } - }, - "deprecated_service": { - "title": "The {deprecated_service} service will be removed", - "fix_flow": { - "step": { - "confirm": { - "title": "The {deprecated_service} service will be removed", - "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`." - } - } - } - } } } diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index e5b0968785..6067a98af0 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -3,34 +3,18 @@ from __future__ import annotations from typing import Any -import voluptuous as vol - from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .. import mysensors -from .const import ( - DOMAIN as MYSENSORS_DOMAIN, - MYSENSORS_DISCOVERY, - SERVICE_SEND_IR_CODE, - DiscoveryInfo, - SensorType, -) -from .device import MySensorsDevice +from . import setup_mysensors_platform +from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType +from .device import MySensorsEntity from .helpers import on_unload -ATTR_IR_CODE = "V_IR_SEND" - -SEND_IR_CODE_SERVICE_SCHEMA = vol.Schema( - {vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_IR_CODE): cv.string} -) - async def async_setup_entry( hass: HomeAssistant, @@ -38,13 +22,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" - device_class_map: dict[SensorType, type[MySensorsDevice]] = { + device_class_map: dict[SensorType, type[MySensorsSwitch]] = { "S_DOOR": MySensorsSwitch, "S_MOTION": MySensorsSwitch, "S_SMOKE": MySensorsSwitch, "S_LIGHT": MySensorsSwitch, "S_LOCK": MySensorsSwitch, - "S_IR": MySensorsIRSwitch, "S_BINARY": MySensorsSwitch, "S_SPRINKLER": MySensorsSwitch, "S_WATER_LEAK": MySensorsSwitch, @@ -56,7 +39,7 @@ async def async_setup_entry( async def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors switch.""" - mysensors.setup_mysensors_platform( + setup_mysensors_platform( hass, Platform.SWITCH, discovery_info, @@ -64,37 +47,6 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - async def async_send_ir_code_service(service: ServiceCall) -> None: - """Set IR code as device state attribute.""" - entity_ids = service.data.get(ATTR_ENTITY_ID) - ir_code = service.data.get(ATTR_IR_CODE) - devices = mysensors.get_mysensors_devices(hass, Platform.SWITCH) - - if entity_ids: - _devices = [ - device - for device in devices.values() - if isinstance(device, MySensorsIRSwitch) - and device.entity_id in entity_ids - ] - else: - _devices = [ - device - for device in devices.values() - if isinstance(device, MySensorsIRSwitch) - ] - - kwargs = {ATTR_IR_CODE: ir_code} - for device in _devices: - await device.async_turn_on(**kwargs) - - hass.services.async_register( - MYSENSORS_DOMAIN, - SERVICE_SEND_IR_CODE, - async_send_ir_code_service, - schema=SEND_IR_CODE_SERVICE_SCHEMA, - ) - on_unload( hass, config_entry.entry_id, @@ -106,7 +58,7 @@ async def async_setup_entry( ) -class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity): +class MySensorsSwitch(MySensorsEntity, SwitchEntity): """Representation of the value of a MySensors Switch child node.""" @property @@ -133,97 +85,3 @@ class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity): # Optimistically assume that switch has changed state self._values[self.value_type] = STATE_OFF self.async_write_ha_state() - - -class MySensorsIRSwitch(MySensorsSwitch): - """IR switch child class to MySensorsSwitch.""" - - def __init__(self, *args: Any) -> None: - """Set up instance attributes.""" - super().__init__(*args) - self._ir_code: str | None = None - - @property - def is_on(self) -> bool: - """Return True if switch is on.""" - set_req = self.gateway.const.SetReq - return self._values.get(set_req.V_LIGHT) == STATE_ON - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the IR switch on.""" - set_req = self.gateway.const.SetReq - placeholders = { - "deprecated_entity": self.entity_id, - "alternate_target": f"remote.{split_entity_id(self.entity_id)[1]}", - } - - if ATTR_IR_CODE in kwargs: - self._ir_code = kwargs[ATTR_IR_CODE] - placeholders[ - "deprecated_service" - ] = f"{MYSENSORS_DOMAIN}.{SERVICE_SEND_IR_CODE}" - placeholders["alternate_service"] = "remote.send_command" - else: - placeholders["deprecated_service"] = "switch.turn_on" - placeholders["alternate_service"] = "remote.turn_on" - - async_create_issue( - self.hass, - MYSENSORS_DOMAIN, - ( - "deprecated_ir_switch_entity_" - f"{self.entity_id}_{placeholders['deprecated_service']}" - ), - breaks_in_ha_version="2023.4.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity", - translation_placeholders=placeholders, - ) - self.gateway.set_child_value( - self.node_id, self.child_id, self.value_type, self._ir_code - ) - self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_LIGHT, 1, ack=1 - ) - if self.assumed_state: - # Optimistically assume that switch has changed state - self._values[self.value_type] = self._ir_code - self._values[set_req.V_LIGHT] = STATE_ON - self.async_write_ha_state() - # Turn off switch after switch was turned on - await self.async_turn_off() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the IR switch off.""" - async_create_issue( - self.hass, - MYSENSORS_DOMAIN, - f"deprecated_ir_switch_entity_{self.entity_id}_switch.turn_off", - breaks_in_ha_version="2023.4.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity", - translation_placeholders={ - "deprecated_entity": self.entity_id, - "deprecated_service": "switch.turn_off", - "alternate_service": "remote.turn_off", - "alternate_target": f"remote.{split_entity_id(self.entity_id)[1]}", - }, - ) - set_req = self.gateway.const.SetReq - self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_LIGHT, 0, ack=1 - ) - if self.assumed_state: - # Optimistically assume that switch has changed state - self._values[set_req.V_LIGHT] = STATE_OFF - self.async_write_ha_state() - - @callback - def _async_update(self) -> None: - """Update the controller with the latest value from a sensor.""" - super()._async_update() - self._ir_code = self._values.get(self.value_type) diff --git a/homeassistant/components/nam/button.py b/homeassistant/components/nam/button.py index 4c8a310ad1..a552159620 100644 --- a/homeassistant/components/nam/button.py +++ b/homeassistant/components/nam/button.py @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) RESTART_BUTTON: ButtonEntityDescription = ButtonEntityDescription( key="restart", - name="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 b78acbf324..3f9821a1e3 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -89,7 +89,7 @@ class NAMSensorEntityDescription(SensorEntityDescription, NAMSensorRequiredKeysM SENSORS: tuple[NAMSensorEntityDescription, ...] = ( NAMSensorEntityDescription( key=ATTR_BME280_HUMIDITY, - name="BME280 humidity", + translation_key="bme280_humidity", suggested_display_precision=1, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, @@ -98,7 +98,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_BME280_PRESSURE, - name="BME280 pressure", + translation_key="bme280_pressure", suggested_display_precision=0, native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, @@ -107,7 +107,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_BME280_TEMPERATURE, - name="BME280 temperature", + translation_key="bme280_temperature", suggested_display_precision=1, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -116,7 +116,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_BMP180_PRESSURE, - name="BMP180 pressure", + translation_key="bmp180_pressure", suggested_display_precision=0, native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, @@ -125,7 +125,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_BMP180_TEMPERATURE, - name="BMP180 temperature", + translation_key="bmp180_temperature", suggested_display_precision=1, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -134,7 +134,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_BMP280_PRESSURE, - name="BMP280 pressure", + translation_key="bmp280_pressure", suggested_display_precision=0, native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, @@ -143,7 +143,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_BMP280_TEMPERATURE, - name="BMP280 temperature", + translation_key="bmp280_temperature", suggested_display_precision=1, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -152,7 +152,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_HECA_HUMIDITY, - name="HECA humidity", + translation_key="heca_humidity", suggested_display_precision=1, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, @@ -161,7 +161,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_HECA_TEMPERATURE, - name="HECA temperature", + translation_key="heca_temperature", suggested_display_precision=1, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -170,7 +170,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_MHZ14A_CARBON_DIOXIDE, - name="MH-Z14A carbon dioxide", + translation_key="mhz14a_carbon_dioxide", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, @@ -179,22 +179,21 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_PMSX003_CAQI, - name="PMSx003 CAQI", + translation_key="pmsx003_caqi", icon="mdi:air-filter", value=lambda sensors: sensors.pms_caqi, ), NAMSensorEntityDescription( key=ATTR_PMSX003_CAQI_LEVEL, - name="PMSx003 CAQI level", + translation_key="pmsx003_caqi_level", icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_low", "low", "medium", "high", "very_high"], - translation_key="caqi_level", value=lambda sensors: sensors.pms_caqi_level, ), NAMSensorEntityDescription( key=ATTR_PMSX003_P0, - name="PMSx003 particulate matter 1.0", + translation_key="pmsx003_pm1", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM1, @@ -203,7 +202,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_PMSX003_P1, - name="PMSx003 particulate matter 10", + translation_key="pmsx003_pm10", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, @@ -212,7 +211,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_PMSX003_P2, - name="PMSx003 particulate matter 2.5", + translation_key="pmsx003_pm25", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, @@ -221,22 +220,21 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SDS011_CAQI, - name="SDS011 CAQI", + translation_key="sds011_caqi", icon="mdi:air-filter", value=lambda sensors: sensors.sds011_caqi, ), NAMSensorEntityDescription( key=ATTR_SDS011_CAQI_LEVEL, - name="SDS011 CAQI level", + translation_key="sds011_caqi_level", icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_low", "low", "medium", "high", "very_high"], - translation_key="caqi_level", value=lambda sensors: sensors.sds011_caqi_level, ), NAMSensorEntityDescription( key=ATTR_SDS011_P1, - name="SDS011 particulate matter 10", + translation_key="sds011_pm10", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, @@ -245,7 +243,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SDS011_P2, - name="SDS011 particulate matter 2.5", + translation_key="sds011_pm25", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, @@ -254,7 +252,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SHT3X_HUMIDITY, - name="SHT3X humidity", + translation_key="sht3x_humidity", suggested_display_precision=1, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, @@ -263,7 +261,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SHT3X_TEMPERATURE, - name="SHT3X temperature", + translation_key="sht3x_temperature", suggested_display_precision=1, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -272,22 +270,21 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SPS30_CAQI, - name="SPS30 CAQI", + translation_key="sps30_caqi", icon="mdi:air-filter", value=lambda sensors: sensors.sps30_caqi, ), NAMSensorEntityDescription( key=ATTR_SPS30_CAQI_LEVEL, - name="SPS30 CAQI level", + translation_key="sps30_caqi_level", icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_low", "low", "medium", "high", "very_high"], - translation_key="caqi_level", value=lambda sensors: sensors.sps30_caqi_level, ), NAMSensorEntityDescription( key=ATTR_SPS30_P0, - name="SPS30 particulate matter 1.0", + translation_key="sps30_pm1", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM1, @@ -296,7 +293,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SPS30_P1, - name="SPS30 particulate matter 10", + translation_key="sps30_pm10", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, @@ -305,7 +302,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SPS30_P2, - name="SPS30 particulate matter 2.5", + translation_key="sps30_pm25", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, @@ -314,7 +311,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SPS30_P4, - name="SPS30 particulate matter 4.0", + translation_key="sps30_pm4", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, icon="mdi:molecule", @@ -323,7 +320,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_DHT22_HUMIDITY, - name="DHT22 humidity", + translation_key="dht22_humidity", suggested_display_precision=1, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, @@ -332,7 +329,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_DHT22_TEMPERATURE, - name="DHT22 temperature", + translation_key="dht22_temperature", suggested_display_precision=1, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -341,7 +338,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SIGNAL_STRENGTH, - name="Signal strength", + translation_key="signal_strength", suggested_display_precision=0, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -352,7 +349,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_UPTIME, - name="Uptime", + translation_key="last_restart", device_class=SensorDeviceClass.TIMESTAMP, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index 17983505e9..e60855b882 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -39,8 +39,47 @@ } }, "entity": { + "button": { + "restart": { + "name": "[%key:component::button::entity_component::restart::name%]" + } + }, "sensor": { - "caqi_level": { + "bme280_humidity": { + "name": "BME280 humidity" + }, + "bme280_pressure": { + "name": "BME280 pressure" + }, + "bme280_temperature": { + "name": "BME280 temperature" + }, + "bmp180_pressure": { + "name": "BMP180 pressure" + }, + "bmp180_temperature": { + "name": "BMP180 temperature" + }, + "bmp280_pressure": { + "name": "BMP280 pressure" + }, + "bmp280_temperature": { + "name": "BMP280 temperature" + }, + "heca_humidity": { + "name": "HECA humidity" + }, + "heca_temperature": { + "name": "HECA temperature" + }, + "mhz14a_carbon_dioxide": { + "name": "MH-Z14A carbon dioxide" + }, + "pmsx003_caqi": { + "name": "PMSx003 common air quality index" + }, + "pmsx003_caqi_level": { + "name": "PMSx003 common air quality index level", "state": { "very_low": "Very low", "low": "Low", @@ -48,6 +87,77 @@ "high": "High", "very_high": "Very high" } + }, + "pmsx003_pm1": { + "name": "PMSx003 PM1" + }, + "pmsx003_pm10": { + "name": "PMSx003 PM10" + }, + "pmsx003_pm25": { + "name": "PMSx003 PM2.5" + }, + "sds011_caqi": { + "name": "SDS011 common air quality index" + }, + "sds011_caqi_level": { + "name": "SDS011 common air quality index level", + "state": { + "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", + "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", + "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", + "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", + "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + } + }, + "sds011_pm10": { + "name": "SDS011 PM10" + }, + "sds011_pm25": { + "name": "SDS011 PM2.5" + }, + "sht3x_humidity": { + "name": "SHT3X humidity" + }, + "sht3x_temperature": { + "name": "SHT3X temperature" + }, + "sps30_caqi": { + "name": "SPS30 common air quality index" + }, + "sps30_caqi_level": { + "name": "SPS30 common air quality index level", + "state": { + "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", + "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", + "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", + "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", + "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + } + }, + "sps30_pm1": { + "name": "SPS30 PM1" + }, + "sps30_pm10": { + "name": "SPS30 PM10" + }, + "sps30_pm25": { + "name": "SPS30 PM2.5" + }, + "sps30_pm4": { + "name": "SPS30 PM4" + }, + "dht22_humidity": { + "name": "DHT22 humidity" + }, + "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/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index 7f4fbdfae7..f0c782bc1b 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -26,7 +26,6 @@ CONF_TO = "to" CONF_VIA = "via" CONF_TIME = "time" -ICON = "mdi:train" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) @@ -104,6 +103,7 @@ class NSDepartureSensor(SensorEntity): """Implementation of a NS Departure Sensor.""" _attr_attribution = "Data provided by NS" + _attr_icon = "mdi:train" def __init__(self, nsapi, name, departure, heading, via, time): """Initialize the sensor.""" @@ -121,11 +121,6 @@ class NSDepartureSensor(SensorEntity): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def native_value(self): """Return the next departure time.""" diff --git a/homeassistant/components/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py index 187ac0ee8c..8eb607b205 100644 --- a/homeassistant/components/nest/sensor_sdm.py +++ b/homeassistant/components/nest/sensor_sdm.py @@ -79,7 +79,7 @@ class TemperatureSensor(SensorBase): _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - _attr_name = "Temperature" + _attr_translation_key = "temperature" @property def native_value(self) -> float: @@ -96,7 +96,7 @@ class HumiditySensor(SensorBase): _attr_device_class = SensorDeviceClass.HUMIDITY _attr_native_unit_of_measurement = PERCENTAGE - _attr_name = "Humidity" + _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 bf68d1988d..4176ad1e22 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -61,7 +61,7 @@ }, "error": { "timeout": "Timeout validating code", - "invalid_pin": "Invalid [%key:common::config_flow::data::pin%]", + "invalid_pin": "Invalid PIN", "unknown": "[%key:common::config_flow::error::unknown%]", "internal_error": "Internal error validating code", "bad_project_id": "Please enter a valid Cloud Project ID (check Cloud Console)", @@ -98,5 +98,15 @@ "title": "Nest Authentication Credentials must be updated", "description": "To improve security and reduce phishing risk Google has deprecated the authentication method used by Home Assistant.\n\n**This requires action by you to resolve** ([more info]({more_info_url}))\n\n1. Visit the integrations page\n1. Click Reconfigure on the Nest integration.\n1. Home Assistant will walk you through the steps to upgrade to Web Authentication.\n\nSee the Nest [integration instructions]({documentation_url}) for troubleshooting information." } + }, + "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/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 80396e8048..c80bd351cc 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -134,8 +134,10 @@ class NetatmoDataHandler: async def async_setup(self) -> None: """Set up the Netatmo data handler.""" - async_track_time_interval( - self.hass, self.async_update, timedelta(seconds=SCAN_INTERVAL) + self.config_entry.async_on_unload( + async_track_time_interval( + self.hass, self.async_update, timedelta(seconds=SCAN_INTERVAL) + ) ) self.config_entry.async_on_unload( diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index f58daadcf7..a500689a93 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -25,10 +25,10 @@ "public_weather": { "data": { "area_name": "Name of the area", - "lat_ne": "[%key:common::config_flow::data::latitude%] North-East corner", - "lon_ne": "[%key:common::config_flow::data::longitude%] North-East corner", - "lat_sw": "[%key:common::config_flow::data::latitude%] South-West corner", - "lon_sw": "[%key:common::config_flow::data::longitude%] South-West corner", + "lat_ne": "North-East corner latitude", + "lon_ne": "North-East corner longitude", + "lat_sw": "South-West corner latitude", + "lon_sw": "South-West corner longitude", "mode": "Calculation", "show_on_map": "Show on map" }, diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index d58c4878f6..7941d1fe0a 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -4,8 +4,8 @@ "user": { "description": "Default host: {host}\nDefault username: {username}", "data": { - "host": "[%key:common::config_flow::data::host%] (Optional)", - "username": "[%key:common::config_flow::data::username%] (Optional)", + "host": "Host (Optional)", + "username": "Username (Optional)", "password": "[%key:common::config_flow::data::password%]" } } diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index fd2c399fb3..0ab3dd07ed 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -221,7 +221,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: netgear_lte_config = config[DOMAIN] # Set up each modem - tasks = [_setup_lte(hass, lte_conf) for lte_conf in netgear_lte_config] + tasks = [ + hass.async_create_task(_setup_lte(hass, lte_conf)) + for lte_conf in netgear_lte_config + ] await asyncio.wait(tasks) # Load platforms for each modem diff --git a/homeassistant/components/netgear_lte/manifest.json b/homeassistant/components/netgear_lte/manifest.json index ae580dbb99..427aa9633c 100644 --- a/homeassistant/components/netgear_lte/manifest.json +++ b/homeassistant/components/netgear_lte/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/netgear_lte", "iot_class": "local_polling", "loggers": ["eternalegypt"], - "requirements": ["eternalegypt==0.0.12"] + "requirements": ["eternalegypt==0.0.15"] } diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py index 52f6d1d722..a9023ffca2 100644 --- a/homeassistant/components/neurio_energy/sensor.py +++ b/homeassistant/components/neurio_energy/sensor.py @@ -33,7 +33,6 @@ DAILY_NAME = "Daily Energy Usage" ACTIVE_TYPE = "active" DAILY_TYPE = "daily" -ICON = "mdi:flash" MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=150) MIN_TIME_BETWEEN_ACTIVE_UPDATES = timedelta(seconds=10) @@ -140,6 +139,8 @@ class NeurioData: class NeurioEnergy(SensorEntity): """Implementation of a Neurio energy sensor.""" + _attr_icon = "mdi:flash" + def __init__(self, data, name, sensor_type, update_call): """Initialize the sensor.""" self._name = name @@ -172,11 +173,6 @@ class NeurioEnergy(SensorEntity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data, update state.""" self.update_sensor() diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index b4080dd2a1..65829f713e 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -1,100 +1,116 @@ """The Nextcloud integration.""" import logging -from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError +from nextcloudmonitor import ( + NextcloudMonitor, + NextcloudMonitorAuthorizationError, + NextcloudMonitorConnectionError, + NextcloudMonitorRequestError, +) import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_URL, CONF_USERNAME, + CONF_VERIFY_SSL, Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.event import track_time_interval +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 .coordinator import NextcloudDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) - PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR) # Validate user configuration CONFIG_SCHEMA = vol.Schema( - { - 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, - } - ) - }, + 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, ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Nextcloud integration.""" - # Fetch Nextcloud Monitor api data - conf = config[DOMAIN] + 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", + ) - try: - ncm = NextcloudMonitor(conf[CONF_URL], conf[CONF_USERNAME], conf[CONF_PASSWORD]) - except NextcloudMonitorError: - _LOGGER.error("Nextcloud setup failed - Check configuration") - return False - - hass.data[DOMAIN] = get_data_points(ncm.data) - hass.data[DOMAIN]["instance"] = conf[CONF_URL] - - def nextcloud_update(event_time): - """Update data from nextcloud api.""" - try: - ncm.update() - except NextcloudMonitorError: - _LOGGER.error("Nextcloud update failed") - return False - - hass.data[DOMAIN] = get_data_points(ncm.data) - hass.data[DOMAIN]["instance"] = conf[CONF_URL] - - # Update sensors on time interval - track_time_interval(hass, nextcloud_update, conf[CONF_SCAN_INTERVAL]) - - for platform in PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, config) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) return True -# Use recursion to create list of sensors & values based on nextcloud api data -def get_data_points(api_data, key_path="", leaf=False): - """Use Recursion to discover data-points and values. +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Nextcloud integration.""" - Get dictionary of data-points by recursing through dict returned by api until - the dictionary value does not contain another dictionary and use the - resulting path of dictionary keys and resulting value as the name/value - for the data-point. + def _connect_nc(): + return NextcloudMonitor( + entry.data[CONF_URL], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_VERIFY_SSL], + ) - returns: dictionary of data-point/values - """ - result = {} - for key, value in api_data.items(): - if isinstance(value, dict): - if leaf: - key_path = f"{key}_" - if not leaf: - key_path += f"{key}_" - leaf = True - result.update(get_data_points(value, key_path, leaf)) - else: - result[f"{DOMAIN}_{key_path}{key}"] = value - leaf = False - return result + try: + ncm = await hass.async_add_executor_job(_connect_nc) + except NextcloudMonitorAuthorizationError as ex: + raise ConfigEntryAuthFailed from ex + except (NextcloudMonitorConnectionError, NextcloudMonitorRequestError) as ex: + raise ConfigEntryNotReady from ex + + coordinator = NextcloudDataUpdateCoordinator( + hass, + ncm, + entry, + ) + 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 Nextcloud integration.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return unload_ok diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index 6e0df919f9..3cf3cc3ae2 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -2,11 +2,12 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity +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 .const import DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator from .entity import NextcloudEntity BINARY_SENSORS = ( @@ -17,20 +18,18 @@ BINARY_SENSORS = ( ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the Nextcloud sensors.""" - if discovery_info is None: - return - binary_sensors = [] - for name in hass.data[DOMAIN]: - if name in BINARY_SENSORS: - binary_sensors.append(NextcloudBinarySensor(name)) - add_entities(binary_sensors, True) + """Set up the Nextcloud binary sensors.""" + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + NextcloudBinarySensor(coordinator, name, entry) + for name in coordinator.data + if name in BINARY_SENSORS + ] + ) class NextcloudBinarySensor(NextcloudEntity, BinarySensorEntity): @@ -39,4 +38,4 @@ class NextcloudBinarySensor(NextcloudEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self._state == "yes" + return self.coordinator.data.get(self.item) == "yes" diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py new file mode 100644 index 0000000000..c5019603c0 --- /dev/null +++ b/homeassistant/components/nextcloud/config_flow.py @@ -0,0 +1,139 @@ +"""Config flow to configure the Nextcloud integration.""" +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 + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_VERIFY_SSL, DOMAIN + +DATA_SCHEMA_USER = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + } +) +DATA_SCHEMA_REAUTH = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + +_LOGGER = logging.getLogger(__name__) + + +class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a Nextcloud config flow.""" + + VERSION = 1 + + _entry: ConfigEntry | None = None + + def _try_connect_nc(self, user_input: dict) -> NextcloudMonitor: + """Try to connect to nextcloud server.""" + return NextcloudMonitor( + user_input[CONF_URL], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + 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: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + 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 NextcloudMonitorAuthorizationError: + errors["base"] = "invalid_auth" + except (NextcloudMonitorConnectionError, NextcloudMonitorRequestError): + errors["base"] = "connection_error" + else: + return self.async_create_entry( + title=user_input[CONF_URL], + data=user_input, + ) + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA_USER, user_input) + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle flow upon an API authentication error.""" + self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle reauthorization flow.""" + errors = {} + assert self._entry is not None + + if user_input is not None: + try: + await self.hass.async_add_executor_job( + self._try_connect_nc, {**self._entry.data, **user_input} + ) + except NextcloudMonitorAuthorizationError: + errors["base"] = "invalid_auth" + except (NextcloudMonitorConnectionError, NextcloudMonitorRequestError): + errors["base"] = "connection_error" + else: + self.hass.config_entries.async_update_entry( + self._entry, + data={**self._entry.data, **user_input}, + ) + await self.hass.config_entries.async_reload(self._entry.entry_id) + return self.async_abort(reason="reauth_successful") + + data_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA_REAUTH, + {CONF_USERNAME: self._entry.data[CONF_USERNAME], **(user_input or {})}, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=data_schema, + description_placeholders={"url": self._entry.data[CONF_URL]}, + errors=errors, + ) diff --git a/homeassistant/components/nextcloud/const.py b/homeassistant/components/nextcloud/const.py index 223d21771b..248128dd53 100644 --- a/homeassistant/components/nextcloud/const.py +++ b/homeassistant/components/nextcloud/const.py @@ -3,3 +3,4 @@ from datetime import timedelta DOMAIN = "nextcloud" DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_VERIFY_SSL = True diff --git a/homeassistant/components/nextcloud/coordinator.py b/homeassistant/components/nextcloud/coordinator.py new file mode 100644 index 0000000000..73a07a77e2 --- /dev/null +++ b/homeassistant/components/nextcloud/coordinator.py @@ -0,0 +1,72 @@ +"""Data update coordinator for the Nextcloud integration.""" + +import logging +from typing import Any + +from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Nextcloud data update coordinator.""" + + def __init__( + self, hass: HomeAssistant, ncm: NextcloudMonitor, entry: ConfigEntry + ) -> None: + """Initialize the Nextcloud coordinator.""" + self.ncm = ncm + self.url = entry.data[CONF_URL] + + super().__init__( + hass, + _LOGGER, + name=self.url, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + + # Use recursion to create list of sensors & values based on nextcloud api data + def _get_data_points( + self, api_data: dict, key_path: str = "", leaf: bool = False + ) -> dict[str, Any]: + """Use Recursion to discover data-points and values. + + Get dictionary of data-points by recursing through dict returned by api until + the dictionary value does not contain another dictionary and use the + resulting path of dictionary keys and resulting value as the name/value + for the data-point. + + returns: dictionary of data-point/values + """ + result = {} + for key, value in api_data.items(): + if isinstance(value, dict): + if leaf: + key_path = f"{key}_" + if not leaf: + key_path += f"{key}_" + leaf = True + result.update(self._get_data_points(value, key_path, leaf)) + else: + result[f"{DOMAIN}_{key_path}{key}"] = value + leaf = False + return result + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch all Nextcloud data.""" + + def _update_data() -> None: + try: + self.ncm.update() + except NextcloudMonitorError as ex: + raise UpdateFailed from ex + + await self.hass.async_add_executor_job(_update_data) + return self._get_data_points(self.ncm.data) diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py index cb066e0fcf..ed5882cfe7 100644 --- a/homeassistant/components/nextcloud/entity.py +++ b/homeassistant/components/nextcloud/entity.py @@ -1,26 +1,31 @@ """Base entity for the Nextcloud integration.""" -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.typing import StateType + + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator -class NextcloudEntity(Entity): +class NextcloudEntity(CoordinatorEntity[NextcloudDataUpdateCoordinator]): """Base Nextcloud entity.""" + _attr_has_entity_name = True _attr_icon = "mdi:cloud" - def __init__(self, item: str) -> None: - """Initialize the Nextcloud entity.""" - self._attr_name = item + def __init__( + self, coordinator: NextcloudDataUpdateCoordinator, item: str, entry: ConfigEntry + ) -> None: + """Initialize the Nextcloud sensor.""" + super().__init__(coordinator) self.item = item - self._state: StateType = None - - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return f"{self.hass.data[DOMAIN]['instance']}#{self.item}" - - def update(self) -> None: - """Update the sensor.""" - self._state = self.hass.data[DOMAIN][self.item] + self._attr_name = item + self._attr_unique_id = f"{coordinator.url}#{item}" + self._attr_device_info = DeviceInfo( + name="Nextcloud", + identifiers={(DOMAIN, entry.entry_id)}, + sw_version=coordinator.data.get("nextcloud_system_version"), + configuration_url=coordinator.url, + ) diff --git a/homeassistant/components/nextcloud/manifest.json b/homeassistant/components/nextcloud/manifest.json index 366c6eeb56..fe4366c334 100644 --- a/homeassistant/components/nextcloud/manifest.json +++ b/homeassistant/components/nextcloud/manifest.json @@ -1,8 +1,9 @@ { "domain": "nextcloud", "name": "Nextcloud", - "codeowners": ["@meichthys"], + "codeowners": ["@mib1185"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nextcloud", "iot_class": "cloud_polling", - "requirements": ["nextcloudmonitor==1.1.0"] + "requirements": ["nextcloudmonitor==1.4.0"] } diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 91d4411b0c..a5df872e08 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -2,11 +2,13 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import StateType from .const import DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator from .entity import NextcloudEntity SENSORS = ( @@ -56,20 +58,18 @@ SENSORS = ( ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Nextcloud sensors.""" - if discovery_info is None: - return - sensors = [] - for name in hass.data[DOMAIN]: - if name in SENSORS: - sensors.append(NextcloudSensor(name)) - add_entities(sensors, True) + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + NextcloudSensor(coordinator, name, entry) + for name in coordinator.data + if name in SENSORS + ] + ) class NextcloudSensor(NextcloudEntity, SensorEntity): @@ -78,4 +78,4 @@ class NextcloudSensor(NextcloudEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state for this sensor.""" - return self._state + return self.coordinator.data.get(self.item) diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json new file mode 100644 index 0000000000..e068ae4041 --- /dev/null +++ b/homeassistant/components/nextcloud/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "flow_title": "Nextcloud", + "step": { + "user": { + "description": "Enter your Nextcloud information.", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + }, + "reauth_confirm": { + "description": "Update your login information for {url}.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "connection_error_during_import": "Connection error occured during yaml configuration import", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "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/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py index bf45b9a487..e2e37ccab2 100644 --- a/homeassistant/components/nextdns/binary_sensor.py +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -43,14 +43,14 @@ SENSORS = ( NextDnsBinarySensorEntityDescription[ConnectionStatus]( key="this_device_nextdns_connection_status", entity_category=EntityCategory.DIAGNOSTIC, - name="This device NextDNS connection status", + translation_key="device_connection_status", device_class=BinarySensorDeviceClass.CONNECTIVITY, state=lambda data, _: data.connected, ), NextDnsBinarySensorEntityDescription[ConnectionStatus]( key="this_device_profile_connection_status", entity_category=EntityCategory.DIAGNOSTIC, - name="This device profile connection status", + translation_key="device_profile_connection_status", device_class=BinarySensorDeviceClass.CONNECTIVITY, state=lambda data, profile_id: profile_id == data.profile_id, ), diff --git a/homeassistant/components/nextdns/button.py b/homeassistant/components/nextdns/button.py index e527e03ee8..2eafe2b477 100644 --- a/homeassistant/components/nextdns/button.py +++ b/homeassistant/components/nextdns/button.py @@ -15,7 +15,7 @@ PARALLEL_UPDATES = 1 CLEAR_LOGS_BUTTON = ButtonEntityDescription( key="clear_logs", - name="Clear logs", + translation_key="clear_logs", entity_category=EntityCategory.CONFIG, ) diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index 2a1240322b..2f13632dc4 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["nextdns"], "quality_scale": "platinum", - "requirements": ["nextdns==1.3.0"] + "requirements": ["nextdns==1.4.0"] } diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index 8b0578f386..ccbbb5e534 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -60,7 +60,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:dns", - name="DNS queries", + translation_key="all_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.all_queries, @@ -70,7 +70,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:dns", - name="DNS queries blocked", + translation_key="blocked_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.blocked_queries, @@ -80,7 +80,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:dns", - name="DNS queries relayed", + translation_key="relayed_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.relayed_queries, @@ -90,7 +90,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:dns", - name="DNS queries blocked ratio", + translation_key="blocked_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.blocked_queries_ratio, @@ -101,7 +101,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="DNS-over-HTTPS queries", + translation_key="doh_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.doh_queries, @@ -112,7 +112,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="DNS-over-HTTP/3 queries", + translation_key="doh3_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.doh3_queries, @@ -123,7 +123,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="DNS-over-TLS queries", + translation_key="dot_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.dot_queries, @@ -134,7 +134,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="DNS-over-QUIC queries", + translation_key="doq_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.doq_queries, @@ -145,7 +145,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="TCP queries", + translation_key="tcp_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.tcp_queries, @@ -156,7 +156,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="UDP queries", + translation_key="udp_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.udp_queries, @@ -167,7 +167,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, icon="mdi:dns", entity_category=EntityCategory.DIAGNOSTIC, - name="DNS-over-HTTPS queries ratio", + translation_key="doh_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.doh_queries_ratio, @@ -178,7 +178,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, icon="mdi:dns", entity_category=EntityCategory.DIAGNOSTIC, - name="DNS-over-HTTP/3 queries ratio", + translation_key="doh3_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.doh3_queries_ratio, @@ -189,7 +189,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="DNS-over-TLS queries ratio", + translation_key="dot_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.dot_queries_ratio, @@ -200,7 +200,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, icon="mdi:dns", entity_category=EntityCategory.DIAGNOSTIC, - name="DNS-over-QUIC queries ratio", + translation_key="doq_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.doq_queries_ratio, @@ -211,7 +211,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="TCP queries ratio", + translation_key="tcp_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.tcp_queries_ratio, @@ -222,7 +222,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:dns", - name="UDP queries ratio", + translation_key="udp_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.udp_queries_ratio, @@ -233,7 +233,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lock", - name="Encrypted queries", + translation_key="encrypted_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.encrypted_queries, @@ -244,7 +244,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lock-open", - name="Unencrypted queries", + translation_key="unencrypted_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.unencrypted_queries, @@ -255,7 +255,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lock", - name="Encrypted queries ratio", + translation_key="encrypted_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.encrypted_queries_ratio, @@ -266,7 +266,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:ip", - name="IPv4 queries", + translation_key="ipv4_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.ipv4_queries, @@ -277,7 +277,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:ip", - name="IPv6 queries", + translation_key="ipv6_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.ipv6_queries, @@ -288,7 +288,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:ip", - name="IPv6 queries ratio", + translation_key="ipv6_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.ipv6_queries_ratio, @@ -299,7 +299,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lock-check", - name="DNSSEC validated queries", + translation_key="validated_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.validated_queries, @@ -310,7 +310,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lock-alert", - name="DNSSEC not validated queries", + translation_key="not_validated_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.not_validated_queries, @@ -321,7 +321,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lock-check", - name="DNSSEC validated queries ratio", + translation_key="validated_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.validated_queries_ratio, diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 59319881c0..2f15c4cd8e 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -25,5 +25,318 @@ "info": { "can_reach_server": "Reach server" } + }, + "entity": { + "binary_sensor": { + "device_connection_status": { + "name": "Device connection status" + }, + "device_profile_connection_status": { + "name": "Device profile connection status" + } + }, + "button": { + "clear_logs": { + "name": "Clear logs" + } + }, + "sensor": { + "all_queries": { + "name": "DNS queries" + }, + "blocked_queries": { + "name": "DNS queries blocked" + }, + "blocked_queries_ratio": { + "name": "DNS queries blocked ratio" + }, + "doh3_queries": { + "name": "DNS-over-HTTP/3 queries" + }, + "doh3_queries_ratio": { + "name": "DNS-over-HTTP/3 queries ratio" + }, + "doh_queries": { + "name": "DNS-over-HTTPS queries" + }, + "doh_queries_ratio": { + "name": "DNS-over-HTTPS queries ratio" + }, + "doq_queries": { + "name": "DNS-over-QUIC queries" + }, + "doq_queries_ratio": { + "name": "DNS-over-QUIC queries ratio" + }, + "dot_queries": { + "name": "DNS-over-TLS queries" + }, + "dot_queries_ratio": { + "name": "DNS-over-TLS queries ratio" + }, + "encrypted_queries": { + "name": "Encrypted queries" + }, + "encrypted_queries_ratio": { + "name": "Encrypted queries ratio" + }, + "ipv4_queries": { + "name": "IPv4 queries" + }, + "ipv6_queries": { + "name": "IPv6 queries" + }, + "ipv6_queries_ratio": { + "name": "IPv6 queries ratio" + }, + "not_validated_queries": { + "name": "DNSSEC not validated queries" + }, + "relayed_queries": { + "name": "DNS queries relayed" + }, + "tcp_queries": { + "name": "TCP queries" + }, + "tcp_queries_ratio": { + "name": "TCP queries ratio" + }, + "udp_queries": { + "name": "UDP queries" + }, + "udp_queries_ratio": { + "name": "UDP queries ratio" + }, + "unencrypted_queries": { + "name": "Unencrypted queries" + }, + "validated_queries": { + "name": "DNSSEC validated queries" + }, + "validated_queries_ratio": { + "name": "DNSSEC validated queries ratio" + } + }, + "switch": { + "ai_threat_detection": { + "name": "AI-Driven threat detection" + }, + "allow_affiliate": { + "name": "Allow affiliate & tracking links" + }, + "anonymized_ecs": { + "name": "Anonymized EDNS client subnet" + }, + "block_9gag": { + "name": "Block 9GAG" + }, + "block_amazon": { + "name": "Block Amazon" + }, + "block_bereal": { + "name": "Block BeReal" + }, + "block_blizzard": { + "name": "Block Blizzard" + }, + "block_bypass_methods": { + "name": "Block bypass methods" + }, + "block_chatgpt": { + "name": "Block ChatGPT" + }, + "block_csam": { + "name": "Block child sexual abuse material" + }, + "block_dailymotion": { + "name": "Block Dailymotion" + }, + "block_dating": { + "name": "Block dating" + }, + "block_ddns": { + "name": "Block dynamic DNS hostnames" + }, + "block_discord": { + "name": "Block Discord" + }, + "block_disguised_trackers": { + "name": "Block disguised third-party trackers" + }, + "block_disneyplus": { + "name": "Block Disney Plus" + }, + "block_ebay": { + "name": "Block eBay" + }, + "block_facebook": { + "name": "Block Facebook" + }, + "block_fortnite": { + "name": "Block Fortnite" + }, + "block_gambling": { + "name": "Block gambling" + }, + "block_google_chat": { + "name": "Block Google Chat" + }, + "block_hbomax": { + "name": "Block HBO Max" + }, + "block_hulu": { + "name": "Block Hulu" + }, + "block_imgur": { + "name": "Block Imgur" + }, + "block_instagram": { + "name": "Block Instagram" + }, + "block_leagueoflegends": { + "name": "Block League of Legends" + }, + "block_mastodon": { + "name": "Block Mastodon" + }, + "block_messenger": { + "name": "Block Messenger" + }, + "block_minecraft": { + "name": "Block Minecraft" + }, + "block_netflix": { + "name": "Block Netflix" + }, + "block_nrd": { + "name": "Block newly registered domains" + }, + "block_online_gaming": { + "name": "Block online gaming" + }, + "block_page": { + "name": "Block page" + }, + "block_parked_domains": { + "name": "Block parked domains" + }, + "block_pinterest": { + "name": "Block Pinterest" + }, + "block_piracy": { + "name": "Block piracy" + }, + "block_playstation_network": { + "name": "Block PlayStation Network" + }, + "block_porn": { + "name": "Block porn" + }, + "block_primevideo": { + "name": "Block Prime Video" + }, + "block_reddit": { + "name": "Block Reddit" + }, + "block_roblox": { + "name": "Block Roblox" + }, + "block_signal": { + "name": "Block Signal" + }, + "block_skype": { + "name": "Block Skype" + }, + "block_snapchat": { + "name": "Block Snapchat" + }, + "block_social_networks": { + "name": "Block social networks" + }, + "block_spotify": { + "name": "Block Spotify" + }, + "block_steam": { + "name": "Block Steam" + }, + "block_telegram": { + "name": "Block Telegram" + }, + "block_tiktok": { + "name": "Block TikTok" + }, + "block_tinder": { + "name": "Block Tinder" + }, + "block_tumblr": { + "name": "Block Tumblr" + }, + "block_twitch": { + "name": "Block Twitch" + }, + "block_twitter": { + "name": "Block Twitter" + }, + "block_video_streaming": { + "name": "Block video streaming" + }, + "block_vimeo": { + "name": "Block Vimeo" + }, + "block_vk": { + "name": "Block VK" + }, + "block_whatsapp": { + "name": "Block WhatsApp" + }, + "block_xboxlive": { + "name": "Block Xbox Live" + }, + "block_youtube": { + "name": "Block YouTube" + }, + "block_zoom": { + "name": "Block Zoom" + }, + "cache_boost": { + "name": "Cache boost" + }, + "cname_flattening": { + "name": "CNAME flattening" + }, + "cryptojacking_protection": { + "name": "Cryptojacking protection" + }, + "dga_protection": { + "name": "Domain generation algorithms protection" + }, + "dns_rebinding_protection": { + "name": "DNS rebinding protection" + }, + "google_safe_browsing": { + "name": "Google safe browsing" + }, + "idn_homograph_attacks_protection": { + "name": "IDN homograph attacks protection" + }, + "logs": { + "name": "Logs" + }, + "safesearch": { + "name": "Force SafeSearch" + }, + "threat_intelligence_feeds": { + "name": "Threat intelligence feeds" + }, + "typosquatting_protection": { + "name": "Typosquatting protection" + }, + "web3": { + "name": "Web3" + }, + "youtube_restricted_mode": { + "name": "Force YouTube restricted mode" + } + } } } diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 703a30ad70..0a310bc29e 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -41,156 +41,156 @@ class NextDnsSwitchEntityDescription( SWITCHES = ( NextDnsSwitchEntityDescription[Settings]( key="block_page", - name="Block page", + translation_key="block_page", entity_category=EntityCategory.CONFIG, icon="mdi:web-cancel", state=lambda data: data.block_page, ), NextDnsSwitchEntityDescription[Settings]( key="cache_boost", - name="Cache boost", + translation_key="cache_boost", entity_category=EntityCategory.CONFIG, icon="mdi:memory", state=lambda data: data.cache_boost, ), NextDnsSwitchEntityDescription[Settings]( key="cname_flattening", - name="CNAME flattening", + translation_key="cname_flattening", entity_category=EntityCategory.CONFIG, icon="mdi:tournament", state=lambda data: data.cname_flattening, ), NextDnsSwitchEntityDescription[Settings]( key="anonymized_ecs", - name="Anonymized EDNS client subnet", + translation_key="anonymized_ecs", entity_category=EntityCategory.CONFIG, icon="mdi:incognito", state=lambda data: data.anonymized_ecs, ), NextDnsSwitchEntityDescription[Settings]( key="logs", - name="Logs", + translation_key="logs", entity_category=EntityCategory.CONFIG, icon="mdi:file-document-outline", state=lambda data: data.logs, ), NextDnsSwitchEntityDescription[Settings]( key="web3", - name="Web3", + translation_key="web3", entity_category=EntityCategory.CONFIG, icon="mdi:web", state=lambda data: data.web3, ), NextDnsSwitchEntityDescription[Settings]( key="allow_affiliate", - name="Allow affiliate & tracking links", + translation_key="allow_affiliate", entity_category=EntityCategory.CONFIG, state=lambda data: data.allow_affiliate, ), NextDnsSwitchEntityDescription[Settings]( key="block_disguised_trackers", - name="Block disguised third-party trackers", + translation_key="block_disguised_trackers", entity_category=EntityCategory.CONFIG, state=lambda data: data.block_disguised_trackers, ), NextDnsSwitchEntityDescription[Settings]( key="ai_threat_detection", - name="AI-Driven threat detection", + translation_key="ai_threat_detection", entity_category=EntityCategory.CONFIG, state=lambda data: data.ai_threat_detection, ), NextDnsSwitchEntityDescription[Settings]( key="block_csam", - name="Block child sexual abuse material", + translation_key="block_csam", entity_category=EntityCategory.CONFIG, state=lambda data: data.block_csam, ), NextDnsSwitchEntityDescription[Settings]( key="block_ddns", - name="Block dynamic DNS hostnames", + translation_key="block_ddns", entity_category=EntityCategory.CONFIG, state=lambda data: data.block_ddns, ), NextDnsSwitchEntityDescription[Settings]( key="block_nrd", - name="Block newly registered domains", + translation_key="block_nrd", entity_category=EntityCategory.CONFIG, state=lambda data: data.block_nrd, ), NextDnsSwitchEntityDescription[Settings]( key="block_parked_domains", - name="Block parked domains", + translation_key="block_parked_domains", entity_category=EntityCategory.CONFIG, state=lambda data: data.block_parked_domains, ), NextDnsSwitchEntityDescription[Settings]( key="cryptojacking_protection", - name="Cryptojacking protection", + translation_key="cryptojacking_protection", entity_category=EntityCategory.CONFIG, state=lambda data: data.cryptojacking_protection, ), NextDnsSwitchEntityDescription[Settings]( key="dga_protection", - name="Domain generation algorithms protection", + translation_key="dga_protection", entity_category=EntityCategory.CONFIG, state=lambda data: data.dga_protection, ), NextDnsSwitchEntityDescription[Settings]( key="dns_rebinding_protection", - name="DNS rebinding protection", + translation_key="dns_rebinding_protection", entity_category=EntityCategory.CONFIG, icon="mdi:dns", state=lambda data: data.dns_rebinding_protection, ), NextDnsSwitchEntityDescription[Settings]( key="google_safe_browsing", - name="Google safe browsing", + translation_key="google_safe_browsing", entity_category=EntityCategory.CONFIG, icon="mdi:google", state=lambda data: data.google_safe_browsing, ), NextDnsSwitchEntityDescription[Settings]( key="idn_homograph_attacks_protection", - name="IDN homograph attacks protection", + translation_key="idn_homograph_attacks_protection", entity_category=EntityCategory.CONFIG, state=lambda data: data.idn_homograph_attacks_protection, ), NextDnsSwitchEntityDescription[Settings]( key="threat_intelligence_feeds", - name="Threat intelligence feeds", + translation_key="threat_intelligence_feeds", entity_category=EntityCategory.CONFIG, state=lambda data: data.threat_intelligence_feeds, ), NextDnsSwitchEntityDescription[Settings]( key="typosquatting_protection", - name="Typosquatting protection", + translation_key="typosquatting_protection", entity_category=EntityCategory.CONFIG, icon="mdi:keyboard-outline", state=lambda data: data.typosquatting_protection, ), NextDnsSwitchEntityDescription[Settings]( key="block_bypass_methods", - name="Block bypass methods", + translation_key="block_bypass_methods", entity_category=EntityCategory.CONFIG, state=lambda data: data.block_bypass_methods, ), NextDnsSwitchEntityDescription[Settings]( key="safesearch", - name="Force SafeSearch", + translation_key="safesearch", entity_category=EntityCategory.CONFIG, icon="mdi:search-web", state=lambda data: data.safesearch, ), NextDnsSwitchEntityDescription[Settings]( key="youtube_restricted_mode", - name="Force YouTube restricted mode", + translation_key="youtube_restricted_mode", entity_category=EntityCategory.CONFIG, icon="mdi:youtube", state=lambda data: data.youtube_restricted_mode, ), NextDnsSwitchEntityDescription[Settings]( key="block_9gag", - name="Block 9GAG", + translation_key="block_9gag", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:file-gif-box", @@ -198,23 +198,39 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_amazon", - name="Block Amazon", + translation_key="block_amazon", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:cart-outline", state=lambda data: data.block_amazon, ), + NextDnsSwitchEntityDescription[Settings]( + key="block_bereal", + translation_key="block_bereal", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:alpha-b-box", + state=lambda data: data.block_bereal, + ), NextDnsSwitchEntityDescription[Settings]( key="block_blizzard", - name="Block Blizzard", + translation_key="block_blizzard", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:sword-cross", state=lambda data: data.block_blizzard, ), + NextDnsSwitchEntityDescription[Settings]( + key="block_chatgpt", + translation_key="block_chatgpt", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:chat-processing-outline", + state=lambda data: data.block_chatgpt, + ), NextDnsSwitchEntityDescription[Settings]( key="block_dailymotion", - name="Block Dailymotion", + translation_key="block_dailymotion", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:movie-search-outline", @@ -222,7 +238,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_discord", - name="Block Discord", + translation_key="block_discord", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:message-text", @@ -230,7 +246,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_disneyplus", - name="Block Disney Plus", + translation_key="block_disneyplus", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:movie-search-outline", @@ -238,7 +254,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_ebay", - name="Block eBay", + translation_key="block_ebay", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:basket-outline", @@ -246,7 +262,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_facebook", - name="Block Facebook", + translation_key="block_facebook", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:facebook", @@ -254,12 +270,28 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_fortnite", - name="Block Fortnite", + translation_key="block_fortnite", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:tank", state=lambda data: data.block_fortnite, ), + NextDnsSwitchEntityDescription[Settings]( + key="block_google_chat", + translation_key="block_google_chat", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:forum", + state=lambda data: data.block_google_chat, + ), + NextDnsSwitchEntityDescription[Settings]( + key="block_hbomax", + translation_key="block_hbomax", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:movie-search-outline", + state=lambda data: data.block_hbomax, + ), NextDnsSwitchEntityDescription[Settings]( key="block_hulu", name="Block Hulu", @@ -270,7 +302,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_imgur", - name="Block Imgur", + translation_key="block_imgur", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:camera-image", @@ -278,7 +310,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_instagram", - name="Block Instagram", + translation_key="block_instagram", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:instagram", @@ -286,15 +318,23 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_leagueoflegends", - name="Block League of Legends", + translation_key="block_leagueoflegends", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:sword", state=lambda data: data.block_leagueoflegends, ), + NextDnsSwitchEntityDescription[Settings]( + key="block_mastodon", + translation_key="block_mastodon", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:mastodon", + state=lambda data: data.block_mastodon, + ), NextDnsSwitchEntityDescription[Settings]( key="block_messenger", - name="Block Messenger", + translation_key="block_messenger", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:message-text", @@ -302,7 +342,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_minecraft", - name="Block Minecraft", + translation_key="block_minecraft", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:minecraft", @@ -310,7 +350,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_netflix", - name="Block Netflix", + translation_key="block_netflix", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:netflix", @@ -318,15 +358,23 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_pinterest", - name="Block Pinterest", + translation_key="block_pinterest", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:pinterest", state=lambda data: data.block_pinterest, ), + NextDnsSwitchEntityDescription[Settings]( + key="block_playstation_network", + translation_key="block_playstation_network", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:sony-playstation", + state=lambda data: data.block_playstation_network, + ), NextDnsSwitchEntityDescription[Settings]( key="block_primevideo", - name="Block Prime Video", + translation_key="block_primevideo", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:filmstrip", @@ -334,7 +382,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_reddit", - name="Block Reddit", + translation_key="block_reddit", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:reddit", @@ -342,7 +390,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_roblox", - name="Block Roblox", + translation_key="block_roblox", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:robot", @@ -350,7 +398,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_signal", - name="Block Signal", + translation_key="block_signal", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:chat-outline", @@ -358,7 +406,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_skype", - name="Block Skype", + translation_key="block_skype", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:skype", @@ -366,7 +414,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_snapchat", - name="Block Snapchat", + translation_key="block_snapchat", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:snapchat", @@ -374,7 +422,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_spotify", - name="Block Spotify", + translation_key="block_spotify", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:spotify", @@ -382,7 +430,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_steam", - name="Block Steam", + translation_key="block_steam", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:steam", @@ -390,7 +438,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_telegram", - name="Block Telegram", + translation_key="block_telegram", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:send-outline", @@ -398,7 +446,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_tiktok", - name="Block TikTok", + translation_key="block_tiktok", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:music-note", @@ -406,7 +454,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_tinder", - name="Block Tinder", + translation_key="block_tinder", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:fire", @@ -414,7 +462,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_tumblr", - name="Block Tumblr", + translation_key="block_tumblr", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:image-outline", @@ -422,7 +470,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_twitch", - name="Block Twitch", + translation_key="block_twitch", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:twitch", @@ -430,7 +478,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_twitter", - name="Block Twitter", + translation_key="block_twitter", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:twitter", @@ -438,7 +486,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_vimeo", - name="Block Vimeo", + translation_key="block_vimeo", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:vimeo", @@ -446,7 +494,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_vk", - name="Block VK", + translation_key="block_vk", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:power-socket-eu", @@ -454,7 +502,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_whatsapp", - name="Block WhatsApp", + translation_key="block_whatsapp", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:whatsapp", @@ -462,7 +510,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_xboxlive", - name="Block Xbox Live", + translation_key="block_xboxlive", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:microsoft-xbox", @@ -470,7 +518,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_youtube", - name="Block YouTube", + translation_key="block_youtube", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:youtube", @@ -478,7 +526,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_zoom", - name="Block Zoom", + translation_key="block_zoom", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:video", @@ -486,7 +534,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_dating", - name="Block dating", + translation_key="block_dating", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:candelabra", @@ -494,15 +542,23 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_gambling", - name="Block gambling", + translation_key="block_gambling", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:slot-machine", state=lambda data: data.block_gambling, ), + NextDnsSwitchEntityDescription[Settings]( + key="block_online_gaming", + translation_key="block_online_gaming", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:gamepad-variant", + state=lambda data: data.block_online_gaming, + ), NextDnsSwitchEntityDescription[Settings]( key="block_piracy", - name="Block piracy", + translation_key="block_piracy", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:pirate", @@ -510,7 +566,7 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_porn", - name="Block porn", + translation_key="block_porn", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:movie-off", @@ -518,12 +574,20 @@ SWITCHES = ( ), NextDnsSwitchEntityDescription[Settings]( key="block_social_networks", - name="Block social networks", + translation_key="block_social_networks", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, icon="mdi:facebook", state=lambda data: data.block_social_networks, ), + NextDnsSwitchEntityDescription[Settings]( + key="block_video_streaming", + translation_key="block_video_streaming", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:video-wireless-outline", + state=lambda data: data.block_video_streaming, + ), ) diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 4d12591a47..b541a145a6 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -8,8 +8,13 @@ from typing import Any import nikohomecontrol import voluptuous as vol -# Import the device class from the component that you want to support -from homeassistant.components.light import PLATFORM_SCHEMA, ColorMode, LightEntity +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + PLATFORM_SCHEMA, + ColorMode, + LightEntity, + brightness_supported, +) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady @@ -52,36 +57,23 @@ async def async_setup_platform( class NikoHomeControlLight(LightEntity): """Representation of an Niko Light.""" - _attr_color_mode = ColorMode.ONOFF - _attr_supported_color_modes = {ColorMode.ONOFF} - def __init__(self, light, data): """Set up the Niko Home Control light platform.""" self._data = data self._light = light - self._unique_id = f"light-{light.id}" - self._name = light.name - self._state = light.is_on - - @property - def unique_id(self): - """Return unique ID for light.""" - return self._unique_id - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def is_on(self): - """Return true if light is on.""" - return self._state + self._attr_unique_id = f"light-{light.id}" + self._attr_name = light.name + self._attr_is_on = light.is_on + self._attr_color_mode = ColorMode.ONOFF + self._attr_supported_color_modes = {ColorMode.ONOFF} + if light._state["type"] == 2: + self._attr_color_mode = ColorMode.BRIGHTNESS + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" _LOGGER.debug("Turn on: %s", self.name) - self._light.turn_on() + self._light.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55) def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" @@ -91,7 +83,10 @@ class NikoHomeControlLight(LightEntity): async def async_update(self) -> None: """Get the latest data from NikoHomeControl API.""" await self._data.async_update() - self._state = self._data.get_state(self._light.id) + state = self._data.get_state(self._light.id) + self._attr_is_on = state != 0 + if brightness_supported(self.supported_color_modes): + self._attr_brightness = state * 2.55 class NikoHomeControlData: @@ -122,5 +117,5 @@ class NikoHomeControlData: """Find and filter state based on action id.""" for state in self.data: if state["id"] == aid: - return state["value1"] != 0 + return state["value1"] _LOGGER.error("Failed to retrieve state off unknown light") diff --git a/homeassistant/components/nilu/air_quality.py b/homeassistant/components/nilu/air_quality.py index 5c3f9c5946..3745c6bae6 100644 --- a/homeassistant/components/nilu/air_quality.py +++ b/homeassistant/components/nilu/air_quality.py @@ -39,7 +39,6 @@ _LOGGER = logging.getLogger(__name__) ATTR_AREA = "area" ATTR_POLLUTION_INDEX = "nilu_pollution_index" -ATTRIBUTION = "Data provided by luftkvalitet.info and nilu.no" CONF_AREA = "area" CONF_STATION = "stations" @@ -173,6 +172,8 @@ class NiluData: class NiluSensor(AirQualityEntity): """Single nilu station air sensor.""" + _attr_attribution = "Data provided by luftkvalitet.info and nilu.no" + def __init__(self, api_data: NiluData, name: str, show_on_map: bool) -> None: """Initialize the sensor.""" self._api = api_data @@ -184,11 +185,6 @@ class NiluSensor(AirQualityEntity): self._attrs[CONF_LATITUDE] = api_data.data.latitude self._attrs[CONF_LONGITUDE] = api_data.data.longitude - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def extra_state_attributes(self) -> dict: """Return other details about the sensor state.""" diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index c3bcdb3553..7fe40af3b6 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -22,6 +22,8 @@ import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) +API_FAILURE = -1 + DEFAULT_NAME = "NMBS" DEFAULT_ICON = "mdi:train" @@ -162,14 +164,19 @@ class NMBSLiveBoard(SensorEntity): """Set the state equal to the next departure.""" liveboard = self._api_client.get_liveboard(self._station) - if ( - liveboard is None - or not liveboard.get("departures") - or liveboard.get("number") == "0" - ): + if liveboard == API_FAILURE: + _LOGGER.warning("API failed in NMBSLiveBoard") return - next_departure = liveboard["departures"]["departure"][0] + if not (departures := liveboard.get("departures")): + _LOGGER.warning("API returned invalid departures: %r", liveboard) + return + + _LOGGER.debug("API returned departures: %r", departures) + if departures["number"] == "0": + # No trains are scheduled + return + next_departure = departures["departure"][0] self._attrs = next_departure self._state = ( @@ -288,13 +295,19 @@ class NMBSSensor(SensorEntity): self._station_from, self._station_to ) - if connections is None or not connections.get("connection"): + if connections == API_FAILURE: + _LOGGER.warning("API failed in NMBSSensor") return - if int(connections["connection"][0]["departure"]["left"]) > 0: - next_connection = connections["connection"][1] + if not (connection := connections.get("connection")): + _LOGGER.warning("API returned invalid connection: %r", connections) + return + + _LOGGER.debug("API returned connection: %r", connection) + if int(connection[0]["departure"]["left"]) > 0: + next_connection = connection[1] else: - next_connection = connections["connection"][0] + next_connection = connection[0] self._attrs = next_connection diff --git a/homeassistant/components/norway_air/air_quality.py b/homeassistant/components/norway_air/air_quality.py index b4acdc3bdc..1a3d3661a1 100644 --- a/homeassistant/components/norway_air/air_quality.py +++ b/homeassistant/components/norway_air/air_quality.py @@ -17,12 +17,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = ( - "Air quality from " - "https://luftkvalitet.miljostatus.no/, " - "delivered by the Norwegian Meteorological Institute." -) -# https://api.met.no/license_data.html CONF_FORECAST = "forecast" @@ -81,6 +75,13 @@ def round_state(func): class AirSensor(AirQualityEntity): """Representation of an air quality sensor.""" + # https://api.met.no/license_data.html + _attr_attribution = ( + "Air quality from " + "https://luftkvalitet.miljostatus.no/, " + "delivered by the Norwegian Meteorological Institute." + ) + def __init__(self, name, coordinates, forecast, session): """Initialize the sensor.""" self._name = name @@ -88,11 +89,6 @@ class AirSensor(AirQualityEntity): coordinates, forecast, session, api_url=OVERRIDE_URL ) - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def extra_state_attributes(self) -> dict: """Return other details about the sensor state.""" diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 2d91e1c065..110671864e 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Mapping from functools import partial from typing import Any, Protocol, cast @@ -221,7 +221,7 @@ class BaseNotificationService: registered_targets: dict[str, Any] @property - def targets(self) -> dict[str, Any] | None: + def targets(self) -> Mapping[str, Any] | None: """Return a dictionary of registered targets.""" return None diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index eaa3f55e56..5e55496fc5 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -2,13 +2,17 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass, field, fields from datetime import timedelta import logging import traceback from typing import Any +from uuid import UUID from aionotion import async_get_client +from aionotion.bridge.models import Bridge from aionotion.errors import InvalidCredentialsError, NotionError +from aionotion.sensor.models import Listener, ListenerKind, Sensor from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform @@ -18,6 +22,7 @@ from homeassistant.helpers import ( aiohttp_client, config_validation as cv, device_registry as dr, + entity_registry as er, ) from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import ( @@ -26,7 +31,20 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import DOMAIN, LOGGER +from .const import ( + DOMAIN, + LOGGER, + SENSOR_BATTERY, + SENSOR_DOOR, + SENSOR_GARAGE_DOOR, + SENSOR_LEAK, + SENSOR_MISSING, + SENSOR_SAFE, + SENSOR_SLIDING, + SENSOR_SMOKE_CO, + SENSOR_TEMPERATURE, + SENSOR_WINDOW_HINGED, +) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -37,6 +55,51 @@ DEFAULT_SCAN_INTERVAL = timedelta(minutes=1) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +# Define a map of old-API task types to new-API listener types: +TASK_TYPE_TO_LISTENER_MAP: dict[str, ListenerKind] = { + SENSOR_BATTERY: ListenerKind.BATTERY, + SENSOR_DOOR: ListenerKind.DOOR, + SENSOR_GARAGE_DOOR: ListenerKind.GARAGE_DOOR, + SENSOR_LEAK: ListenerKind.LEAK_STATUS, + SENSOR_MISSING: ListenerKind.CONNECTED, + SENSOR_SAFE: ListenerKind.SAFE, + SENSOR_SLIDING: ListenerKind.SLIDING_DOOR_OR_WINDOW, + SENSOR_SMOKE_CO: ListenerKind.SMOKE, + SENSOR_TEMPERATURE: ListenerKind.TEMPERATURE, + SENSOR_WINDOW_HINGED: ListenerKind.HINGED_WINDOW, +} + + +@callback +def is_uuid(value: str) -> bool: + """Return whether a string is a valid UUID.""" + try: + UUID(value) + except ValueError: + return False + return True + + +@dataclass +class NotionData: + """Define a manager class for Notion data.""" + + # Define a dict of bridges, indexed by bridge ID (an integer): + bridges: dict[int, Bridge] = field(default_factory=dict) + + # Define a dict of listeners, indexed by listener UUID (a string): + listeners: dict[str, Listener] = field(default_factory=dict) + + # Define a dict of sensors, indexed by sensor UUID (a string): + sensors: dict[str, Sensor] = field(default_factory=dict) + + def asdict(self) -> dict[str, Any]: + """Represent this dataclass (and its Pydantic contents) as a dict.""" + return { + field.name: [obj.dict() for obj in getattr(self, field.name).values()] + for field in fields(self) + } + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Notion as a config entry.""" @@ -56,13 +119,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except NotionError as err: raise ConfigEntryNotReady("Config entry failed to load") from err - async def async_update() -> dict[str, dict[str, Any]]: + async def async_update() -> NotionData: """Get the latest data from the Notion API.""" - data: dict[str, dict[str, Any]] = {"bridges": {}, "sensors": {}, "tasks": {}} + data = NotionData() tasks = { "bridges": client.bridge.async_all(), + "listeners": client.sensor.async_listeners(), "sensors": client.sensor.async_all(), - "tasks": client.task.async_all(), } results = await asyncio.gather(*tasks.values(), return_exceptions=True) @@ -83,10 +146,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) from result for item in result: - if attr == "bridges" and item["id"] not in data["bridges"]: + if attr == "bridges": # If a new bridge is discovered, register it: - _async_register_new_bridge(hass, item, entry) - data[attr][item["id"]] = item + if item.id not in data.bridges: + _async_register_new_bridge(hass, item, entry) + data.bridges[item.id] = item + elif attr == "listeners": + data.listeners[item.id] = item + else: + data.sensors[item.uuid] = item return data @@ -102,6 +170,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator + @callback + def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None: + """Migrate Notion entity entries. + + This migration focuses on unique IDs, which have changed because of a Notion API + change: + + Old Format: _ + New Format: + """ + if is_uuid(entry.unique_id): + # If the unique ID is already a UUID, we don't need to migrate it: + return None + + sensor_id_str, task_type = entry.unique_id.split("_", 1) + sensor = next( + sensor + for sensor in coordinator.data.sensors.values() + if sensor.id == int(sensor_id_str) + ) + listener = next( + listener + for listener in coordinator.data.listeners.values() + if listener.sensor_id == sensor.uuid + and listener.listener_kind == TASK_TYPE_TO_LISTENER_MAP[task_type] + ) + + return {"new_unique_id": listener.id} + + await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -118,61 +216,59 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def _async_register_new_bridge( - hass: HomeAssistant, bridge: dict, entry: ConfigEntry + hass: HomeAssistant, bridge: Bridge, entry: ConfigEntry ) -> None: """Register a new bridge.""" - if name := bridge["name"]: + if name := bridge.name: bridge_name = name.capitalize() else: - bridge_name = bridge["id"] + bridge_name = str(bridge.id) device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, - identifiers={(DOMAIN, bridge["hardware_id"])}, + identifiers={(DOMAIN, bridge.hardware_id)}, manufacturer="Silicon Labs", - model=bridge["hardware_revision"], + model=str(bridge.hardware_revision), name=bridge_name, - sw_version=bridge["firmware_version"]["wifi"], + sw_version=bridge.firmware_version.wifi, ) -class NotionEntity(CoordinatorEntity): +class NotionEntity(CoordinatorEntity[DataUpdateCoordinator[NotionData]]): """Define a base Notion entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator, - task_id: str, + coordinator: DataUpdateCoordinator[NotionData], + listener_id: str, sensor_id: str, - bridge_id: str, + bridge_id: int, system_id: str, description: EntityDescription, ) -> None: """Initialize the entity.""" super().__init__(coordinator) - bridge = self.coordinator.data["bridges"].get(bridge_id, {}) - sensor = self.coordinator.data["sensors"][sensor_id] + bridge = self.coordinator.data.bridges[bridge_id] + sensor = self.coordinator.data.sensors[sensor_id] self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, sensor["hardware_id"])}, + identifiers={(DOMAIN, sensor.hardware_id)}, manufacturer="Silicon Labs", - model=sensor["hardware_revision"], - name=str(sensor["name"]).capitalize(), - sw_version=sensor["firmware_version"], - via_device=(DOMAIN, bridge.get("hardware_id")), + model=str(sensor.hardware_revision), + name=str(sensor.name).capitalize(), + sw_version=sensor.firmware_version, + via_device=(DOMAIN, bridge.hardware_id), ) self._attr_extra_state_attributes = {} - self._attr_unique_id = ( - f'{sensor_id}_{coordinator.data["tasks"][task_id]["task_type"]}' - ) + self._attr_unique_id = listener_id self._bridge_id = bridge_id + self._listener_id = listener_id self._sensor_id = sensor_id self._system_id = system_id - self._task_id = task_id self.entity_description = description @property @@ -180,7 +276,7 @@ class NotionEntity(CoordinatorEntity): """Return True if entity is available.""" return ( self.coordinator.last_update_success - and self._task_id in self.coordinator.data["tasks"] + and self._listener_id in self.coordinator.data.listeners ) @callback @@ -189,27 +285,23 @@ class NotionEntity(CoordinatorEntity): Sensors can move to other bridges based on signal strength, etc. """ - sensor = self.coordinator.data["sensors"][self._sensor_id] + sensor = self.coordinator.data.sensors[self._sensor_id] # If the sensor's bridge ID is the same as what we had before or if it points # to a bridge that doesn't exist (which can happen due to a Notion API bug), # return immediately: if ( - self._bridge_id == sensor["bridge"]["id"] - or sensor["bridge"]["id"] not in self.coordinator.data["bridges"] + self._bridge_id == sensor.bridge.id + or sensor.bridge.id not in self.coordinator.data.bridges ): return - self._bridge_id = sensor["bridge"]["id"] + self._bridge_id = sensor.bridge.id device_registry = dr.async_get(self.hass) - this_device = device_registry.async_get_device( - {(DOMAIN, sensor["hardware_id"])} - ) - bridge = self.coordinator.data["bridges"][self._bridge_id] - bridge_device = device_registry.async_get_device( - {(DOMAIN, bridge["hardware_id"])} - ) + this_device = device_registry.async_get_device({(DOMAIN, sensor.hardware_id)}) + bridge = self.coordinator.data.bridges[self._bridge_id] + bridge_device = device_registry.async_get_device({(DOMAIN, bridge.hardware_id)}) if not bridge_device or not this_device: return @@ -226,7 +318,7 @@ class NotionEntity(CoordinatorEntity): @callback def _handle_coordinator_update(self) -> None: """Respond to a DataUpdateCoordinator update.""" - if self._task_id in self.coordinator.data["tasks"]: + if self._listener_id in self.coordinator.data.listeners: self._async_update_bridge_id() self._async_update_from_latest_data() diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index f5d40b2a9d..bd2de303d2 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -4,6 +4,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import Literal +from aionotion.sensor.models import ListenerKind + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -26,9 +28,9 @@ from .const import ( SENSOR_SAFE, SENSOR_SLIDING, SENSOR_SMOKE_CO, - SENSOR_WINDOW_HINGED_HORIZONTAL, - SENSOR_WINDOW_HINGED_VERTICAL, + SENSOR_WINDOW_HINGED, ) +from .model import NotionEntityDescriptionMixin @dataclass @@ -40,7 +42,9 @@ class NotionBinarySensorDescriptionMixin: @dataclass class NotionBinarySensorDescription( - BinarySensorEntityDescription, NotionBinarySensorDescriptionMixin + BinarySensorEntityDescription, + NotionBinarySensorDescriptionMixin, + NotionEntityDescriptionMixin, ): """Describe a Notion binary sensor.""" @@ -51,24 +55,28 @@ BINARY_SENSOR_DESCRIPTIONS = ( name="Low battery", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, + listener_kind=ListenerKind.BATTERY, on_state="critical", ), NotionBinarySensorDescription( key=SENSOR_DOOR, name="Door", device_class=BinarySensorDeviceClass.DOOR, + listener_kind=ListenerKind.DOOR, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_GARAGE_DOOR, name="Garage door", device_class=BinarySensorDeviceClass.GARAGE_DOOR, + listener_kind=ListenerKind.GARAGE_DOOR, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_LEAK, name="Leak detector", device_class=BinarySensorDeviceClass.MOISTURE, + listener_kind=ListenerKind.LEAK_STATUS, on_state="leak", ), NotionBinarySensorDescription( @@ -76,36 +84,34 @@ BINARY_SENSOR_DESCRIPTIONS = ( name="Missing", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, + listener_kind=ListenerKind.CONNECTED, on_state="not_missing", ), NotionBinarySensorDescription( key=SENSOR_SAFE, name="Safe", device_class=BinarySensorDeviceClass.DOOR, + listener_kind=ListenerKind.SAFE, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_SLIDING, name="Sliding door/window", device_class=BinarySensorDeviceClass.DOOR, + listener_kind=ListenerKind.SLIDING_DOOR_OR_WINDOW, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_SMOKE_CO, name="Smoke/Carbon monoxide detector", device_class=BinarySensorDeviceClass.SMOKE, + listener_kind=ListenerKind.SMOKE, on_state="alarm", ), NotionBinarySensorDescription( - key=SENSOR_WINDOW_HINGED_HORIZONTAL, + key=SENSOR_WINDOW_HINGED, name="Hinged window", - device_class=BinarySensorDeviceClass.WINDOW, - on_state="open", - ), - NotionBinarySensorDescription( - key=SENSOR_WINDOW_HINGED_VERTICAL, - name="Hinged window", - device_class=BinarySensorDeviceClass.WINDOW, + listener_kind=ListenerKind.HINGED_WINDOW, on_state="open", ), ) @@ -121,16 +127,16 @@ async def async_setup_entry( [ NotionBinarySensor( coordinator, - task_id, - sensor["id"], - sensor["bridge"]["id"], - sensor["system_id"], + listener_id, + sensor.uuid, + sensor.bridge.id, + sensor.system_id, description, ) - for task_id, task in coordinator.data["tasks"].items() + for listener_id, listener in coordinator.data.listeners.items() for description in BINARY_SENSOR_DESCRIPTIONS - if description.key == task["task_type"] - and (sensor := coordinator.data["sensors"][task["sensor_id"]]) + if description.listener_kind == listener.listener_kind + and (sensor := coordinator.data.sensors[listener.sensor_id]) ] ) @@ -143,14 +149,14 @@ class NotionBinarySensor(NotionEntity, BinarySensorEntity): @callback def _async_update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" - task = self.coordinator.data["tasks"][self._task_id] + listener = self.coordinator.data.listeners[self._listener_id] - if "value" in task["status"]: - state = task["status"]["value"] - elif task["status"].get("insights", {}).get("primary"): - state = task["status"]["insights"]["primary"]["to_state"] + if listener.status.trigger_value: + state = listener.status.trigger_value + elif listener.insights.primary.value: + state = listener.insights.primary.value else: - LOGGER.warning("Unknown data payload: %s", task["status"]) + LOGGER.warning("Unknown listener structure: %s", listener) state = None self._attr_is_on = self.entity_description.on_state == state diff --git a/homeassistant/components/notion/const.py b/homeassistant/components/notion/const.py index 339d302073..5e89767d0e 100644 --- a/homeassistant/components/notion/const.py +++ b/homeassistant/components/notion/const.py @@ -13,5 +13,4 @@ SENSOR_SAFE = "safe" SENSOR_SLIDING = "sliding" SENSOR_SMOKE_CO = "alarm" SENSOR_TEMPERATURE = "temperature" -SENSOR_WINDOW_HINGED_HORIZONTAL = "window_hinged_horizontal" -SENSOR_WINDOW_HINGED_VERTICAL = "window_hinged_vertical" +SENSOR_WINDOW_HINGED = "window_hinged" diff --git a/homeassistant/components/notion/diagnostics.py b/homeassistant/components/notion/diagnostics.py index 9b0a070897..06100580b3 100644 --- a/homeassistant/components/notion/diagnostics.py +++ b/homeassistant/components/notion/diagnostics.py @@ -9,6 +9,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID, CONF_ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from . import NotionData from .const import DOMAIN CONF_DEVICE_KEY = "device_key" @@ -33,9 +34,12 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: DataUpdateCoordinator[NotionData] = hass.data[DOMAIN][entry.entry_id] - return { - "entry": async_redact_data(entry.as_dict(), TO_REDACT), - "data": async_redact_data(coordinator.data, TO_REDACT), - } + return async_redact_data( + { + "entry": entry.as_dict(), + "data": coordinator.data.asdict(), + }, + TO_REDACT, + ) diff --git a/homeassistant/components/notion/manifest.json b/homeassistant/components/notion/manifest.json index a2a01ca113..7eb2ef6bba 100644 --- a/homeassistant/components/notion/manifest.json +++ b/homeassistant/components/notion/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aionotion"], - "requirements": ["aionotion==3.0.2"] + "requirements": ["aionotion==2023.04.2"] } diff --git a/homeassistant/components/notion/model.py b/homeassistant/components/notion/model.py new file mode 100644 index 0000000000..0999df3abd --- /dev/null +++ b/homeassistant/components/notion/model.py @@ -0,0 +1,11 @@ +"""Define Notion model mixins.""" +from dataclasses import dataclass + +from aionotion.sensor.models import ListenerKind + + +@dataclass +class NotionEntityDescriptionMixin: + """Define an description mixin Notion entities.""" + + listener_kind: ListenerKind diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 7881780c4e..f4e6e7cc32 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -1,4 +1,8 @@ """Support for Notion sensors.""" +from dataclasses import dataclass + +from aionotion.sensor.models import ListenerKind + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -12,14 +16,22 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import NotionEntity from .const import DOMAIN, LOGGER, SENSOR_TEMPERATURE +from .model import NotionEntityDescriptionMixin + + +@dataclass +class NotionSensorDescription(SensorEntityDescription, NotionEntityDescriptionMixin): + """Describe a Notion sensor.""" + SENSOR_DESCRIPTIONS = ( - SensorEntityDescription( + NotionSensorDescription( key=SENSOR_TEMPERATURE, name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, + listener_kind=ListenerKind.TEMPERATURE, ), ) @@ -34,16 +46,16 @@ async def async_setup_entry( [ NotionSensor( coordinator, - task_id, - sensor["id"], - sensor["bridge"]["id"], - sensor["system_id"], + listener_id, + sensor.uuid, + sensor.bridge.id, + sensor.system_id, description, ) - for task_id, task in coordinator.data["tasks"].items() + for listener_id, listener in coordinator.data.listeners.items() for description in SENSOR_DESCRIPTIONS - if description.key == task["task_type"] - and (sensor := coordinator.data["sensors"][task["sensor_id"]]) + if description.listener_kind == listener.listener_kind + and (sensor := coordinator.data.sensors[listener.sensor_id]) ] ) @@ -54,13 +66,12 @@ class NotionSensor(NotionEntity, SensorEntity): @callback def _async_update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" - task = self.coordinator.data["tasks"][self._task_id] + listener = self.coordinator.data.listeners[self._listener_id] - if task["task_type"] == SENSOR_TEMPERATURE: - self._attr_native_value = round(float(task["status"]["value"]), 1) + if listener.listener_kind == ListenerKind.TEMPERATURE: + self._attr_native_value = round(listener.status.temperature, 1) # type: ignore[attr-defined] else: LOGGER.error( - "Unknown task type: %s: %s", - self.coordinator.data["sensors"][self._sensor_id], - task["task_type"], + "Unknown listener type for sensor %s", + self.coordinator.data.sensors[self._sensor_id], ) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 9504d38c93..b0bfe18614 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -3,9 +3,11 @@ from __future__ import annotations from collections import defaultdict from datetime import timedelta +from http import HTTPStatus import logging from typing import Generic, TypeVar +from aiohttp import web import async_timeout from pynuki import NukiBridge, NukiLock, NukiOpener from pynuki.bridge import InvalidCredentialsException @@ -13,10 +15,22 @@ from pynuki.device import NukiDevice from requests.exceptions import RequestException from homeassistant import exceptions +from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + CONF_TOKEN, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -32,7 +46,7 @@ from .const import ( DOMAIN, ERROR_STATES, ) -from .helpers import parse_id +from .helpers import NukiWebhookException, parse_id _NukiDeviceT = TypeVar("_NukiDeviceT", bound=NukiDevice) @@ -46,6 +60,110 @@ def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOp return bridge.locks, bridge.openers +async def _create_webhook( + hass: HomeAssistant, entry: ConfigEntry, bridge: NukiBridge +) -> None: + # Create HomeAssistant webhook + async def handle_webhook( + hass: HomeAssistant, webhook_id: str, request: web.Request + ) -> web.Response: + """Handle webhook callback.""" + try: + data = await request.json() + except ValueError: + return web.Response(status=HTTPStatus.BAD_REQUEST) + + locks = hass.data[DOMAIN][entry.entry_id][DATA_LOCKS] + openers = hass.data[DOMAIN][entry.entry_id][DATA_OPENERS] + + devices = [x for x in locks + openers if x.nuki_id == data["nukiId"]] + if len(devices) == 1: + devices[0].update_from_callback(data) + + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator.async_set_updated_data(None) + + return web.Response(status=HTTPStatus.OK) + + webhook.async_register( + hass, DOMAIN, entry.title, entry.entry_id, handle_webhook, local_only=True + ) + + webhook_url = webhook.async_generate_path(entry.entry_id) + + try: + hass_url = get_url( + hass, + allow_cloud=False, + allow_external=False, + allow_ip=True, + require_ssl=False, + ) + except NoURLAvailableError: + webhook.async_unregister(hass, entry.entry_id) + raise NukiWebhookException( + f"Error registering URL for webhook {entry.entry_id}: " + "HomeAssistant URL is not available" + ) from None + + url = f"{hass_url}{webhook_url}" + + if hass_url.startswith("https"): + ir.async_create_issue( + hass, + DOMAIN, + "https_webhook", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="https_webhook", + translation_placeholders={ + "base_url": hass_url, + "network_link": "https://my.home-assistant.io/redirect/network/", + }, + ) + else: + ir.async_delete_issue(hass, DOMAIN, "https_webhook") + + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _register_webhook, bridge, entry.entry_id, url + ) + except InvalidCredentialsException as err: + webhook.async_unregister(hass, entry.entry_id) + raise NukiWebhookException( + f"Invalid credentials for Bridge: {err}" + ) from err + except RequestException as err: + webhook.async_unregister(hass, entry.entry_id) + raise NukiWebhookException( + f"Error communicating with Bridge: {err}" + ) from err + + +def _register_webhook(bridge: NukiBridge, entry_id: str, url: str) -> bool: + # Register HA URL as webhook if not already + callbacks = bridge.callback_list() + for item in callbacks["callbacks"]: + if entry_id in item["url"]: + if item["url"] == url: + return True + bridge.callback_remove(item["id"]) + + if bridge.callback_add(url)["success"]: + return True + + return False + + +def _remove_webhook(bridge: NukiBridge, entry_id: str) -> None: + # Remove webhook if set + callbacks = bridge.callback_list() + for item in callbacks["callbacks"]: + if entry_id in item["url"]: + bridge.callback_remove(item["id"]) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Nuki entry.""" @@ -88,6 +206,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: sw_version=info["versions"]["firmwareVersion"], ) + try: + await _create_webhook(hass, entry, bridge) + except NukiWebhookException as err: + _LOGGER.warning("Error registering HomeAssistant webhook: %s", err) + + async def _stop_nuki(_: Event): + """Stop and remove the Nuki webhook.""" + webhook.async_unregister(hass, entry.entry_id) + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _remove_webhook, bridge, entry.entry_id + ) + except InvalidCredentialsException as err: + _LOGGER.error( + "Error unregistering webhook, invalid credentials for bridge: %s", err + ) + except RequestException as err: + _LOGGER.error("Error communicating with bridge: %s", err) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) + ) + coordinator = NukiCoordinator(hass, bridge, locks, openers) hass.data[DOMAIN][entry.entry_id] = { @@ -107,6 +249,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload the Nuki entry.""" + webhook.async_unregister(hass, entry.entry_id) + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _remove_webhook, + hass.data[DOMAIN][entry.entry_id][DATA_BRIDGE], + entry.entry_id, + ) + except InvalidCredentialsException as err: + raise UpdateFailed( + f"Unable to remove callback. Invalid credentials for Bridge: {err}" + ) from err + except RequestException as err: + raise UpdateFailed( + f"Unable to remove callback. Error communicating with Bridge: {err}" + ) from err + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 93bf164acc..2b3006eeb3 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -36,7 +36,6 @@ class NukiDoorsensorEntity(NukiEntity[NukiDevice], BinarySensorEntity): """Representation of a Nuki Lock Doorsensor.""" _attr_has_entity_name = True - _attr_name = "Door sensor" _attr_device_class = BinarySensorDeviceClass.DOOR @property diff --git a/homeassistant/components/nuki/helpers.py b/homeassistant/components/nuki/helpers.py index 45b7420754..1ba8e393f5 100644 --- a/homeassistant/components/nuki/helpers.py +++ b/homeassistant/components/nuki/helpers.py @@ -13,3 +13,7 @@ class CannotConnect(exceptions.HomeAssistantError): class InvalidAuth(exceptions.HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class NukiWebhookException(exceptions.HomeAssistantError): + """Error to indicate there was an issue with the webhook.""" diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 56b19b75a6..55560d3bf8 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -71,6 +71,7 @@ class NukiDeviceEntity(NukiEntity[_NukiDeviceT], LockEntity): _attr_has_entity_name = True _attr_supported_features = LockEntityFeature.OPEN + _attr_translation_key = "nuki_lock" @property def unique_id(self) -> str | None: diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index e6b741d442..8b87816fb7 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -3,6 +3,7 @@ "name": "Nuki", "codeowners": ["@pschmitt", "@pvizeli", "@pree"], "config_flow": true, + "dependencies": ["webhook"], "dhcp": [ { "hostname": "nuki_bridge_*" diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index 55f74f5d8a..c4578c7d14 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -29,7 +29,7 @@ class NukiBatterySensor(NukiEntity[NukiDevice], SensorEntity): """Representation of a Nuki Lock Battery sensor.""" _attr_has_entity_name = True - _attr_name = "Battery" + _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 32b72c7425..f139124e96 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -25,5 +25,30 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "issues": { + "https_webhook": { + "title": "Nuki webhook URL uses HTTPS (SSL)", + "description": "The Nuki bridge can not push events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device" + } + }, + "entity": { + "lock": { + "nuki_lock": { + "state_attributes": { + "battery_critical": { + "state": { + "on": "[%key:component::binary_sensor::entity_component::battery::state::on%]", + "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]" + } + } + } + } + }, + "sensor": { + "battery": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + } + } } } diff --git a/homeassistant/components/numato/sensor.py b/homeassistant/components/numato/sensor.py index 4ac28e0761..44adb78e6a 100644 --- a/homeassistant/components/numato/sensor.py +++ b/homeassistant/components/numato/sensor.py @@ -23,8 +23,6 @@ from . import ( _LOGGER = logging.getLogger(__name__) -ICON = "mdi:gauge" - def setup_platform( hass: HomeAssistant, @@ -71,6 +69,8 @@ def setup_platform( class NumatoGpioAdc(SensorEntity): """Represents an ADC port of a Numato USB GPIO expander.""" + _attr_icon = "mdi:gauge" + def __init__(self, name, device_id, port, src_range, dst_range, dst_unit, api): """Initialize the sensor.""" self._name = name @@ -97,11 +97,6 @@ class NumatoGpioAdc(SensorEntity): """Return the unit the value is expressed in.""" return self._unit_of_measurement - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the state.""" try: diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index b4110736e5..6bf5b68e92 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta import logging +from typing import cast import async_timeout from pynut2.nut2 import PyNUTClient, PyNUTError @@ -19,6 +20,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -26,9 +28,11 @@ from .const import ( COORDINATOR, DEFAULT_SCAN_INTERVAL, DOMAIN, + INTEGRATION_SUPPORTED_COMMANDS, PLATFORMS, PYNUT_DATA, PYNUT_UNIQUE_ID, + USER_AVAILABLE_COMMANDS, ) NUT_FAKE_SERIAL = ["unknown", "blank"] @@ -86,11 +90,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unique_id is None: unique_id = entry.entry_id + if username is not None and password is not None: + user_available_commands = { + device_supported_command + for device_supported_command in data.list_commands() or {} + if device_supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + else: + user_available_commands = set() + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { COORDINATOR: coordinator, PYNUT_DATA: data, PYNUT_UNIQUE_ID: unique_id, + USER_AVAILABLE_COMMANDS: user_available_commands, } device_registry = dr.async_get(hass) @@ -270,3 +284,24 @@ class PyNUTData: self._status = self._get_status() if self._device_info is None: self._device_info = self._get_device_info() + + async def async_run_command( + self, hass: HomeAssistant, command_name: str | None + ) -> None: + """Invoke instant command in UPS.""" + try: + await hass.async_add_executor_job( + self._client.run_command, self._alias, command_name + ) + except PyNUTError as err: + raise HomeAssistantError( + f"Error running command {command_name}, {err}" + ) from err + + def list_commands(self) -> dict[str, str] | None: + """Fetch the list of supported commands.""" + try: + return cast(dict[str, str], self._client.list_commands(self._alias)) + except PyNUTError as err: + _LOGGER.error("Error retrieving supported commands %s", err) + return None diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index a96b39e6d7..3041ac3872 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -21,6 +21,8 @@ PYNUT_DATA = "data" PYNUT_UNIQUE_ID = "unique_id" +USER_AVAILABLE_COMMANDS = "user_available_commands" + STATE_TYPES = { "OL": "Online", "OB": "On Battery", @@ -38,3 +40,59 @@ STATE_TYPES = { "FSD": "Forced Shutdown", "ALARM": "Alarm", } + +COMMAND_BEEPER_DISABLE = "beeper.disable" +COMMAND_BEEPER_ENABLE = "beeper.enable" +COMMAND_BEEPER_MUTE = "beeper.mute" +COMMAND_BEEPER_TOGGLE = "beeper.toggle" +COMMAND_BYPASS_START = "bypass.start" +COMMAND_BYPASS_STOP = "bypass.stop" +COMMAND_CALIBRATE_START = "calibrate.start" +COMMAND_CALIBRATE_STOP = "calibrate.stop" +COMMAND_LOAD_OFF = "load.off" +COMMAND_LOAD_ON = "load.on" +COMMAND_RESET_INPUT_MINMAX = "reset.input.minmax" +COMMAND_RESET_WATCHDOG = "reset.watchdog" +COMMAND_SHUTDOWN_REBOOT = "shutdown.reboot" +COMMAND_SHUTDOWN_REBOOT_GRACEFUL = "shutdown.reboot.graceful" +COMMAND_SHUTDOWN_RETURN = "shutdown.return" +COMMAND_SHUTDOWN_STAYOFF = "shutdown.stayoff" +COMMAND_SHUTDOWN_STOP = "shutdown.stop" +COMMAND_TEST_BATTERY_START = "test.battery.start" +COMMAND_TEST_BATTERY_START_DEEP = "test.battery.start.deep" +COMMAND_TEST_BATTERY_START_QUICK = "test.battery.start.quick" +COMMAND_TEST_BATTERY_STOP = "test.battery.stop" +COMMAND_TEST_FAILURE_START = "test.failure.start" +COMMAND_TEST_FAILURE_STOP = "test.failure.stop" +COMMAND_TEST_PANEL_START = "test.panel.start" +COMMAND_TEST_PANEL_STOP = "test.panel.stop" +COMMAND_TEST_SYSTEM_START = "test.system.start" + +INTEGRATION_SUPPORTED_COMMANDS = { + COMMAND_BEEPER_DISABLE, + COMMAND_BEEPER_ENABLE, + COMMAND_BEEPER_MUTE, + COMMAND_BEEPER_TOGGLE, + COMMAND_BYPASS_START, + COMMAND_BYPASS_STOP, + COMMAND_CALIBRATE_START, + COMMAND_CALIBRATE_STOP, + COMMAND_LOAD_OFF, + COMMAND_LOAD_ON, + COMMAND_RESET_INPUT_MINMAX, + COMMAND_RESET_WATCHDOG, + COMMAND_SHUTDOWN_REBOOT, + COMMAND_SHUTDOWN_REBOOT_GRACEFUL, + COMMAND_SHUTDOWN_RETURN, + COMMAND_SHUTDOWN_STAYOFF, + COMMAND_SHUTDOWN_STOP, + COMMAND_TEST_BATTERY_START, + COMMAND_TEST_BATTERY_START_DEEP, + COMMAND_TEST_BATTERY_START_QUICK, + COMMAND_TEST_BATTERY_STOP, + COMMAND_TEST_FAILURE_START, + COMMAND_TEST_FAILURE_STOP, + COMMAND_TEST_PANEL_START, + COMMAND_TEST_PANEL_STOP, + COMMAND_TEST_SYSTEM_START, +} diff --git a/homeassistant/components/nut/device_action.py b/homeassistant/components/nut/device_action.py new file mode 100644 index 0000000000..4898d9cc82 --- /dev/null +++ b/homeassistant/components/nut/device_action.py @@ -0,0 +1,75 @@ +"""Provides device actions for Network UPS Tools (NUT).""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import device_registry as dr +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import PyNUTData +from .const import ( + DOMAIN, + INTEGRATION_SUPPORTED_COMMANDS, + PYNUT_DATA, + USER_AVAILABLE_COMMANDS, +) + +ACTION_TYPES = {cmd.replace(".", "_") for cmd in INTEGRATION_SUPPORTED_COMMANDS} + +ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), + } +) + + +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: + """List device actions for Network UPS Tools (NUT) devices.""" + if (entry_id := _get_entry_id_from_device_id(hass, device_id)) is None: + return [] + base_action = { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + } + user_available_commands: set[str] = hass.data[DOMAIN][entry_id][ + USER_AVAILABLE_COMMANDS + ] + return [ + {CONF_TYPE: _get_device_action_name(command_name)} | base_action + for command_name in user_available_commands + ] + + +async def async_call_action_from_config( + hass: HomeAssistant, + config: ConfigType, + variables: TemplateVarsType, + context: Context | None, +) -> None: + """Execute a device action.""" + device_action_name: str = config[CONF_TYPE] + command_name = _get_command_name(device_action_name) + device_id: str = config[CONF_DEVICE_ID] + entry_id = _get_entry_id_from_device_id(hass, device_id) + data: PyNUTData = hass.data[DOMAIN][entry_id][PYNUT_DATA] + await data.async_run_command(hass, command_name) + + +def _get_device_action_name(command_name: str) -> str: + return command_name.replace(".", "_") + + +def _get_command_name(device_action_name: str) -> str: + return device_action_name.replace("_", ".") + + +def _get_entry_id_from_device_id(hass: HomeAssistant, device_id: str) -> str | None: + device_registry = dr.async_get(hass) + if (device := device_registry.async_get(device_id)) is None: + return None + return next(entry for entry in device.config_entries) diff --git a/homeassistant/components/nut/manifest.json b/homeassistant/components/nut/manifest.json index 9085b28c5c..0303dd70ec 100644 --- a/homeassistant/components/nut/manifest.json +++ b/homeassistant/components/nut/manifest.json @@ -1,7 +1,7 @@ { "domain": "nut", "name": "Network UPS Tools (NUT)", - "codeowners": ["@bdraco", "@ollo69"], + "codeowners": ["@bdraco", "@ollo69", "@pestevez"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nut", "integration_type": "device", diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 72ca53fc6b..6574577558 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -57,22 +57,22 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.status.display": SensorEntityDescription( key="ups.status.display", - name="Status", + translation_key="ups_status_display", icon="mdi:information-outline", ), "ups.status": SensorEntityDescription( key="ups.status", - name="Status Data", + translation_key="ups_status", icon="mdi:information-outline", ), "ups.alarm": SensorEntityDescription( key="ups.alarm", - name="Alarms", + translation_key="ups_alarm", icon="mdi:alarm", ), "ups.temperature": SensorEntityDescription( key="ups.temperature", - name="UPS Temperature", + translation_key="ups_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -81,14 +81,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.load": SensorEntityDescription( key="ups.load", - name="Load", + translation_key="ups_load", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", state_class=SensorStateClass.MEASUREMENT, ), "ups.load.high": SensorEntityDescription( key="ups.load.high", - name="Overload Setting", + translation_key="ups_load_high", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, @@ -96,14 +96,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.id": SensorEntityDescription( key="ups.id", - name="System identifier", + translation_key="ups_id", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.delay.start": SensorEntityDescription( key="ups.delay.start", - name="Load Restart Delay", + translation_key="ups_delay_start", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -111,7 +111,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.delay.reboot": SensorEntityDescription( key="ups.delay.reboot", - name="UPS Reboot Delay", + translation_key="ups_delay_reboot", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -119,7 +119,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.delay.shutdown": SensorEntityDescription( key="ups.delay.shutdown", - name="UPS Shutdown Delay", + translation_key="ups_delay_shutdown", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -127,7 +127,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.timer.start": SensorEntityDescription( key="ups.timer.start", - name="Load Start Timer", + translation_key="ups_timer_start", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -135,7 +135,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.timer.reboot": SensorEntityDescription( key="ups.timer.reboot", - name="Load Reboot Timer", + translation_key="ups_timer_reboot", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -143,7 +143,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.timer.shutdown": SensorEntityDescription( key="ups.timer.shutdown", - name="Load Shutdown Timer", + translation_key="ups_timer_shutdown", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -151,7 +151,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.test.interval": SensorEntityDescription( key="ups.test.interval", - name="Self-Test Interval", + translation_key="ups_test_interval", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -159,35 +159,35 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.test.result": SensorEntityDescription( key="ups.test.result", - name="Self-Test Result", + translation_key="ups_test_result", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.test.date": SensorEntityDescription( key="ups.test.date", - name="Self-Test Date", + translation_key="ups_test_date", icon="mdi:calendar", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.display.language": SensorEntityDescription( key="ups.display.language", - name="Language", + translation_key="ups_display_language", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.contacts": SensorEntityDescription( key="ups.contacts", - name="External Contacts", + translation_key="ups_contacts", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.efficiency": SensorEntityDescription( key="ups.efficiency", - name="Efficiency", + translation_key="ups_efficiency", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", state_class=SensorStateClass.MEASUREMENT, @@ -196,7 +196,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.power": SensorEntityDescription( key="ups.power", - name="Current Apparent Power", + translation_key="ups_power", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, @@ -205,7 +205,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.power.nominal": SensorEntityDescription( key="ups.power.nominal", - name="Nominal Power", + translation_key="ups_power_nominal", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, entity_category=EntityCategory.DIAGNOSTIC, @@ -213,7 +213,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.realpower": SensorEntityDescription( key="ups.realpower", - name="Current Real Power", + translation_key="ups_realpower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -222,7 +222,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.realpower.nominal": SensorEntityDescription( key="ups.realpower.nominal", - name="Nominal Real Power", + translation_key="ups_realpower_nominal", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, @@ -230,63 +230,63 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ups.beeper.status": SensorEntityDescription( key="ups.beeper.status", - name="Beeper Status", + translation_key="ups_beeper_status", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.type": SensorEntityDescription( key="ups.type", - name="UPS Type", + translation_key="ups_type", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.watchdog.status": SensorEntityDescription( key="ups.watchdog.status", - name="Watchdog Status", + translation_key="ups_watchdog_status", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.start.auto": SensorEntityDescription( key="ups.start.auto", - name="Start on AC", + translation_key="ups_start_auto", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.start.battery": SensorEntityDescription( key="ups.start.battery", - name="Start on Battery", + translation_key="ups_start_battery", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.start.reboot": SensorEntityDescription( key="ups.start.reboot", - name="Reboot on Battery", + translation_key="ups_start_reboot", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.shutdown": SensorEntityDescription( key="ups.shutdown", - name="Shutdown Ability", + translation_key="ups_shutdown", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.charge": SensorEntityDescription( key="battery.charge", - name="Battery Charge", + translation_key="battery_charge", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), "battery.charge.low": SensorEntityDescription( key="battery.charge.low", - name="Low Battery Setpoint", + translation_key="battery_charge_low", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, @@ -294,7 +294,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.charge.restart": SensorEntityDescription( key="battery.charge.restart", - name="Minimum Battery to Start", + translation_key="battery_charge_restart", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, @@ -302,7 +302,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.charge.warning": SensorEntityDescription( key="battery.charge.warning", - name="Warning Battery Setpoint", + translation_key="battery_charge_warning", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, @@ -310,12 +310,12 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.charger.status": SensorEntityDescription( key="battery.charger.status", - name="Charging Status", + translation_key="battery_charger_status", icon="mdi:information-outline", ), "battery.voltage": SensorEntityDescription( key="battery.voltage", - name="Battery Voltage", + translation_key="battery_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -324,7 +324,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.voltage.nominal": SensorEntityDescription( key="battery.voltage.nominal", - name="Nominal Battery Voltage", + translation_key="battery_voltage_nominal", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -332,7 +332,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.voltage.low": SensorEntityDescription( key="battery.voltage.low", - name="Low Battery Voltage", + translation_key="battery_voltage_low", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -340,7 +340,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.voltage.high": SensorEntityDescription( key="battery.voltage.high", - name="High Battery Voltage", + translation_key="battery_voltage_high", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -348,7 +348,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.capacity": SensorEntityDescription( key="battery.capacity", - name="Battery Capacity", + translation_key="battery_capacity", native_unit_of_measurement="Ah", icon="mdi:flash", entity_category=EntityCategory.DIAGNOSTIC, @@ -356,7 +356,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.current": SensorEntityDescription( key="battery.current", - name="Battery Current", + translation_key="battery_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -365,7 +365,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.current.total": SensorEntityDescription( key="battery.current.total", - name="Total Battery Current", + translation_key="battery_current_total", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -373,7 +373,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.temperature": SensorEntityDescription( key="battery.temperature", - name="Battery Temperature", + translation_key="battery_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -382,7 +382,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.runtime": SensorEntityDescription( key="battery.runtime", - name="Battery Runtime", + translation_key="battery_runtime", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -390,7 +390,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.runtime.low": SensorEntityDescription( key="battery.runtime.low", - name="Low Battery Runtime", + translation_key="battery_runtime_low", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -398,7 +398,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.runtime.restart": SensorEntityDescription( key="battery.runtime.restart", - name="Minimum Battery Runtime to Start", + translation_key="battery_runtime_restart", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -406,56 +406,56 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "battery.alarm.threshold": SensorEntityDescription( key="battery.alarm.threshold", - name="Battery Alarm Threshold", + translation_key="battery_alarm_threshold", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.date": SensorEntityDescription( key="battery.date", - name="Battery Date", + translation_key="battery_date", icon="mdi:calendar", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.mfr.date": SensorEntityDescription( key="battery.mfr.date", - name="Battery Manuf. Date", + translation_key="battery_mfr_date", icon="mdi:calendar", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.packs": SensorEntityDescription( key="battery.packs", - name="Number of Batteries", + translation_key="battery_packs", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.packs.bad": SensorEntityDescription( key="battery.packs.bad", - name="Number of Bad Batteries", + translation_key="battery_packs_bad", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.type": SensorEntityDescription( key="battery.type", - name="Battery Chemistry", + translation_key="battery_type", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "input.sensitivity": SensorEntityDescription( key="input.sensitivity", - name="Input Power Sensitivity", + translation_key="input_sensitivity", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "input.transfer.low": SensorEntityDescription( key="input.transfer.low", - name="Low Voltage Transfer", + translation_key="input_transfer_low", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -463,7 +463,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "input.transfer.high": SensorEntityDescription( key="input.transfer.high", - name="High Voltage Transfer", + translation_key="input_transfer_high", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -471,21 +471,21 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "input.transfer.reason": SensorEntityDescription( key="input.transfer.reason", - name="Voltage Transfer Reason", + translation_key="input_transfer_reason", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "input.voltage": SensorEntityDescription( key="input.voltage", - name="Input Voltage", + translation_key="input_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), "input.voltage.nominal": SensorEntityDescription( key="input.voltage.nominal", - name="Nominal Input Voltage", + translation_key="input_voltage_nominal", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -493,7 +493,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "input.frequency": SensorEntityDescription( key="input.frequency", - name="Input Line Frequency", + translation_key="input_frequency", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, @@ -502,7 +502,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "input.frequency.nominal": SensorEntityDescription( key="input.frequency.nominal", - name="Nominal Input Line Frequency", + translation_key="input_frequency_nominal", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, entity_category=EntityCategory.DIAGNOSTIC, @@ -510,14 +510,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "input.frequency.status": SensorEntityDescription( key="input.frequency.status", - name="Input Frequency Status", + translation_key="input_frequency_status", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "input.bypass.frequency": SensorEntityDescription( key="input.bypass.frequency", - name="Input Bypass Frequency", + translation_key="input_bypass_frequency", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, @@ -526,14 +526,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "input.bypass.phases": SensorEntityDescription( key="input.bypass.phases", - name="Input Bypass Phases", + translation_key="input_bypass_phases", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "input.current": SensorEntityDescription( key="input.current", - name="Input Current", + translation_key="input_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -542,14 +542,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "input.phases": SensorEntityDescription( key="input.phases", - name="Input Phases", + translation_key="input_phases", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "input.realpower": SensorEntityDescription( key="input.realpower", - name="Current Input Real Power", + translation_key="input_realpower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -558,7 +558,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.power.nominal": SensorEntityDescription( key="output.power.nominal", - name="Nominal Output Power", + translation_key="output_power_nominal", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, entity_category=EntityCategory.DIAGNOSTIC, @@ -566,7 +566,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.current": SensorEntityDescription( key="output.current", - name="Output Current", + translation_key="output_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -575,7 +575,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.current.nominal": SensorEntityDescription( key="output.current.nominal", - name="Nominal Output Current", + translation_key="output_current_nominal", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -583,14 +583,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.voltage": SensorEntityDescription( key="output.voltage", - name="Output Voltage", + translation_key="output_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), "output.voltage.nominal": SensorEntityDescription( key="output.voltage.nominal", - name="Nominal Output Voltage", + translation_key="output_voltage_nominal", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -598,7 +598,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.frequency": SensorEntityDescription( key="output.frequency", - name="Output Frequency", + translation_key="output_frequency", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, @@ -607,7 +607,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.frequency.nominal": SensorEntityDescription( key="output.frequency.nominal", - name="Nominal Output Frequency", + translation_key="output_frequency_nominal", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, entity_category=EntityCategory.DIAGNOSTIC, @@ -615,14 +615,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.phases": SensorEntityDescription( key="output.phases", - name="Output Phases", + translation_key="output_phases", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "output.power": SensorEntityDescription( key="output.power", - name="Output Apparent Power", + translation_key="output_power", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, @@ -631,7 +631,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.realpower": SensorEntityDescription( key="output.realpower", - name="Current Output Real Power", + translation_key="output_realpower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -640,7 +640,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "output.realpower.nominal": SensorEntityDescription( key="output.realpower.nominal", - name="Nominal Output Real Power", + translation_key="output_realpower_nominal", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, @@ -648,21 +648,21 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { ), "ambient.humidity": SensorEntityDescription( key="ambient.humidity", - name="Ambient Humidity", + translation_key="ambient_humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), "ambient.temperature": SensorEntityDescription( key="ambient.temperature", - name="Ambient Temperature", + translation_key="ambient_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), "watts": SensorEntityDescription( key="watts", - name="Watts", + translation_key="watts", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -717,6 +717,8 @@ async def async_setup_entry( class NUTSensor(CoordinatorEntity[DataUpdateCoordinator[dict[str, str]]], SensorEntity): """Representation of a sensor entity for NUT status values.""" + _attr_has_entity_name = True + def __init__( self, coordinator: DataUpdateCoordinator[dict[str, str]], @@ -729,7 +731,6 @@ class NUTSensor(CoordinatorEntity[DataUpdateCoordinator[dict[str, str]]], Sensor self.entity_description = sensor_description device_name = data.name.title() - self._attr_name = f"{device_name} {sensor_description.name}" self._attr_unique_id = f"{unique_id}_{sensor_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 70ecbfb6d2..a07e0ec2f7 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -33,5 +33,119 @@ } } } + }, + "device_automation": { + "action_type": { + "beeper_disable": "Disable UPS beeper/buzzer", + "beeper_enable": "Enable UPS beeper/buzzer", + "beeper_mute": "Temporarily mute UPS beeper/buzzer", + "beeper_toggle": "Toggle UPS beeper/buzzer", + "bypass_start": "Put the UPS in bypass mode", + "bypass_stop": "Take the UPS out of bypass mode", + "calibrate_start": "Start runtime calibration", + "calibrate_stop": "Stop runtime calibration", + "load_off": "Turn off the load immediately", + "load_on": "Turn on the load immediately", + "reset_input_minmax": "Reset minimum and maximum input voltage status", + "reset_watchdog": "Reset watchdog timer (forced reboot of load)", + "shutdown_reboot": "Shut down the load briefly while rebooting the UPS", + "shutdown_reboot_graceful": "After a delay, shut down the load briefly while rebooting the UPS", + "shutdown_return": "Turn off the load possibly after a delay and return when power is back", + "shutdown_stayoff": "Turn off the load possibly after a delay and remain off even if power returns", + "shutdown_stop": "Stop a shutdown in progress", + "test_battery_start": "Start a battery test", + "test_battery_start_deep": "Start a deep battery test", + "test_battery_start_quick": "Start a quick battery test", + "test_battery_stop": "Stop the battery test", + "test_failure_start": "Start a simulated power failure", + "test_failure_stop": "Stop simulating a power failure", + "test_panel_start": "Start testing the UPS panel", + "test_panel_stop": "Stop a UPS panel test", + "test_system_start": "Start a system test" + } + }, + "entity": { + "sensor": { + "ambient_humidity": { "name": "Ambient humidity" }, + "ambient_temperature": { "name": "Ambient temperature" }, + "battery_alarm_threshold": { "name": "Battery alarm threshold" }, + "battery_capacity": { "name": "Battery capacity" }, + "battery_charge": { "name": "Battery charge" }, + "battery_charge_low": { "name": "Low battery setpoint" }, + "battery_charge_restart": { "name": "Minimum battery to start" }, + "battery_charge_warning": { "name": "Warning battery setpoint" }, + "battery_charger_status": { "name": "Charging status" }, + "battery_current": { "name": "Battery current" }, + "battery_current_total": { "name": "Total battery current" }, + "battery_date": { "name": "Battery date" }, + "battery_mfr_date": { "name": "Battery manuf. date" }, + "battery_packs": { "name": "Number of batteries" }, + "battery_packs_bad": { "name": "Number of bad batteries" }, + "battery_runtime": { "name": "Battery runtime" }, + "battery_runtime_low": { "name": "Low battery runtime" }, + "battery_runtime_restart": { "name": "Minimum battery runtime to start" }, + "battery_temperature": { "name": "Battery temperature" }, + "battery_type": { "name": "Battery chemistry" }, + "battery_voltage": { "name": "Battery voltage" }, + "battery_voltage_high": { "name": "High battery voltage" }, + "battery_voltage_low": { "name": "Low battery voltage" }, + "battery_voltage_nominal": { "name": "Nominal battery voltage" }, + "input_bypass_frequency": { "name": "Input bypass frequency" }, + "input_bypass_phases": { "name": "Input bypass phases" }, + "input_current": { "name": "Input current" }, + "input_frequency": { "name": "Input line frequency" }, + "input_frequency_nominal": { "name": "Nominal input line frequency" }, + "input_frequency_status": { "name": "Input frequency status" }, + "input_phases": { "name": "Input phases" }, + "input_realpower": { "name": "Current input real power" }, + "input_sensitivity": { "name": "Input power sensitivity" }, + "input_transfer_high": { "name": "High voltage transfer" }, + "input_transfer_low": { "name": "Low voltage transfer" }, + "input_transfer_reason": { "name": "Voltage transfer reason" }, + "input_voltage": { "name": "Input voltage" }, + "input_voltage_nominal": { "name": "Nominal input voltage" }, + "output_current": { "name": "Output current" }, + "output_current_nominal": { "name": "Nominal output current" }, + "output_frequency": { "name": "Output frequency" }, + "output_frequency_nominal": { "name": "Nominal output frequency" }, + "output_phases": { "name": "Output phases" }, + "output_power": { "name": "Output apparent power" }, + "output_power_nominal": { "name": "Nominal output power" }, + "output_realpower": { "name": "Current output real power" }, + "output_realpower_nominal": { "name": "Nominal output real power" }, + "output_voltage": { "name": "Output voltage" }, + "output_voltage_nominal": { "name": "Nominal output voltage" }, + "ups_alarm": { "name": "Alarms" }, + "ups_beeper_status": { "name": "Beeper status" }, + "ups_contacts": { "name": "External contacts" }, + "ups_delay_reboot": { "name": "UPS reboot delay" }, + "ups_delay_shutdown": { "name": "UPS shutdown delay" }, + "ups_delay_start": { "name": "Load restart delay" }, + "ups_display_language": { "name": "Language" }, + "ups_efficiency": { "name": "Efficiency" }, + "ups_id": { "name": "System identifier" }, + "ups_load": { "name": "Load" }, + "ups_load_high": { "name": "Overload setting" }, + "ups_power": { "name": "Current apparent power" }, + "ups_power_nominal": { "name": "Nominal power" }, + "ups_realpower": { "name": "Current real power" }, + "ups_realpower_nominal": { "name": "Nominal real power" }, + "ups_shutdown": { "name": "Shutdown ability" }, + "ups_start_auto": { "name": "Start on ac" }, + "ups_start_battery": { "name": "Start on battery" }, + "ups_start_reboot": { "name": "Reboot on battery" }, + "ups_status": { "name": "Status data" }, + "ups_status_display": { "name": "Status" }, + "ups_temperature": { "name": "UPS temperature" }, + "ups_test_date": { "name": "Self-test date" }, + "ups_test_interval": { "name": "Self-test interval" }, + "ups_test_result": { "name": "Self-test result" }, + "ups_timer_reboot": { "name": "Load reboot timer" }, + "ups_timer_shutdown": { "name": "Load shutdown timer" }, + "ups_timer_start": { "name": "Load start timer" }, + "ups_type": { "name": "UPS type" }, + "ups_watchdog_status": { "name": "Watchdog status" }, + "watts": { "name": "Watts" } + } } } diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index fed7642605..ef0731ee94 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -26,6 +26,7 @@ from .const import ( COORDINATOR_OBSERVATION, DOMAIN, NWS_DATA, + UPDATE_TIME_PERIOD, ) _LOGGER = logging.getLogger(__name__) @@ -110,11 +111,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: nws_data = SimpleNWS(latitude, longitude, api_key, client_session) await nws_data.set_station(station) + async def update_observation() -> None: + """Retrieve recent observations.""" + await nws_data.update_observation(start_time=utcnow() - UPDATE_TIME_PERIOD) + coordinator_observation = NwsDataUpdateCoordinator( hass, _LOGGER, name=f"NWS observation station {station}", - update_method=nws_data.update_observation, + update_method=update_observation, update_interval=DEFAULT_SCAN_INTERVAL, failed_update_interval=FAILED_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index 96844edd80..109af7a565 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -82,3 +82,5 @@ COORDINATOR_FORECAST_HOURLY = "coordinator_forecast_hourly" OBSERVATION_VALID_TIME = timedelta(minutes=20) FORECAST_VALID_TIME = timedelta(minutes=45) +# A lot of stations update once hourly plus some wiggle room +UPDATE_TIME_PERIOD = timedelta(minutes=70) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index ecb95a1f9e..9edf6e6175 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -108,6 +108,7 @@ if TYPE_CHECKING: class NWSWeather(WeatherEntity): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_should_poll = False def __init__( @@ -154,11 +155,6 @@ class NWSWeather(WeatherEntity): self.async_write_ha_state() - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def name(self) -> str: """Return the name of the station.""" diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index 664ad033cf..b910964594 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -34,7 +34,7 @@ CONF_STOP_ID = "stop_id" CONF_ROUTE_ID = "route_id" DEFAULT_NAME = "OASA Telematics" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(seconds=60) @@ -67,6 +67,7 @@ class OASATelematicsSensor(SensorEntity): """Implementation of the OASA Telematics sensor.""" _attr_attribution = "Data retrieved from telematics.oasa.gr" + _attr_icon = "mdi:bus" def __init__(self, data, stop_id, route_id, name): """Initialize the sensor.""" @@ -121,11 +122,6 @@ class OASATelematicsSensor(SensorEntity): ) return {k: v for k, v in params.items() if v} - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from OASA API and update the states.""" self.data.update() diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index 810b24dca2..12cb9e25f8 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -1,9 +1,11 @@ """The Obihai integration.""" from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import PLATFORMS +from .connectivity import ObihaiConnection +from .const import LOGGER, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -13,6 +15,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + + version = entry.version + + LOGGER.debug("Migrating from version %s", version) + if version != 2: + requester = ObihaiConnection( + entry.data[CONF_HOST], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + await hass.async_add_executor_job(requester.update) + + new_unique_id = await hass.async_add_executor_job( + requester.pyobihai.get_device_mac + ) + hass.config_entries.async_update_entry(entry, unique_id=new_unique_id) + + entry.version = 2 + + LOGGER.info("Migration to version %s successful", entry.version) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py index 2f8dd0075b..6216fe0b97 100644 --- a/homeassistant/components/obihai/config_flow.py +++ b/homeassistant/components/obihai/config_flow.py @@ -1,10 +1,14 @@ """Config flow to configure the Obihai integration.""" + from __future__ import annotations +from socket import gaierror, gethostbyname from typing import Any +from pyobihai import PyObihai import voluptuous as vol +from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -16,11 +20,11 @@ from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, - vol.Optional( + vol.Required( CONF_USERNAME, default=DEFAULT_USERNAME, ): str, - vol.Optional( + vol.Required( CONF_PASSWORD, default=DEFAULT_PASSWORD, ): str, @@ -28,48 +32,124 @@ DATA_SCHEMA = vol.Schema( ) -async def async_validate_creds(hass: HomeAssistant, user_input: dict[str, Any]) -> bool: +async def async_validate_creds( + hass: HomeAssistant, user_input: dict[str, Any] +) -> PyObihai | None: """Manage Obihai options.""" - return await hass.async_add_executor_job( - validate_auth, - user_input[CONF_HOST], - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) + + if user_input[CONF_USERNAME] and user_input[CONF_PASSWORD]: + return await hass.async_add_executor_job( + validate_auth, + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + + # Don't bother authenticating if we've already determined the credentials are invalid + return None class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for Obihai.""" - VERSION = 1 + VERSION = 2 + discovery_schema: vol.Schema | None = None + _dhcp_discovery_info: dhcp.DhcpServiceInfo | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + ip: str | None = None if user_input is not None: - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - if await async_validate_creds(self.hass, user_input): - return self.async_create_entry( - title=user_input[CONF_HOST], - data=user_input, + try: + ip = await self.hass.async_add_executor_job( + gethostbyname, user_input[CONF_HOST] ) - errors["base"] = "cannot_connect" + except gaierror: + errors["base"] = "cannot_connect" - data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) + if ip: + if pyobihai := await async_validate_creds(self.hass, user_input): + device_mac = await self.hass.async_add_executor_job( + pyobihai.get_device_mac + ) + await self.async_set_unique_id(device_mac) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + ) + errors["base"] = "invalid_auth" + + data_schema = self.discovery_schema or DATA_SCHEMA return self.async_show_form( step_id="user", errors=errors, - data_schema=data_schema, + data_schema=self.add_suggested_values_to_schema(data_schema, user_input), ) + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Prepare configuration for a DHCP discovered Obihai.""" + + self._dhcp_discovery_info = discovery_info + return await self.async_step_dhcp_confirm() + + async def async_step_dhcp_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Attempt to confirm.""" + assert self._dhcp_discovery_info + await self.async_set_unique_id(self._dhcp_discovery_info.macaddress) + self._abort_if_unique_id_configured() + + if user_input is None: + credentials = { + CONF_HOST: self._dhcp_discovery_info.ip, + CONF_PASSWORD: DEFAULT_PASSWORD, + CONF_USERNAME: DEFAULT_USERNAME, + } + if await async_validate_creds(self.hass, credentials): + self.discovery_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, credentials + ) + else: + self.discovery_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, + { + CONF_HOST: self._dhcp_discovery_info.ip, + CONF_USERNAME: "", + CONF_PASSWORD: "", + }, + ) + + # Show the confirmation dialog + return self.async_show_form( + step_id="dhcp_confirm", + data_schema=self.discovery_schema, + description_placeholders={CONF_HOST: self._dhcp_discovery_info.ip}, + ) + + return await self.async_step_user(user_input=user_input) + # DEPRECATED async def async_step_import(self, config: dict[str, Any]) -> FlowResult: """Handle a flow initialized by importing a config.""" - self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) - if await async_validate_creds(self.hass, config): + + try: + _ = await self.hass.async_add_executor_job(gethostbyname, config[CONF_HOST]) + except gaierror: + return self.async_abort(reason="cannot_connect") + + if pyobihai := await async_validate_creds(self.hass, config): + device_mac = await self.hass.async_add_executor_job(pyobihai.get_device_mac) + await self.async_set_unique_id(device_mac) + self._abort_if_unique_id_configured() + return self.async_create_entry( title=config.get(CONF_NAME, config[CONF_HOST]), data={ @@ -79,4 +159,4 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - return self.async_abort(reason="cannot_connect") + return self.async_abort(reason="invalid_auth") diff --git a/homeassistant/components/obihai/connectivity.py b/homeassistant/components/obihai/connectivity.py index 93eeccd1bb..071390f1ad 100644 --- a/homeassistant/components/obihai/connectivity.py +++ b/homeassistant/components/obihai/connectivity.py @@ -1,4 +1,5 @@ """Support for Obihai Connectivity.""" + from __future__ import annotations from pyobihai import PyObihai @@ -12,6 +13,7 @@ def get_pyobihai( password: str, ) -> PyObihai: """Retrieve an authenticated PyObihai.""" + return PyObihai(host, username, password) @@ -19,16 +21,17 @@ def validate_auth( host: str, username: str, password: str, -) -> bool: +) -> PyObihai | None: """Test if the given setting works as expected.""" + obi = get_pyobihai(host, username, password) login = obi.check_account() if not login: LOGGER.debug("Invalid credentials") - return False + return None - return True + return obi class ObihaiConnection: @@ -53,6 +56,7 @@ class ObihaiConnection: def update(self) -> bool: """Validate connection and retrieve a list of sensors.""" + if not self.pyobihai: self.pyobihai = get_pyobihai(self.host, self.username, self.password) diff --git a/homeassistant/components/obihai/manifest.json b/homeassistant/components/obihai/manifest.json index 939c170f98..2907f3f179 100644 --- a/homeassistant/components/obihai/manifest.json +++ b/homeassistant/components/obihai/manifest.json @@ -3,6 +3,11 @@ "name": "Obihai", "codeowners": ["@dshokouhi", "@ejpenney"], "config_flow": true, + "dhcp": [ + { + "macaddress": "9CADEF*" + } + ], "documentation": "https://www.home-assistant.io/integrations/obihai", "iot_class": "local_polling", "loggers": ["pyobihai"], diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json index fb673675ad..1b91cd6065 100644 --- a/homeassistant/components/obihai/strings.json +++ b/homeassistant/components/obihai/strings.json @@ -7,10 +7,19 @@ "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" } + }, + "dhcp_confirm": { + "description": "Do you want to set up {host}?", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py index 653c15f184..9c3049ff87 100644 --- a/homeassistant/components/octoprint/camera.py +++ b/homeassistant/components/octoprint/camera.py @@ -5,6 +5,7 @@ from pyoctoprintapi import OctoprintClient, WebcamSettings from homeassistant.components.mjpeg.camera import MjpegCamera from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -28,6 +29,7 @@ async def async_setup_entry( assert device_id is not None camera_info = await client.get_webcam_info() + verify_ssl = config_entry.data[CONF_VERIFY_SSL] if not camera_info or not camera_info.enabled: return @@ -38,6 +40,7 @@ async def async_setup_entry( camera_info, coordinator.device_info, device_id, + verify_ssl, ) ] ) @@ -47,7 +50,11 @@ class OctoprintCamera(MjpegCamera): """Representation of an OctoPrint Camera Stream.""" def __init__( - self, camera_settings: WebcamSettings, device_info: DeviceInfo, device_id: str + self, + camera_settings: WebcamSettings, + device_info: DeviceInfo, + device_id: str, + verify_ssl: bool, ) -> None: """Initialize as a subclass of MjpegCamera.""" super().__init__( @@ -56,4 +63,5 @@ class OctoprintCamera(MjpegCamera): name="OctoPrint Camera", still_image_url=camera_settings.external_snapshot_url, unique_id=device_id, + verify_ssl=verify_ssl, ) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index e255357646..b405140bc3 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -37,8 +37,8 @@ DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ... OneWireBinarySensorEntityDescription( key=f"sensed.{id}", entity_registry_enabled_default=False, - name=f"Sensed {id}", read_mode=READ_MODE_BOOL, + translation_key=f"sensed_{id.lower()}", ) for id in DEVICE_KEYS_A_B ), @@ -46,8 +46,8 @@ DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ... OneWireBinarySensorEntityDescription( key=f"sensed.{id}", entity_registry_enabled_default=False, - name=f"Sensed {id}", read_mode=READ_MODE_BOOL, + translation_key=f"sensed_{id}", ) for id in DEVICE_KEYS_0_7 ), @@ -55,8 +55,8 @@ DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ... OneWireBinarySensorEntityDescription( key=f"sensed.{id}", entity_registry_enabled_default=False, - name=f"Sensed {id}", read_mode=READ_MODE_BOOL, + translation_key=f"sensed_{id.lower()}", ) for id in DEVICE_KEYS_A_B ), @@ -69,10 +69,10 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireBinarySensorEntityDescription, ...]] = { OneWireBinarySensorEntityDescription( key=f"hub/short.{id}", entity_registry_enabled_default=False, - name=f"Hub Short on Branch {id}", read_mode=READ_MODE_BOOL, entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, + translation_key=f"hub_short_{id}", ) for id in DEVICE_KEYS_0_3 ), @@ -120,14 +120,12 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: continue for description in get_sensor_types(device_sub_type)[family]: device_file = os.path.join(os.path.split(device.path)[0], description.key) - name = f"{device_id} {description.name}" entities.append( OneWireBinarySensor( description=description, device_id=device_id, device_file=device_file, device_info=device_info, - name=name, owproxy=onewire_hub.owproxy, ) ) diff --git a/homeassistant/components/onewire/onewire_entities.py b/homeassistant/components/onewire/onewire_entities.py index 59ceb34d6f..f2a56e513f 100644 --- a/homeassistant/components/onewire/onewire_entities.py +++ b/homeassistant/components/onewire/onewire_entities.py @@ -27,6 +27,7 @@ class OneWireEntity(Entity): """Implementation of a 1-Wire entity.""" entity_description: OneWireEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -34,7 +35,6 @@ class OneWireEntity(Entity): device_id: str, device_info: DeviceInfo, device_file: str, - name: str, owproxy: protocol._Proxy, ) -> None: """Initialize the entity.""" @@ -42,7 +42,6 @@ class OneWireEntity(Entity): self._last_update_success = True self._attr_unique_id = f"/{device_id}/{description.key}" self._attr_device_info = device_info - self._attr_name = name self._device_file = device_file self._state: StateType = None self._value_raw: float | None = None diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index a21a093e0a..65bd542fc3 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -70,10 +70,10 @@ def _get_sensor_precision_family_28(device_id: str, options: Mapping[str, Any]) SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION = OneWireSensorEntityDescription( key="temperature", device_class=SensorDeviceClass.TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="temperature", ) _LOGGER = logging.getLogger(__name__) @@ -86,19 +86,19 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { key="TAI8570/temperature", device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="temperature", ), OneWireSensorEntityDescription( key="TAI8570/pressure", device_class=SensorDeviceClass.PRESSURE, entity_registry_enabled_default=False, - name="Pressure", native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="pressure", ), ), "22": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), @@ -108,102 +108,102 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { key="humidity", device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, - name="Humidity", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity", ), OneWireSensorEntityDescription( key="HIH3600/humidity", device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, - name="Humidity HIH3600", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity_hih3600", ), OneWireSensorEntityDescription( key="HIH4000/humidity", device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, - name="Humidity HIH4000", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity_hih4000", ), OneWireSensorEntityDescription( key="HIH5030/humidity", device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, - name="Humidity HIH5030", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity_hih5030", ), OneWireSensorEntityDescription( key="HTM1735/humidity", device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, - name="Humidity HTM1735", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity_htm1735", ), OneWireSensorEntityDescription( key="B1-R1-A/pressure", device_class=SensorDeviceClass.PRESSURE, entity_registry_enabled_default=False, - name="Pressure", native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="pressure", ), OneWireSensorEntityDescription( key="S3-R1-A/illuminance", device_class=SensorDeviceClass.ILLUMINANCE, entity_registry_enabled_default=False, - name="Illuminance", native_unit_of_measurement=LIGHT_LUX, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="illuminance", ), OneWireSensorEntityDescription( key="VAD", device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, - name="Voltage VAD", native_unit_of_measurement=UnitOfElectricPotential.VOLT, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_vad", ), OneWireSensorEntityDescription( key="VDD", device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, - name="Voltage VDD", native_unit_of_measurement=UnitOfElectricPotential.VOLT, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_vdd", ), OneWireSensorEntityDescription( key="vis", device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, - name="vis", native_unit_of_measurement=UnitOfElectricPotential.VOLT, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_vis", ), ), "28": ( OneWireSensorEntityDescription( key="temperature", device_class=SensorDeviceClass.TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, override_key=_get_sensor_precision_family_28, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="temperature", ), ), "30": ( @@ -212,29 +212,29 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { key="typeX/temperature", device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, - name="Thermocouple temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, override_key=lambda d, o: "typeK/temperature", state_class=SensorStateClass.MEASUREMENT, + translation_key="thermocouple_temperature_k", ), OneWireSensorEntityDescription( key="volt", device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, - name="Voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage", ), OneWireSensorEntityDescription( key="vis", device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, - name="vis", native_unit_of_measurement=UnitOfElectricPotential.VOLT, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_vis_gradient", ), ), "3B": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), @@ -242,10 +242,10 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { "1D": tuple( OneWireSensorEntityDescription( key=f"counter.{id}", - name=f"Counter {id}", native_unit_of_measurement="count", read_mode=READ_MODE_INT, state_class=SensorStateClass.TOTAL_INCREASING, + translation_key=f"counter_{id.lower()}", ) for id in DEVICE_KEYS_A_B ), @@ -258,36 +258,36 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { OneWireSensorEntityDescription( key="humidity/humidity_corrected", device_class=SensorDeviceClass.HUMIDITY, - name="Humidity", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity", ), OneWireSensorEntityDescription( key="humidity/humidity_raw", device_class=SensorDeviceClass.HUMIDITY, - name="Humidity Raw", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity_raw", ), OneWireSensorEntityDescription( key="humidity/temperature", device_class=SensorDeviceClass.TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="temperature", ), ), "HB_MOISTURE_METER": tuple( OneWireSensorEntityDescription( key=f"moisture/sensor.{id}", device_class=SensorDeviceClass.PRESSURE, - name=f"Moisture {id}", native_unit_of_measurement=UnitOfPressure.CBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key=f"moisture_{id}", ) for id in DEVICE_KEYS_0_3 ), @@ -300,52 +300,52 @@ EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { OneWireSensorEntityDescription( key="EDS0066/temperature", device_class=SensorDeviceClass.TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="temperature", ), OneWireSensorEntityDescription( key="EDS0066/pressure", device_class=SensorDeviceClass.PRESSURE, - name="Pressure", native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="pressure", ), ), "EDS0068": ( OneWireSensorEntityDescription( key="EDS0068/temperature", device_class=SensorDeviceClass.TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="temperature", ), OneWireSensorEntityDescription( key="EDS0068/pressure", device_class=SensorDeviceClass.PRESSURE, - name="Pressure", native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="pressure", ), OneWireSensorEntityDescription( key="EDS0068/light", device_class=SensorDeviceClass.ILLUMINANCE, - name="Illuminance", native_unit_of_measurement=LIGHT_LUX, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="illuminance", ), OneWireSensorEntityDescription( key="EDS0068/humidity", device_class=SensorDeviceClass.HUMIDITY, - name="Humidity", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, + translation_key="humidity", ), ), } @@ -412,7 +412,8 @@ def get_entities( description = copy.deepcopy(description) description.device_class = SensorDeviceClass.HUMIDITY description.native_unit_of_measurement = PERCENTAGE - description.name = f"Wetness {s_id}" + description.translation_key = f"wetness_{s_id}" + _LOGGER.info(description.translation_key) override_key = None if description.override_key: override_key = description.override_key(device_id, options) @@ -420,7 +421,6 @@ def get_entities( os.path.split(device.path)[0], override_key or description.key, ) - name = f"{device_id} {description.name}" if family == "12": # We need to check if there is TAI8570 plugged in try: @@ -438,7 +438,6 @@ def get_entities( device_id=device_id, device_file=device_file, device_info=device_info, - name=name, owproxy=onewire_hub.owproxy, ) ) diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 734971cb2a..2a7bd307ff 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -16,6 +16,233 @@ } } }, + "entity": { + "binary_sensor": { + "sensed_a": { + "name": "Sensed A" + }, + "sensed_b": { + "name": "Sensed B" + }, + "sensed_0": { + "name": "Sensed 0" + }, + "sensed_1": { + "name": "Sensed 1" + }, + "sensed_2": { + "name": "Sensed 2" + }, + "sensed_3": { + "name": "Sensed 3" + }, + "sensed_4": { + "name": "Sensed 4" + }, + "sensed_5": { + "name": "Sensed 5" + }, + "sensed_6": { + "name": "Sensed 6" + }, + "sensed_7": { + "name": "Sensed 7" + }, + "hub_short_0": { + "name": "Hub short on branch 0" + }, + "hub_short_1": { + "name": "Hub short on branch 1" + }, + "hub_short_2": { + "name": "Hub short on branch 2" + }, + "hub_short_3": { + "name": "Hub short on branch 3" + } + }, + "sensor": { + "counter_a": { + "name": "Counter A" + }, + "counter_b": { + "name": "Counter B" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "humidity_hih3600": { + "name": "HIH3600 humidity" + }, + "humidity_hih4000": { + "name": "HIH4000 humidity" + }, + "humidity_hih5030": { + "name": "HIH5030 humidity" + }, + "humidity_htm1735": { + "name": "HTM1735 humidity" + }, + "humidity_raw": { + "name": "Raw humidity" + }, + "illuminance": { + "name": "[%key:component::sensor::entity_component::illuminance::name%]" + }, + "moisture_1": { + "name": "Moisture 1" + }, + "moisture_2": { + "name": "Moisture 2" + }, + "moisture_3": { + "name": "Moisture 3" + }, + "moisture_4": { + "name": "Moisture 4" + }, + "pressure": { + "name": "[%key:component::sensor::entity_component::pressure::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "thermocouple_temperature_k": { + "name": "Thermocouple K temperature" + }, + "voltage": { + "name": "[%key:component::sensor::entity_component::voltage::name%]" + }, + "voltage_vad": { + "name": "VAD voltage" + }, + "voltage_vdd": { + "name": "VDD voltage" + }, + "voltage_vis": { + "name": "VIS voltage difference" + }, + "voltage_vis_gradient": { + "name": "VIS voltage gradient" + }, + "wetness_0": { + "name": "Wetness 0" + }, + "wetness_1": { + "name": "Wetness 1" + }, + "wetness_2": { + "name": "Wetness 2" + }, + "wetness_3": { + "name": "Wetness 3" + } + }, + "switch": { + "hub_branch_0": { + "name": "Hub branch 0" + }, + "hub_branch_1": { + "name": "Hub branch 1" + }, + "hub_branch_2": { + "name": "Hub branch 2" + }, + "hub_branch_3": { + "name": "Hub branch 3" + }, + "iad": { + "name": "Current A/D control" + }, + "latch_0": { + "name": "Latch 0" + }, + "latch_1": { + "name": "Latch 1" + }, + "latch_2": { + "name": "Latch 2" + }, + "latch_3": { + "name": "Latch 3" + }, + "latch_4": { + "name": "Latch 4" + }, + "latch_5": { + "name": "Latch 5" + }, + "latch_6": { + "name": "Latch 6" + }, + "latch_7": { + "name": "Latch 7" + }, + "latch_a": { + "name": "Latch A" + }, + "latch_b": { + "name": "Latch B" + }, + "leaf_sensor_0": { + "name": "Leaf sensor 0" + }, + "leaf_sensor_1": { + "name": "Leaf sensor 1" + }, + "leaf_sensor_2": { + "name": "Leaf sensor 2" + }, + "leaf_sensor_3": { + "name": "Leaf sensor 3" + }, + "moisture_sensor_0": { + "name": "Moisture sensor 0" + }, + "moisture_sensor_1": { + "name": "Moisture sensor 1" + }, + "moisture_sensor_2": { + "name": "Moisture sensor 2" + }, + "moisture_sensor_3": { + "name": "Moisture sensor 3" + }, + "pio": { + "name": "Programmed input-output" + }, + "pio_0": { + "name": "Programmed input-output 0" + }, + "pio_1": { + "name": "Programmed input-output 1" + }, + "pio_2": { + "name": "Programmed input-output 2" + }, + "pio_3": { + "name": "Programmed input-output 3" + }, + "pio_4": { + "name": "Programmed input-output 4" + }, + "pio_5": { + "name": "Programmed input-output 5" + }, + "pio_6": { + "name": "Programmed input-output 6" + }, + "pio_7": { + "name": "Programmed input-output 7" + }, + "pio_a": { + "name": "Programmed input-output A" + }, + "pio_b": { + "name": "Programmed input-output B" + } + } + }, "options": { "error": { "device_not_selected": "Select devices to configure" diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 86172a8751..986be11d16 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -32,8 +32,8 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key="PIO", entity_registry_enabled_default=False, - name="PIO", read_mode=READ_MODE_BOOL, + translation_key="pio", ), ), "12": tuple( @@ -41,8 +41,8 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"PIO.{id}", entity_registry_enabled_default=False, - name=f"PIO {id}", read_mode=READ_MODE_BOOL, + translation_key=f"pio_{id.lower()}", ) for id in DEVICE_KEYS_A_B ] @@ -50,8 +50,8 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"latch.{id}", entity_registry_enabled_default=False, - name=f"Latch {id}", read_mode=READ_MODE_BOOL, + translation_key=f"latch_{id.lower()}", ) for id in DEVICE_KEYS_A_B ] @@ -61,8 +61,8 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { key="IAD", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, - name="IAD", read_mode=READ_MODE_BOOL, + translation_key="iad", ), ), "29": tuple( @@ -70,8 +70,8 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"PIO.{id}", entity_registry_enabled_default=False, - name=f"PIO {id}", read_mode=READ_MODE_BOOL, + translation_key=f"pio_{id}", ) for id in DEVICE_KEYS_0_7 ] @@ -79,8 +79,8 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"latch.{id}", entity_registry_enabled_default=False, - name=f"Latch {id}", read_mode=READ_MODE_BOOL, + translation_key=f"latch_{id}", ) for id in DEVICE_KEYS_0_7 ] @@ -89,8 +89,8 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"PIO.{id}", entity_registry_enabled_default=False, - name=f"PIO {id}", read_mode=READ_MODE_BOOL, + translation_key=f"pio_{id.lower()}", ) for id in DEVICE_KEYS_A_B ), @@ -104,9 +104,9 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"hub/branch.{id}", entity_registry_enabled_default=False, - name=f"Hub Branch {id} Enable", read_mode=READ_MODE_BOOL, entity_category=EntityCategory.CONFIG, + translation_key=f"hub_branch_{id}", ) for id in DEVICE_KEYS_0_3 ), @@ -115,9 +115,9 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"moisture/is_leaf.{id}", entity_registry_enabled_default=False, - name=f"Leaf Sensor {id} Enable", read_mode=READ_MODE_BOOL, entity_category=EntityCategory.CONFIG, + translation_key=f"leaf_sensor_{id}", ) for id in DEVICE_KEYS_0_3 ] @@ -125,9 +125,9 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireEntityDescription, ...]] = { OneWireSwitchEntityDescription( key=f"moisture/is_moisture.{id}", entity_registry_enabled_default=False, - name=f"Moisture Sensor {id} Enable", read_mode=READ_MODE_BOOL, entity_category=EntityCategory.CONFIG, + translation_key=f"moisture_sensor_{id}", ) for id in DEVICE_KEYS_0_3 ] @@ -177,14 +177,12 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: continue for description in get_sensor_types(device_sub_type)[family]: device_file = os.path.join(os.path.split(device.path)[0], description.key) - name = f"{device_id} {description.name}" entities.append( OneWireSwitch( description=description, device_id=device_id, device_file=device_file, device_info=device_info, - name=name, owproxy=onewire_hub.owproxy, ) ) diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 77a5c6d1bd..ec894befae 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -1,5 +1,9 @@ """The ONVIF integration.""" +import logging + +from httpx import RequestError from onvif.exceptions import ONVIFAuthError, ONVIFError, ONVIFTimeoutError +from zeep.exceptions import Fault, TransportError from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS from homeassistant.components.stream import CONF_RTSP_TRANSPORT, RTSP_TRANSPORTS @@ -11,10 +15,13 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_SNAPSHOT_AUTH, DEFAULT_ARGUMENTS, DOMAIN from .device import ONVIFDevice +from .util import is_auth_error, stringify_onvif_error + +LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -27,27 +34,44 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device = ONVIFDevice(hass, entry) - if not await device.async_setup(): + try: + await device.async_setup() + if not entry.data.get(CONF_SNAPSHOT_AUTH): + await async_populate_snapshot_auth(hass, device, entry) + except RequestError as err: await device.device.close() - return False + raise ConfigEntryNotReady( + f"Could not connect to camera {device.device.host}:{device.device.port}: {err}" + ) from err + except Fault as err: + await device.device.close() + if is_auth_error(err): + raise ConfigEntryAuthFailed( + f"Auth Failed: {stringify_onvif_error(err)}" + ) from err + raise ConfigEntryNotReady( + f"Could not connect to camera: {stringify_onvif_error(err)}" + ) from err + except ONVIFError as err: + await device.device.close() + raise ConfigEntryNotReady( + f"Could not setup camera {device.device.host}:{device.device.port}: {err}" + ) from err if not device.available: raise ConfigEntryNotReady() - if not entry.data.get(CONF_SNAPSHOT_AUTH): - await async_populate_snapshot_auth(hass, device, entry) - hass.data[DOMAIN][entry.unique_id] = device - platforms = [Platform.BUTTON, Platform.CAMERA] + device.platforms = [Platform.BUTTON, Platform.CAMERA] if device.capabilities.events: - platforms += [Platform.BINARY_SENSOR, Platform.SENSOR] + device.platforms += [Platform.BINARY_SENSOR, Platform.SENSOR] if device.capabilities.imaging: - platforms += [Platform.SWITCH] + device.platforms += [Platform.SWITCH] - await hass.config_entries.async_forward_entry_setups(entry, platforms) + await hass.config_entries.async_forward_entry_setups(entry, device.platforms) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.async_stop) @@ -59,16 +83,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - device = hass.data[DOMAIN][entry.unique_id] - platforms = ["camera"] + device: ONVIFDevice = hass.data[DOMAIN][entry.unique_id] if device.capabilities.events and device.events.started: - platforms += [Platform.BINARY_SENSOR, Platform.SENSOR] - await device.events.async_stop() - if device.capabilities.imaging: - platforms += [Platform.SWITCH] + try: + await device.events.async_stop() + except (ONVIFError, Fault, RequestError, TransportError): + LOGGER.warning("Error while stopping events: %s", device.name) - return await hass.config_entries.async_unload_platforms(entry, platforms) + return await hass.config_entries.async_unload_platforms(entry, device.platforms) async def _get_snapshot_auth(device): diff --git a/homeassistant/components/onvif/binary_sensor.py b/homeassistant/components/onvif/binary_sensor.py index 8f79b43296..3676e3b6c2 100644 --- a/homeassistant/components/onvif/binary_sensor.py +++ b/homeassistant/components/onvif/binary_sensor.py @@ -24,7 +24,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a ONVIF binary sensor.""" - device = hass.data[DOMAIN][config_entry.unique_id] + device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] entities = { event.uid: ONVIFBinarySensor(event.uid, device) @@ -39,16 +39,20 @@ async def async_setup_entry( ) async_add_entities(entities.values()) + uids_by_platform = device.events.get_uids_by_platform("binary_sensor") @callback - def async_check_entities(): + def async_check_entities() -> None: """Check if we have added an entity for the event.""" - new_entities = [] - for event in device.events.get_platform("binary_sensor"): - if event.uid not in entities: - entities[event.uid] = ONVIFBinarySensor(event.uid, device) - new_entities.append(entities[event.uid]) - async_add_entities(new_entities) + nonlocal uids_by_platform + if not (missing := uids_by_platform.difference(entities)): + return + new_entities: dict[str, ONVIFBinarySensor] = { + uid: ONVIFBinarySensor(uid, device) for uid in missing + } + if new_entities: + entities.update(new_entities) + async_add_entities(new_entities.values()) device.events.async_add_listener(async_check_entities) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 11699731b2..7a87ec66c8 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -1,6 +1,8 @@ """Support for ONVIF Cameras with FFmpeg as decoder.""" from __future__ import annotations +import asyncio + from haffmpeg.camera import CameraMjpeg from onvif.exceptions import ONVIFError import voluptuous as vol @@ -110,6 +112,7 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): == HTTP_BASIC_AUTHENTICATION ) self._stream_uri: str | None = None + self._stream_uri_future: asyncio.Future[str] | None = None @property def name(self) -> str: @@ -130,7 +133,7 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): async def stream_source(self): """Return the stream source.""" - return self._stream_uri + return await self._async_get_stream_uri() async def async_camera_image( self, width: int | None = None, height: int | None = None @@ -158,10 +161,10 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): self.device.name, ) - assert self._stream_uri + stream_uri = await self._async_get_stream_uri() return await ffmpeg.async_get_image( self.hass, - self._stream_uri, + stream_uri, extra_cmd=self.device.config_entry.options.get(CONF_EXTRA_ARGUMENTS), width=width, height=height, @@ -173,9 +176,10 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): ffmpeg_manager = get_ffmpeg_manager(self.hass) stream = CameraMjpeg(ffmpeg_manager.binary) + stream_uri = await self._async_get_stream_uri() await stream.open_camera( - self._stream_uri, + stream_uri, extra_cmd=self.device.config_entry.options.get(CONF_EXTRA_ARGUMENTS), ) @@ -190,13 +194,27 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): finally: await stream.close() - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - uri_no_auth = await self.device.async_get_stream_uri(self.profile) + async def _async_get_stream_uri(self) -> str: + """Return the stream URI.""" + if self._stream_uri: + return self._stream_uri + if self._stream_uri_future: + return await self._stream_uri_future + loop = asyncio.get_running_loop() + self._stream_uri_future = loop.create_future() + try: + uri_no_auth = await self.device.async_get_stream_uri(self.profile) + except (asyncio.TimeoutError, Exception) as err: + LOGGER.error("Failed to get stream uri: %s", err) + if self._stream_uri_future: + self._stream_uri_future.set_exception(err) + raise url = URL(uri_no_auth) url = url.with_user(self.device.username) url = url.with_password(self.device.password) self._stream_uri = str(url) + self._stream_uri_future.set_result(self._stream_uri) + return self._stream_uri async def async_perform_ptz( self, diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index d9cf28f3e8..181b3321c5 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -1,11 +1,11 @@ """Config flow for ONVIF.""" from __future__ import annotations +from collections.abc import Mapping from pprint import pformat from typing import Any from urllib.parse import urlparse -from onvif.exceptions import ONVIFError import voluptuous as vol from wsdiscovery.discovery import ThreadedWSDiscovery as WSDiscovery from wsdiscovery.scope import Scope @@ -13,6 +13,7 @@ from wsdiscovery.service import Service from zeep.exceptions import Fault from homeassistant import config_entries +from homeassistant.components import dhcp from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, @@ -27,9 +28,19 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers import device_registry as dr -from .const import CONF_DEVICE_ID, DEFAULT_ARGUMENTS, DEFAULT_PORT, DOMAIN, LOGGER +from .const import ( + CONF_DEVICE_ID, + DEFAULT_ARGUMENTS, + DEFAULT_PORT, + DOMAIN, + GET_CAPABILITIES_EXCEPTIONS, + LOGGER, +) from .device import get_device +from .util import is_auth_error, stringify_onvif_error CONF_MANUAL_INPUT = "Manually configure ONVIF device" @@ -74,6 +85,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a ONVIF config flow.""" VERSION = 1 + _reauth_entry: config_entries.ConfigEntry @staticmethod @callback @@ -101,6 +113,68 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required("auto", default=True): bool}), ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle re-authentication of an existing config entry.""" + reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + assert reauth_entry is not None + self._reauth_entry = reauth_entry + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth.""" + entry = self._reauth_entry + errors: dict[str, str] | None = {} + description_placeholders: dict[str, str] | None = None + if user_input is not None: + entry_data = entry.data + self.onvif_config = entry_data | user_input + errors, description_placeholders = await self.async_setup_profiles( + configure_unique_id=False + ) + if not errors: + hass = self.hass + entry_id = entry.entry_id + hass.config_entries.async_update_entry(entry, data=self.onvif_config) + hass.async_create_task(hass.config_entries.async_reload(entry_id)) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + description_placeholders=description_placeholders, + ) + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle dhcp discovery.""" + hass = self.hass + mac = discovery_info.macaddress + registry = dr.async_get(self.hass) + if not ( + device := registry.async_get_device( + identifiers=set(), connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + ): + return self.async_abort(reason="no_devices_found") + for entry_id in device.config_entries: + if ( + not (entry := hass.config_entries.async_get_entry(entry_id)) + or entry.domain != DOMAIN + or entry.state is config_entries.ConfigEntryState.LOADED + ): + continue + if hass.config_entries.async_update_entry( + entry, data=entry.data | {CONF_HOST: discovery_info.ip} + ): + hass.async_create_task(self.hass.config_entries.async_reload(entry_id)) + return self.async_abort(reason="already_configured") + async def async_step_device(self, user_input=None): """Handle WS-Discovery. @@ -148,15 +222,18 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_configure() - async def async_step_configure(self, user_input=None): + async def async_step_configure( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Device configuration.""" - errors = {} + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} if user_input: self.onvif_config = user_input - try: - return await self.async_setup_profiles() - except Fault: - errors["base"] = "cannot_connect" + errors, description_placeholders = await self.async_setup_profiles() + if not errors: + title = f"{self.onvif_config[CONF_NAME]} - {self.device_id}" + return self.async_create_entry(title=title, data=self.onvif_config) def conf(name, default=None): return self.onvif_config.get(name, default) @@ -177,9 +254,12 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ), errors=errors, + description_placeholders=description_placeholders, ) - async def async_setup_profiles(self): + async def async_setup_profiles( + self, configure_unique_id: bool = True + ) -> tuple[dict[str, str], dict[str, str]]: """Fetch ONVIF device profiles.""" LOGGER.debug( "Fetching profiles from ONVIF device %s", pformat(self.onvif_config) @@ -196,7 +276,6 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: await device.update_xaddrs() device_mgmt = device.create_devicemgmt_service() - # Get the MAC address to use as the unique ID for the config flow if not self.device_id: try: @@ -210,56 +289,66 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except Fault as fault: if "not implemented" not in fault.message: raise fault - LOGGER.debug( - ( - "Couldn't get network interfaces from ONVIF deivice '%s'." - " Error: %s" - ), + "%s: Could not get network interfaces: %s", self.onvif_config[CONF_NAME], - fault, + stringify_onvif_error(fault), ) - # If no network interfaces are exposed, fallback to serial number if not self.device_id: device_info = await device_mgmt.GetDeviceInformation() self.device_id = device_info.SerialNumber if not self.device_id: - return self.async_abort(reason="no_mac") - - await self.async_set_unique_id(self.device_id, raise_on_progress=False) - self._abort_if_unique_id_configured( - updates={ - CONF_HOST: self.onvif_config[CONF_HOST], - CONF_PORT: self.onvif_config[CONF_PORT], - CONF_NAME: self.onvif_config[CONF_NAME], - } - ) + raise AbortFlow(reason="no_mac") + if configure_unique_id: + await self.async_set_unique_id(self.device_id, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.onvif_config[CONF_HOST], + CONF_PORT: self.onvif_config[CONF_PORT], + CONF_NAME: self.onvif_config[CONF_NAME], + CONF_USERNAME: self.onvif_config[CONF_USERNAME], + CONF_PASSWORD: self.onvif_config[CONF_PASSWORD], + } + ) # Verify there is an H264 profile media_service = device.create_media_service() profiles = await media_service.GetProfiles() - h264 = any( + except Fault as err: + stringified_error = stringify_onvif_error(err) + description_placeholders = {"error": stringified_error} + if is_auth_error(err): + LOGGER.debug( + "%s: Could not authenticate with camera: %s", + self.onvif_config[CONF_NAME], + stringified_error, + ) + return {CONF_PASSWORD: "auth_failed"}, description_placeholders + LOGGER.debug( + "%s: Could not determine camera capabilities: %s", + self.onvif_config[CONF_NAME], + stringified_error, + exc_info=True, + ) + return {"base": "onvif_error"}, description_placeholders + except GET_CAPABILITIES_EXCEPTIONS as err: + LOGGER.debug( + "%s: Could not determine camera capabilities: %s", + self.onvif_config[CONF_NAME], + stringify_onvif_error(err), + exc_info=True, + ) + return {"base": "onvif_error"}, {"error": stringify_onvif_error(err)} + else: + if not any( profile.VideoEncoderConfiguration and profile.VideoEncoderConfiguration.Encoding == "H264" for profile in profiles - ) - - if not h264: - return self.async_abort(reason="no_h264") - - title = f"{self.onvif_config[CONF_NAME]} - {self.device_id}" - return self.async_create_entry(title=title, data=self.onvif_config) - - except ONVIFError as err: - LOGGER.error( - "Couldn't setup ONVIF device '%s'. Error: %s", - self.onvif_config[CONF_NAME], - err, - ) - return self.async_abort(reason="onvif_error") - + ): + raise AbortFlow(reason="no_h264") + return {}, {} finally: await device.close() diff --git a/homeassistant/components/onvif/const.py b/homeassistant/components/onvif/const.py index 410088f28d..bfe22eacbd 100644 --- a/homeassistant/components/onvif/const.py +++ b/homeassistant/components/onvif/const.py @@ -1,6 +1,10 @@ """Constants for the onvif component.""" import logging +from httpx import RequestError +from onvif.exceptions import ONVIFError +from zeep.exceptions import Fault, TransportError + LOGGER = logging.getLogger(__package__) DOMAIN = "onvif" @@ -36,3 +40,8 @@ GOTOPRESET_MOVE = "GotoPreset" STOP_MOVE = "Stop" SERVICE_PTZ = "ptz" + + +# Some cameras don't support the GetServiceCapabilities call +# and will return a 404 error which is caught by TransportError +GET_CAPABILITIES_EXCEPTIONS = (ONVIFError, Fault, RequestError, TransportError) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 1556ae8a1f..5747803116 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -20,6 +20,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_USERNAME, + Platform, ) from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util @@ -27,6 +28,7 @@ import homeassistant.util.dt as dt_util from .const import ( ABSOLUTE_MOVE, CONTINUOUS_MOVE, + GET_CAPABILITIES_EXCEPTIONS, GOTOPRESET_MOVE, LOGGER, PAN_FACTOR, @@ -55,6 +57,7 @@ class ONVIFDevice: self.capabilities: Capabilities = Capabilities() self.profiles: list[Profile] = [] self.max_resolution: int = 0 + self.platforms: list[Platform] = [] self._dt_diff_seconds: float = 0 @@ -83,7 +86,7 @@ class ONVIFDevice: """Return the password of this device.""" return self.config_entry.data[CONF_PASSWORD] - async def async_setup(self) -> bool: + async def async_setup(self) -> None: """Set up the device.""" self.device = get_device( self.hass, @@ -94,57 +97,34 @@ class ONVIFDevice: ) # Get all device info - try: - await self.device.update_xaddrs() - await self.async_check_date_and_time() + await self.device.update_xaddrs() + await self.async_check_date_and_time() - # Create event manager - assert self.config_entry.unique_id - self.events = EventManager( - self.hass, self.device, self.config_entry.unique_id - ) + # Create event manager + assert self.config_entry.unique_id + self.events = EventManager(self.hass, self.device, self.config_entry, self.name) - # Fetch basic device info and capabilities - self.info = await self.async_get_device_info() - LOGGER.debug("Camera %s info = %s", self.name, self.info) - self.capabilities = await self.async_get_capabilities() - LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities) - self.profiles = await self.async_get_profiles() - LOGGER.debug("Camera %s profiles = %s", self.name, self.profiles) + # Fetch basic device info and capabilities + self.info = await self.async_get_device_info() + LOGGER.debug("Camera %s info = %s", self.name, self.info) + self.capabilities = await self.async_get_capabilities() + LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities) + self.profiles = await self.async_get_profiles() + LOGGER.debug("Camera %s profiles = %s", self.name, self.profiles) - # No camera profiles to add - if not self.profiles: - return False + # No camera profiles to add + if not self.profiles: + raise ONVIFError("No camera profiles found") - if self.capabilities.ptz: - self.device.create_ptz_service() + if self.capabilities.ptz: + self.device.create_ptz_service() - # Determine max resolution from profiles - self.max_resolution = max( - profile.video.resolution.width - for profile in self.profiles - if profile.video.encoding == "H264" - ) - except RequestError as err: - LOGGER.warning( - "Couldn't connect to camera '%s', but will retry later. Error: %s", - self.name, - err, - ) - self.available = False - await self.device.close() - except Fault as err: - LOGGER.error( - ( - "Couldn't connect to camera '%s', please verify " - "that the credentials are correct. Error: %s" - ), - self.name, - err, - ) - return False - - return True + # Determine max resolution from profiles + self.max_resolution = max( + profile.video.resolution.width + for profile in self.profiles + if profile.video.encoding == "H264" + ) async def async_stop(self, event=None): """Shut it all down.""" @@ -168,22 +148,38 @@ class ONVIFDevice: dt_param.DaylightSavings = bool(time.localtime().tm_isdst) dt_param.UTCDateTime = device_time.UTCDateTime # Retrieve timezone from system - dt_param.TimeZone = str(system_date.astimezone().tzinfo) dt_param.UTCDateTime.Date.Year = system_date.year dt_param.UTCDateTime.Date.Month = system_date.month dt_param.UTCDateTime.Date.Day = system_date.day dt_param.UTCDateTime.Time.Hour = system_date.hour dt_param.UTCDateTime.Time.Minute = system_date.minute dt_param.UTCDateTime.Time.Second = system_date.second - LOGGER.debug("SetSystemDateAndTime: %s", dt_param) - await device_mgmt.SetSystemDateAndTime(dt_param) + system_timezone = str(system_date.astimezone().tzinfo) + timezone_names: list[str | None] = [system_timezone] + if (time_zone := device_time.TimeZone) and system_timezone != time_zone.TZ: + timezone_names.append(time_zone.TZ) + timezone_names.append(None) + timezone_max_idx = len(timezone_names) - 1 + LOGGER.debug( + "%s: SetSystemDateAndTime: timezone_names:%s", self.name, timezone_names + ) + for idx, timezone_name in enumerate(timezone_names): + dt_param.TimeZone = timezone_name + LOGGER.debug("%s: SetSystemDateAndTime: %s", self.name, dt_param) + try: + await device_mgmt.SetSystemDateAndTime(dt_param) + LOGGER.debug("%s: SetSystemDateAndTime: success", self.name) + return + except Fault: + if idx == timezone_max_idx: + raise async def async_check_date_and_time(self) -> None: """Warns if device and system date not synced.""" - LOGGER.debug("Setting up the ONVIF device management service") + LOGGER.debug("%s: Setting up the ONVIF device management service", self.name) device_mgmt = self.device.create_devicemgmt_service() - LOGGER.debug("Retrieving current device date/time") + LOGGER.debug("%s: Retrieving current device date/time", self.name) try: system_date = dt_util.utcnow() device_time = await device_mgmt.GetSystemDateAndTime() @@ -195,7 +191,7 @@ class ONVIFDevice: ) return - LOGGER.debug("Device time: %s", device_time) + LOGGER.debug("%s: Device time: %s", self.name, device_time) tzone = dt_util.DEFAULT_TIME_ZONE cdate = device_time.LocalDateTime @@ -206,7 +202,9 @@ class ONVIFDevice: tzone = dt_util.get_time_zone(device_time.TimeZone.TZ) or tzone if cdate is None: - LOGGER.warning("Could not retrieve date/time on this camera") + LOGGER.warning( + "%s: Could not retrieve date/time on this camera", self.name + ) else: cam_date = dt.datetime( cdate.Date.Year, @@ -222,7 +220,8 @@ class ONVIFDevice: cam_date_utc = cam_date.astimezone(dt_util.UTC) LOGGER.debug( - "Device date/time: %s | System date/time: %s", + "%s: Device date/time: %s | System date/time: %s", + self.name, cam_date_utc, system_date, ) @@ -230,7 +229,8 @@ class ONVIFDevice: dt_diff = cam_date - system_date self._dt_diff_seconds = dt_diff.total_seconds() - if self._dt_diff_seconds > 5: + # It could be off either direction, so we need to check the absolute value + if abs(self._dt_diff_seconds) > 5: LOGGER.warning( ( "The date/time on %s (UTC) is '%s', " @@ -282,26 +282,26 @@ class ONVIFDevice: async def async_get_capabilities(self): """Obtain information about the available services on the device.""" snapshot = False - with suppress(ONVIFError, Fault, RequestError): + with suppress(*GET_CAPABILITIES_EXCEPTIONS): media_service = self.device.create_media_service() media_capabilities = await media_service.GetServiceCapabilities() snapshot = media_capabilities and media_capabilities.SnapshotUri - pullpoint = False - with suppress(ONVIFError, Fault, RequestError, XMLParseError): - pullpoint = await self.events.async_start() - ptz = False - with suppress(ONVIFError, Fault, RequestError): + with suppress(*GET_CAPABILITIES_EXCEPTIONS): self.device.get_definition("ptz") ptz = True imaging = False - with suppress(ONVIFError, Fault, RequestError): + with suppress(*GET_CAPABILITIES_EXCEPTIONS): self.device.create_imaging_service() imaging = True - return Capabilities(snapshot, pullpoint, ptz, imaging) + events = False + with suppress(*GET_CAPABILITIES_EXCEPTIONS, XMLParseError): + events = await self.events.async_start() + + return Capabilities(snapshot, events, ptz, imaging) async def async_get_profiles(self) -> list[Profile]: """Obtain media profiles for this device.""" @@ -348,7 +348,7 @@ class ONVIFDevice: ptz_service = self.device.create_ptz_service() presets = await ptz_service.GetPresets(profile.token) profile.ptz.presets = [preset.token for preset in presets if preset] - except (Fault, RequestError): + except GET_CAPABILITIES_EXCEPTIONS: # It's OK if Presets aren't supported profile.ptz.presets = [] diff --git a/homeassistant/components/onvif/diagnostics.py b/homeassistant/components/onvif/diagnostics.py index eb818f53a3..d7f2c51530 100644 --- a/homeassistant/components/onvif/diagnostics.py +++ b/homeassistant/components/onvif/diagnostics.py @@ -28,5 +28,9 @@ async def async_get_config_entry_diagnostics( "capabilities": asdict(device.capabilities), "profiles": [asdict(profile) for profile in device.profiles], } + data["events"] = { + "webhook_manager_state": device.events.webhook_manager.state, + "pullpoint_manager_state": device.events.pullpoint_manager.state, + } return data diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 54c5b3b007..36e0bbbc66 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -5,58 +5,105 @@ import asyncio from collections.abc import Callable from contextlib import suppress import datetime as dt -from logging import DEBUG, WARNING -from httpx import RemoteProtocolError, TransportError +from aiohttp.web import Request +from httpx import RemoteProtocolError, RequestError, TransportError from onvif import ONVIFCamera, ONVIFService +from onvif.client import NotificationManager +from onvif.exceptions import ONVIFError from zeep.exceptions import Fault, XMLParseError -from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback -from homeassistant.helpers.event import async_call_later -from homeassistant.util import dt as dt_util - -from .const import LOGGER -from .models import Event -from .parsers import PARSERS - -UNHANDLED_TOPICS: set[str] = set() -SUBSCRIPTION_ERRORS = ( - Fault, - asyncio.TimeoutError, - TransportError, +from homeassistant.components import webhook +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import ( + CALLBACK_TYPE, + CoreState, + HassJob, + HomeAssistant, + callback, ) +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.network import NoURLAvailableError, get_url + +from .const import DOMAIN, LOGGER +from .models import Event, PullPointManagerState, WebHookManagerState +from .parsers import PARSERS +from .util import stringify_onvif_error + +# Topics in this list are ignored because we do not want to create +# entities for them. +UNHANDLED_TOPICS: set[str] = {"tns1:MediaControl/VideoEncoderConfiguration"} + +SUBSCRIPTION_ERRORS = (Fault, asyncio.TimeoutError, TransportError) +CREATE_ERRORS = (ONVIFError, Fault, RequestError, XMLParseError) +SET_SYNCHRONIZATION_POINT_ERRORS = (*SUBSCRIPTION_ERRORS, TypeError) +UNSUBSCRIBE_ERRORS = (XMLParseError, *SUBSCRIPTION_ERRORS) +RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS) +# +# We only keep the subscription alive for 3 minutes, and will keep +# renewing it every 1.5 minutes. This is to avoid the camera +# accumulating subscriptions which will be impossible to clean up +# since ONVIF does not provide a way to list existing subscriptions. +# +# If we max out the number of subscriptions, the camera will stop +# sending events to us, and we will not be able to recover until +# the subscriptions expire or the camera is rebooted. +# +SUBSCRIPTION_TIME = dt.timedelta(minutes=3) +SUBSCRIPTION_RELATIVE_TIME = ( + "PT3M" # use relative time since the time on the camera is not reliable +) +SUBSCRIPTION_RENEW_INTERVAL = SUBSCRIPTION_TIME.total_seconds() / 2 +SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR = 60.0 + +PULLPOINT_POLL_TIME = dt.timedelta(seconds=60) +PULLPOINT_MESSAGE_LIMIT = 100 +PULLPOINT_COOLDOWN_TIME = 0.75 class EventManager: """ONVIF Event Manager.""" def __init__( - self, hass: HomeAssistant, device: ONVIFCamera, unique_id: str + self, + hass: HomeAssistant, + device: ONVIFCamera, + config_entry: ConfigEntry, + name: str, ) -> None: """Initialize event manager.""" - self.hass: HomeAssistant = hass - self.device: ONVIFCamera = device - self.unique_id: str = unique_id - self.started: bool = False + self.hass = hass + self.device = device + self.config_entry = config_entry + self.unique_id = config_entry.unique_id + self.name = name - self._subscription: ONVIFService = None + self.webhook_manager = WebHookManager(self) + self.pullpoint_manager = PullPointManager(self) + + self._uid_by_platform: dict[str, set[str]] = {} self._events: dict[str, Event] = {} self._listeners: list[CALLBACK_TYPE] = [] - self._unsub_refresh: CALLBACK_TYPE | None = None - - super().__init__() @property - def platforms(self) -> set[str]: - """Return platforms to setup.""" - return {event.platform for event in self._events.values()} + def started(self) -> bool: + """Return True if event manager is started.""" + return ( + self.webhook_manager.state == WebHookManagerState.STARTED + or self.pullpoint_manager.state == PullPointManagerState.STARTED + ) + + @property + def has_listeners(self) -> bool: + """Return if there are listeners.""" + return bool(self._listeners) @callback def async_add_listener(self, update_callback: CALLBACK_TYPE) -> Callable[[], None]: """Listen for data updates.""" # This is the first listener, set up polling. if not self._listeners: - self.async_schedule_pull() + self.pullpoint_manager.async_schedule_pull_messages() self._listeners.append(update_callback) @@ -73,186 +120,743 @@ class EventManager: if update_callback in self._listeners: self._listeners.remove(update_callback) - if not self._listeners and self._unsub_refresh: - self._unsub_refresh() - self._unsub_refresh = None + if not self._listeners: + self.pullpoint_manager.async_cancel_pull_messages() async def async_start(self) -> bool: """Start polling events.""" - if await self.device.create_pullpoint_subscription(): - # Create subscription manager - self._subscription = self.device.create_subscription_service( - "PullPointSubscription" - ) - - # Renew immediately - await self.async_renew() - - # Initialize events - pullpoint = self.device.create_pullpoint_service() - with suppress(*SUBSCRIPTION_ERRORS): - await pullpoint.SetSynchronizationPoint() - response = await pullpoint.PullMessages( - {"MessageLimit": 100, "Timeout": dt.timedelta(seconds=5)} - ) - - # Parse event initialization - await self.async_parse_messages(response.NotificationMessage) - - self.started = True - return True - - return False + # Always start pull point first, since it will populate the event list + event_via_pull_point = await self.pullpoint_manager.async_start() + events_via_webhook = await self.webhook_manager.async_start() + return events_via_webhook or event_via_pull_point async def async_stop(self) -> None: """Unsubscribe from events.""" self._listeners = [] - self.started = False + await self.pullpoint_manager.async_stop() + await self.webhook_manager.async_stop() - if not self._subscription: - return - - await self._subscription.Unsubscribe() - self._subscription = None - - async def async_restart(self, _now: dt.datetime | None = None) -> None: - """Restart the subscription assuming the camera rebooted.""" - if not self.started: - return - - if self._subscription: - # Suppressed. The subscription may no longer exist. - try: - await self._subscription.Unsubscribe() - except (XMLParseError, *SUBSCRIPTION_ERRORS) as err: - LOGGER.debug( - ( - "Failed to unsubscribe ONVIF PullPoint subscription for '%s';" - " This is normal if the device restarted: %s" - ), - self.unique_id, - err, - ) - self._subscription = None - - try: - restarted = await self.async_start() - except (XMLParseError, *SUBSCRIPTION_ERRORS) as err: - restarted = False - # Device may not support subscriptions so log at debug level - # when we get an XMLParseError - LOGGER.log( - DEBUG if isinstance(err, XMLParseError) else WARNING, - ( - "Failed to restart ONVIF PullPoint subscription for '%s'; " - "Retrying later: %s" - ), - self.unique_id, - err, - ) - - if not restarted: - # Try again in a minute - self._unsub_refresh = async_call_later(self.hass, 60, self.async_restart) - elif self._listeners: - LOGGER.debug( - "Restarted ONVIF PullPoint subscription for '%s'", self.unique_id - ) - self.async_schedule_pull() - - async def async_renew(self) -> None: - """Renew subscription.""" - if not self._subscription: - return - - termination_time = ( - (dt_util.utcnow() + dt.timedelta(days=1)) - .isoformat(timespec="seconds") - .replace("+00:00", "Z") - ) - await self._subscription.Renew(termination_time) - - def async_schedule_pull(self) -> None: - """Schedule async_pull_messages to run.""" - self._unsub_refresh = async_call_later(self.hass, 1, self.async_pull_messages) - - async def async_pull_messages(self, _now: dt.datetime | None = None) -> None: - """Pull messages from device.""" - if self.hass.state == CoreState.running: - try: - pullpoint = self.device.create_pullpoint_service() - response = await pullpoint.PullMessages( - {"MessageLimit": 100, "Timeout": dt.timedelta(seconds=60)} - ) - - # Renew subscription if less than two hours is left - if ( - dt_util.as_utc(response.TerminationTime) - dt_util.utcnow() - ).total_seconds() < 7200: - await self.async_renew() - except RemoteProtocolError: - # Likely a shutdown event, nothing to see here - return - except (XMLParseError, *SUBSCRIPTION_ERRORS) as err: - # Device may not support subscriptions so log at debug level - # when we get an XMLParseError - LOGGER.log( - DEBUG if isinstance(err, XMLParseError) else WARNING, - ( - "Failed to fetch ONVIF PullPoint subscription messages for" - " '%s': %s" - ), - self.unique_id, - err, - ) - # Treat errors as if the camera restarted. Assume that the pullpoint - # subscription is no longer valid. - self._unsub_refresh = None - await self.async_restart() - return - - # Parse response - await self.async_parse_messages(response.NotificationMessage) - - # Update entities - for update_callback in self._listeners: - update_callback() - - # Reschedule another pull - if self._listeners: - self.async_schedule_pull() + @callback + def async_callback_listeners(self) -> None: + """Update listeners.""" + for update_callback in self._listeners: + update_callback() # pylint: disable=protected-access async def async_parse_messages(self, messages) -> None: """Parse notification message.""" + unique_id = self.unique_id + assert unique_id is not None for msg in messages: # Guard against empty message if not msg.Topic: continue - topic = msg.Topic._value_1 + # Topic may look like the following + # + # tns1:RuleEngine/CellMotionDetector/Motion//. + # tns1:RuleEngine/CellMotionDetector/Motion + # tns1:RuleEngine/CellMotionDetector/Motion/ + # + # Our parser expects the topic to be + # tns1:RuleEngine/CellMotionDetector/Motion + topic = msg.Topic._value_1.rstrip("/.") + if not (parser := PARSERS.get(topic)): if topic not in UNHANDLED_TOPICS: LOGGER.info( - "No registered handler for event from %s: %s", - self.unique_id, + "%s: No registered handler for event from %s: %s", + self.name, + unique_id, msg, ) UNHANDLED_TOPICS.add(topic) continue - event = await parser(self.unique_id, msg) + event = await parser(unique_id, msg) if not event: - LOGGER.info("Unable to parse event from %s: %s", self.unique_id, msg) + LOGGER.info( + "%s: Unable to parse event from %s: %s", self.name, unique_id, msg + ) return + self.get_uids_by_platform(event.platform).add(event.uid) self._events[event.uid] = event - def get_uid(self, uid) -> Event | None: + def get_uid(self, uid: str) -> Event | None: """Retrieve event for given id.""" return self._events.get(uid) def get_platform(self, platform) -> list[Event]: """Retrieve events for given platform.""" return [event for event in self._events.values() if event.platform == platform] + + def get_uids_by_platform(self, platform: str) -> set[str]: + """Retrieve uids for a given platform.""" + if (possible_uids := self._uid_by_platform.get(platform)) is None: + uids: set[str] = set() + self._uid_by_platform[platform] = uids + return uids + return possible_uids + + @callback + def async_webhook_failed(self) -> None: + """Mark webhook as failed.""" + if self.pullpoint_manager.state != PullPointManagerState.PAUSED: + return + LOGGER.debug("%s: Switching to PullPoint for events", self.name) + self.pullpoint_manager.async_resume() + + @callback + def async_webhook_working(self) -> None: + """Mark webhook as working.""" + if self.pullpoint_manager.state != PullPointManagerState.STARTED: + return + LOGGER.debug("%s: Switching to webhook for events", self.name) + self.pullpoint_manager.async_pause() + + @callback + def async_mark_events_stale(self) -> None: + """Mark all events as stale when the subscriptions fail since we are out of sync.""" + self._events.clear() + self.async_callback_listeners() + + +class PullPointManager: + """ONVIF PullPoint Manager. + + If the camera supports webhooks and the webhook is reachable, the pullpoint + manager will keep the pull point subscription alive, but will not poll for + messages unless the webhook fails. + """ + + def __init__(self, event_manager: EventManager) -> None: + """Initialize pullpoint manager.""" + self.state = PullPointManagerState.STOPPED + + self._event_manager = event_manager + self._device = event_manager.device + self._hass = event_manager.hass + self._name = event_manager.name + + self._pullpoint_subscription: ONVIFService = None + self._pullpoint_service: ONVIFService = None + self._pull_lock: asyncio.Lock = asyncio.Lock() + + self._cancel_pull_messages: CALLBACK_TYPE | None = None + self._cancel_pullpoint_renew: CALLBACK_TYPE | None = None + + self._renew_lock: asyncio.Lock = asyncio.Lock() + self._renew_or_restart_job = HassJob( + self._async_renew_or_restart_pullpoint, + f"{self._name}: renew or restart pullpoint", + ) + self._pull_messages_job = HassJob( + self._async_background_pull_messages, + f"{self._name}: pull messages", + ) + + async def async_start(self) -> bool: + """Start pullpoint subscription.""" + assert ( + self.state == PullPointManagerState.STOPPED + ), "PullPoint manager already started" + LOGGER.debug("%s: Starting PullPoint manager", self._name) + if not await self._async_start_pullpoint(): + self.state = PullPointManagerState.FAILED + return False + self.state = PullPointManagerState.STARTED + return True + + @callback + def async_pause(self) -> None: + """Pause pullpoint subscription.""" + LOGGER.debug("%s: Pausing PullPoint manager", self._name) + self.state = PullPointManagerState.PAUSED + self._hass.async_create_task(self._async_cancel_and_unsubscribe()) + + @callback + def async_resume(self) -> None: + """Resume pullpoint subscription.""" + LOGGER.debug("%s: Resuming PullPoint manager", self._name) + self.state = PullPointManagerState.STARTED + self.async_schedule_pullpoint_renew(0.0) + + @callback + def async_schedule_pullpoint_renew(self, delay: float) -> None: + """Schedule PullPoint subscription renewal.""" + self._async_cancel_pullpoint_renew() + self._cancel_pullpoint_renew = async_call_later( + self._hass, + delay, + self._renew_or_restart_job, + ) + + @callback + def async_cancel_pull_messages(self) -> None: + """Cancel the PullPoint task.""" + if self._cancel_pull_messages: + self._cancel_pull_messages() + self._cancel_pull_messages = None + + @callback + def async_schedule_pull_messages(self, delay: float | None = None) -> None: + """Schedule async_pull_messages to run. + + Used as fallback when webhook is not working. + + Must not check if the webhook is working. + """ + self.async_cancel_pull_messages() + if self.state != PullPointManagerState.STARTED: + return + if self._pullpoint_service: + when = delay if delay is not None else PULLPOINT_COOLDOWN_TIME + self._cancel_pull_messages = async_call_later( + self._hass, when, self._pull_messages_job + ) + + async def async_stop(self) -> None: + """Unsubscribe from PullPoint and cancel callbacks.""" + self.state = PullPointManagerState.STOPPED + await self._async_cancel_and_unsubscribe() + + async def _async_start_pullpoint(self) -> bool: + """Start pullpoint subscription.""" + try: + try: + started = await self._async_create_pullpoint_subscription() + except RequestError: + # + # We should only need to retry on RemoteProtocolError but some cameras + # are flaky and sometimes do not respond to the Renew request so we + # retry on RequestError as well. + # + # For RemoteProtocolError: + # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server + # to close the connection at any time, we treat this as a normal and try again + # once since we do not want to declare the camera as not supporting PullPoint + # if it just happened to close the connection at the wrong time. + started = await self._async_create_pullpoint_subscription() + except CREATE_ERRORS as err: + LOGGER.debug( + "%s: Device does not support PullPoint service or has too many subscriptions: %s", + self._name, + stringify_onvif_error(err), + ) + return False + + if started: + self.async_schedule_pullpoint_renew(SUBSCRIPTION_RENEW_INTERVAL) + + return started + + async def _async_cancel_and_unsubscribe(self) -> None: + """Cancel and unsubscribe from PullPoint.""" + self._async_cancel_pullpoint_renew() + self.async_cancel_pull_messages() + await self._async_unsubscribe_pullpoint() + + async def _async_renew_or_restart_pullpoint( + self, now: dt.datetime | None = None + ) -> None: + """Renew or start pullpoint subscription.""" + if self._hass.is_stopping or self.state != PullPointManagerState.STARTED: + return + if self._renew_lock.locked(): + LOGGER.debug("%s: PullPoint renew already in progress", self._name) + # Renew is already running, another one will be + # scheduled when the current one is done if needed. + return + async with self._renew_lock: + next_attempt = SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR + try: + if ( + await self._async_renew_pullpoint() + or await self._async_restart_pullpoint() + ): + next_attempt = SUBSCRIPTION_RENEW_INTERVAL + finally: + self.async_schedule_pullpoint_renew(next_attempt) + + async def _async_create_pullpoint_subscription(self) -> bool: + """Create pullpoint subscription.""" + + if not await self._device.create_pullpoint_subscription( + {"InitialTerminationTime": SUBSCRIPTION_RELATIVE_TIME} + ): + LOGGER.debug("%s: Failed to create PullPoint subscription", self._name) + return False + + # Create subscription manager + self._pullpoint_subscription = self._device.create_subscription_service( + "PullPointSubscription" + ) + + # Create the service that will be used to pull messages from the device. + self._pullpoint_service = self._device.create_pullpoint_service() + + # Initialize events + with suppress(*SET_SYNCHRONIZATION_POINT_ERRORS): + sync_result = await self._pullpoint_service.SetSynchronizationPoint() + LOGGER.debug("%s: SetSynchronizationPoint: %s", self._name, sync_result) + + # Always schedule an initial pull messages + self.async_schedule_pull_messages(0.0) + + return True + + @callback + def _async_cancel_pullpoint_renew(self) -> None: + """Cancel the pullpoint renew task.""" + if self._cancel_pullpoint_renew: + self._cancel_pullpoint_renew() + self._cancel_pullpoint_renew = None + + async def _async_restart_pullpoint(self) -> bool: + """Restart the subscription assuming the camera rebooted.""" + self.async_cancel_pull_messages() + await self._async_unsubscribe_pullpoint() + restarted = await self._async_start_pullpoint() + if restarted and self._event_manager.has_listeners: + LOGGER.debug("%s: Restarted PullPoint subscription", self._name) + self.async_schedule_pull_messages(0.0) + return restarted + + async def _async_unsubscribe_pullpoint(self) -> None: + """Unsubscribe the pullpoint subscription.""" + if ( + not self._pullpoint_subscription + or self._pullpoint_subscription.transport.client.is_closed + ): + return + LOGGER.debug("%s: Unsubscribing from PullPoint", self._name) + try: + await self._pullpoint_subscription.Unsubscribe() + except UNSUBSCRIBE_ERRORS as err: + LOGGER.debug( + ( + "%s: Failed to unsubscribe PullPoint subscription;" + " This is normal if the device restarted: %s" + ), + self._name, + stringify_onvif_error(err), + ) + self._pullpoint_subscription = None + + async def _async_renew_pullpoint(self) -> bool: + """Renew the PullPoint subscription.""" + if ( + not self._pullpoint_subscription + or self._pullpoint_subscription.transport.client.is_closed + ): + return False + try: + # The first time we renew, we may get a Fault error so we + # suppress it. The subscription will be restarted in + # async_restart later. + try: + await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + except RequestError: + # + # We should only need to retry on RemoteProtocolError but some cameras + # are flaky and sometimes do not respond to the Renew request so we + # retry on RequestError as well. + # + # For RemoteProtocolError: + # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server + # to close the connection at any time, we treat this as a normal and try again + # once since we do not want to mark events as stale + # if it just happened to close the connection at the wrong time. + await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + LOGGER.debug("%s: Renewed PullPoint subscription", self._name) + return True + except RENEW_ERRORS as err: + self._event_manager.async_mark_events_stale() + LOGGER.debug( + "%s: Failed to renew PullPoint subscription; %s", + self._name, + stringify_onvif_error(err), + ) + return False + + async def _async_pull_messages_with_lock(self) -> bool: + """Pull messages from device while holding the lock. + + This function must not be called directly, it should only + be called from _async_pull_messages. + + Returns True if the subscription is working. + + Returns False if the subscription is not working and should be restarted. + """ + assert self._pull_lock.locked(), "Pull lock must be held" + assert self._pullpoint_service is not None, "PullPoint service does not exist" + event_manager = self._event_manager + LOGGER.debug( + "%s: Pulling PullPoint messages timeout=%s limit=%s", + self._name, + PULLPOINT_POLL_TIME, + PULLPOINT_MESSAGE_LIMIT, + ) + try: + response = await self._pullpoint_service.PullMessages( + { + "MessageLimit": PULLPOINT_MESSAGE_LIMIT, + "Timeout": PULLPOINT_POLL_TIME, + } + ) + except RemoteProtocolError as err: + # Either a shutdown event or the camera closed the connection. Because + # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server + # to close the connection at any time, we treat this as a normal. Some + # cameras may close the connection if there are no messages to pull. + LOGGER.debug( + "%s: PullPoint subscription encountered a remote protocol error " + "(this is normal for some cameras): %s", + self._name, + stringify_onvif_error(err), + ) + return True + except (XMLParseError, *SUBSCRIPTION_ERRORS) as err: + # Device may not support subscriptions so log at debug level + # when we get an XMLParseError + LOGGER.debug( + "%s: Failed to fetch PullPoint subscription messages: %s", + self._name, + stringify_onvif_error(err), + ) + # Treat errors as if the camera restarted. Assume that the pullpoint + # subscription is no longer valid. + return False + + if self.state != PullPointManagerState.STARTED: + # If the webhook became started working during the long poll, + # and we got paused, our data is stale and we should not process it. + LOGGER.debug( + "%s: PullPoint is paused (likely due to working webhook), skipping PullPoint messages", + self._name, + ) + return True + + # Parse response + if (notification_message := response.NotificationMessage) and ( + number_of_events := len(notification_message) + ): + LOGGER.debug( + "%s: continuous PullMessages: %s event(s)", + self._name, + number_of_events, + ) + await event_manager.async_parse_messages(notification_message) + event_manager.async_callback_listeners() + else: + LOGGER.debug("%s: continuous PullMessages: no events", self._name) + + return True + + @callback + def _async_background_pull_messages(self, _now: dt.datetime | None = None) -> None: + """Pull messages from device in the background.""" + self._cancel_pull_messages = None + self._hass.async_create_background_task( + self._async_pull_messages(), + f"{self._name} background pull messages", + ) + + async def _async_pull_messages(self) -> None: + """Pull messages from device.""" + event_manager = self._event_manager + + if self._pull_lock.locked(): + # Pull messages if the lock is not already locked + # any pull will do, so we don't need to wait for the lock + LOGGER.debug( + "%s: PullPoint subscription is already locked, skipping pull", + self._name, + ) + return + + async with self._pull_lock: + # Before we pop out of the lock we always need to schedule the next pull + # or call async_schedule_pullpoint_renew if the pull fails so the pull + # loop continues. + try: + if self._hass.state == CoreState.running: + if not await self._async_pull_messages_with_lock(): + self.async_schedule_pullpoint_renew(0.0) + return + finally: + if event_manager.has_listeners: + self.async_schedule_pull_messages() + + +class WebHookManager: + """Manage ONVIF webhook subscriptions. + + If the camera supports webhooks, we will use that instead of + pullpoint subscriptions as soon as we detect that the camera + can reach our webhook. + """ + + def __init__(self, event_manager: EventManager) -> None: + """Initialize webhook manager.""" + self.state = WebHookManagerState.STOPPED + + self._event_manager = event_manager + self._device = event_manager.device + self._hass = event_manager.hass + self._webhook_unique_id = f"{DOMAIN}_{event_manager.config_entry.entry_id}" + self._name = event_manager.name + + self._webhook_url: str | None = None + + self._webhook_subscription: ONVIFService | None = None + self._notification_manager: NotificationManager | None = None + + self._cancel_webhook_renew: CALLBACK_TYPE | None = None + self._renew_lock = asyncio.Lock() + self._renew_or_restart_job = HassJob( + self._async_renew_or_restart_webhook, + f"{self._name}: renew or restart webhook", + ) + + async def async_start(self) -> bool: + """Start polling events.""" + LOGGER.debug("%s: Starting webhook manager", self._name) + assert ( + self.state == WebHookManagerState.STOPPED + ), "Webhook manager already started" + assert self._webhook_url is None, "Webhook already registered" + self._async_register_webhook() + if not await self._async_start_webhook(): + self.state = WebHookManagerState.FAILED + return False + self.state = WebHookManagerState.STARTED + return True + + async def async_stop(self) -> None: + """Unsubscribe from events.""" + self.state = WebHookManagerState.STOPPED + self._async_cancel_webhook_renew() + await self._async_unsubscribe_webhook() + self._async_unregister_webhook() + + @callback + def _async_schedule_webhook_renew(self, delay: float) -> None: + """Schedule webhook subscription renewal.""" + self._async_cancel_webhook_renew() + self._cancel_webhook_renew = async_call_later( + self._hass, + delay, + self._renew_or_restart_job, + ) + + async def _async_create_webhook_subscription(self) -> None: + """Create webhook subscription.""" + LOGGER.debug("%s: Creating webhook subscription", self._name) + self._notification_manager = self._device.create_notification_manager( + { + "InitialTerminationTime": SUBSCRIPTION_RELATIVE_TIME, + "ConsumerReference": {"Address": self._webhook_url}, + } + ) + self._webhook_subscription = await self._notification_manager.setup() + await self._notification_manager.start() + LOGGER.debug("%s: Webhook subscription created", self._name) + + async def _async_start_webhook(self) -> bool: + """Start webhook.""" + try: + try: + await self._async_create_webhook_subscription() + except RequestError: + # + # We should only need to retry on RemoteProtocolError but some cameras + # are flaky and sometimes do not respond to the Renew request so we + # retry on RequestError as well. + # + # For RemoteProtocolError: + # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server + # to close the connection at any time, we treat this as a normal and try again + # once since we do not want to declare the camera as not supporting webhooks + # if it just happened to close the connection at the wrong time. + await self._async_create_webhook_subscription() + except CREATE_ERRORS as err: + self._event_manager.async_webhook_failed() + LOGGER.debug( + "%s: Device does not support notification service or too many subscriptions: %s", + self._name, + stringify_onvif_error(err), + ) + return False + + self._async_schedule_webhook_renew(SUBSCRIPTION_RENEW_INTERVAL) + return True + + async def _async_restart_webhook(self) -> bool: + """Restart the webhook subscription assuming the camera rebooted.""" + await self._async_unsubscribe_webhook() + return await self._async_start_webhook() + + async def _async_renew_webhook(self) -> bool: + """Renew webhook subscription.""" + if ( + not self._webhook_subscription + or self._webhook_subscription.transport.client.is_closed + ): + return False + try: + try: + await self._webhook_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + except RequestError: + # + # We should only need to retry on RemoteProtocolError but some cameras + # are flaky and sometimes do not respond to the Renew request so we + # retry on RequestError as well. + # + # For RemoteProtocolError: + # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server + # to close the connection at any time, we treat this as a normal and try again + # once since we do not want to mark events as stale + # if it just happened to close the connection at the wrong time. + await self._webhook_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + LOGGER.debug("%s: Renewed Webhook subscription", self._name) + return True + except RENEW_ERRORS as err: + self._event_manager.async_mark_events_stale() + LOGGER.debug( + "%s: Failed to renew webhook subscription %s", + self._name, + stringify_onvif_error(err), + ) + return False + + async def _async_renew_or_restart_webhook( + self, now: dt.datetime | None = None + ) -> None: + """Renew or start webhook subscription.""" + if self._hass.is_stopping or self.state != WebHookManagerState.STARTED: + return + if self._renew_lock.locked(): + LOGGER.debug("%s: Webhook renew already in progress", self._name) + # Renew is already running, another one will be + # scheduled when the current one is done if needed. + return + async with self._renew_lock: + next_attempt = SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR + try: + if ( + await self._async_renew_webhook() + or await self._async_restart_webhook() + ): + next_attempt = SUBSCRIPTION_RENEW_INTERVAL + finally: + self._async_schedule_webhook_renew(next_attempt) + + @callback + def _async_register_webhook(self) -> None: + """Register the webhook for motion events.""" + LOGGER.debug("%s: Registering webhook: %s", self._name, self._webhook_unique_id) + + try: + base_url = get_url(self._hass, prefer_external=False) + except NoURLAvailableError: + try: + base_url = get_url(self._hass, prefer_external=True) + except NoURLAvailableError: + return + + webhook_id = self._webhook_unique_id + webhook.async_register( + self._hass, DOMAIN, webhook_id, webhook_id, self._async_handle_webhook + ) + webhook_path = webhook.async_generate_path(webhook_id) + self._webhook_url = f"{base_url}{webhook_path}" + LOGGER.debug("%s: Registered webhook: %s", self._name, webhook_id) + + @callback + def _async_unregister_webhook(self): + """Unregister the webhook for motion events.""" + LOGGER.debug( + "%s: Unregistering webhook %s", self._name, self._webhook_unique_id + ) + webhook.async_unregister(self._hass, self._webhook_unique_id) + self._webhook_url = None + + async def _async_handle_webhook( + self, hass: HomeAssistant, webhook_id: str, request: Request + ) -> None: + """Handle incoming webhook.""" + content: bytes | None = None + try: + content = await request.read() + except ConnectionResetError as ex: + LOGGER.error("Error reading webhook: %s", ex) + return + except asyncio.CancelledError as ex: + LOGGER.error("Error reading webhook: %s", ex) + raise + finally: + self._hass.async_create_background_task( + self._async_process_webhook(hass, webhook_id, content), + f"ONVIF event webhook for {self._name}", + ) + + async def _async_process_webhook( + self, hass: HomeAssistant, webhook_id: str, content: bytes | None + ) -> None: + """Process incoming webhook data in the background.""" + event_manager = self._event_manager + if content is None: + # webhook is marked as not working as something + # went wrong. We will mark it as working again + # when we receive a valid notification. + event_manager.async_webhook_failed() + return + if not self._notification_manager: + LOGGER.debug( + "%s: Received webhook before notification manager is setup", self._name + ) + return + if not (result := self._notification_manager.process(content)): + LOGGER.debug("%s: Failed to process webhook %s", self._name, webhook_id) + return + LOGGER.debug( + "%s: Processed webhook %s with %s event(s)", + self._name, + webhook_id, + len(result.NotificationMessage), + ) + event_manager.async_webhook_working() + await event_manager.async_parse_messages(result.NotificationMessage) + event_manager.async_callback_listeners() + + @callback + def _async_cancel_webhook_renew(self) -> None: + """Cancel the webhook renew task.""" + if self._cancel_webhook_renew: + self._cancel_webhook_renew() + self._cancel_webhook_renew = None + + async def _async_unsubscribe_webhook(self) -> None: + """Unsubscribe from the webhook.""" + if ( + not self._webhook_subscription + or self._webhook_subscription.transport.client.is_closed + ): + return + LOGGER.debug("%s: Unsubscribing from webhook", self._name) + try: + await self._webhook_subscription.Unsubscribe() + except UNSUBSCRIBE_ERRORS as err: + LOGGER.debug( + ( + "%s: Failed to unsubscribe webhook subscription;" + " This is normal if the device restarted: %s" + ), + self._name, + stringify_onvif_error(err), + ) + self._webhook_subscription = None diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index db9f76189d..41d5164452 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -4,8 +4,9 @@ "codeowners": ["@hunterjm"], "config_flow": true, "dependencies": ["ffmpeg"], + "dhcp": [{ "registered_devices": true }], "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==1.2.1", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==1.3.0", "WSDiscovery==2.0.0"] } diff --git a/homeassistant/components/onvif/models.py b/homeassistant/components/onvif/models.py index 9f0ca2da66..64edc85f3d 100644 --- a/homeassistant/components/onvif/models.py +++ b/homeassistant/components/onvif/models.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from enum import Enum from typing import Any from homeassistant.const import EntityCategory @@ -78,3 +79,20 @@ class Event: value: Any = None entity_category: EntityCategory | None = None entity_enabled: bool = True + + +class PullPointManagerState(Enum): + """States for the pullpoint manager.""" + + STOPPED = 0 # Not running or not supported + STARTED = 1 # Running and renewing + PAUSED = 2 # Switched to webhook, but can resume + FAILED = 3 # Failed to do initial subscription + + +class WebHookManagerState(Enum): + """States for the webhook manager.""" + + STOPPED = 0 + STARTED = 1 + FAILED = 2 # Failed to do initial subscription diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 08446d8fab..443254e125 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -301,6 +301,106 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: return None +@PARSERS.register("tns1:RuleEngine/MyRuleDetector/DogCatDetect") +# pylint: disable=protected-access +async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/MyRuleDetector/DogCatDetect + """ + try: + video_source = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "Source": + video_source = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}", + "Pet Detection", + "binary_sensor", + "motion", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:RuleEngine/MyRuleDetector/VehicleDetect") +# pylint: disable=protected-access +async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/MyRuleDetector/VehicleDetect + """ + try: + video_source = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "Source": + video_source = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}", + "Vehicle Detection", + "binary_sensor", + "motion", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:RuleEngine/MyRuleDetector/PeopleDetect") +# pylint: disable=protected-access +async def async_parse_person_detector(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/MyRuleDetector/PeopleDetect + """ + try: + video_source = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "Source": + video_source = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}", + "Person Detection", + "binary_sensor", + "motion", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:RuleEngine/MyRuleDetector/FaceDetect") +# pylint: disable=protected-access +async def async_parse_face_detector(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/MyRuleDetector/FaceDetect + """ + try: + video_source = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "Source": + video_source = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}", + "Face Detection", + "binary_sensor", + "motion", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + @PARSERS.register("tns1:Device/Trigger/DigitalInput") # pylint: disable=protected-access async def async_parse_digital_input(uid: str, msg) -> Event | None: @@ -511,3 +611,67 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: ) except (AttributeError, KeyError): return None + + +@PARSERS.register("tns1:RuleEngine/LineDetector/Crossed") +# pylint: disable=protected-access +async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/LineDetector/Crossed + """ + try: + video_source = "" + video_analytics = "" + rule = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = source.Value + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + "Line Detector Crossed", + "sensor", + None, + None, + msg.Message._value_1.Data.SimpleItem[0].Value, + EntityCategory.DIAGNOSTIC, + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:RuleEngine/CountAggregation/Counter") +# pylint: disable=protected-access +async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/CountAggregation/Counter + """ + try: + video_source = "" + video_analytics = "" + rule = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = source.Value + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + "Count Aggregation Counter", + "sensor", + None, + None, + msg.Message._value_1.Data.SimpleItem[0].Value, + EntityCategory.DIAGNOSTIC, + ) + except (AttributeError, KeyError): + return None diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py index 2fb7402be2..67da0ed979 100644 --- a/homeassistant/components/onvif/sensor.py +++ b/homeassistant/components/onvif/sensor.py @@ -23,7 +23,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a ONVIF binary sensor.""" - device = hass.data[DOMAIN][config_entry.unique_id] + device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] entities = { event.uid: ONVIFSensor(event.uid, device) @@ -36,16 +36,20 @@ async def async_setup_entry( entities[entry.unique_id] = ONVIFSensor(entry.unique_id, device, entry) async_add_entities(entities.values()) + uids_by_platform = device.events.get_uids_by_platform("sensor") @callback - def async_check_entities(): + def async_check_entities() -> None: """Check if we have added an entity for the event.""" - new_entities = [] - for event in device.events.get_platform("sensor"): - if event.uid not in entities: - entities[event.uid] = ONVIFSensor(event.uid, device) - new_entities.append(entities[event.uid]) - async_add_entities(new_entities) + nonlocal uids_by_platform + if not (missing := uids_by_platform.difference(entities)): + return + new_entities: dict[str, ONVIFSensor] = { + uid: ONVIFSensor(uid, device) for uid in missing + } + if new_entities: + entities.update(new_entities) + async_add_entities(new_entities.values()) device.events.async_add_listener(async_check_entities) @@ -84,6 +88,7 @@ class ONVIFSensor(ONVIFBaseEntity, RestoreSensor): @property def native_value(self) -> StateType | date | datetime | Decimal: """Return the value reported by the sensor.""" + assert self._attr_unique_id is not None if (event := self.device.events.get_uid(self._attr_unique_id)) is not None: return event.value return self._attr_native_value diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index 210027e96e..07f2e6fb7a 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -2,12 +2,15 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "onvif_error": "Error setting up ONVIF device. Check logs for more information.", "no_h264": "There were no H264 streams available. Check the profile configuration on your device.", - "no_mac": "Could not configure unique ID for ONVIF device." + "no_mac": "Could not configure unique ID for ONVIF device.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { + "onvif_error": "Error setting up ONVIF device: {error}. Check logs for more information.", + "auth_failed": "Could not authenticate: {error}", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { @@ -40,6 +43,13 @@ "data": { "include": "Create camera entity" } + }, + "reauth_confirm": { + "title": "Reauthenticate the ONVIF device", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } } }, diff --git a/homeassistant/components/onvif/switch.py b/homeassistant/components/onvif/switch.py index c72a826a79..4f7de67386 100644 --- a/homeassistant/components/onvif/switch.py +++ b/homeassistant/components/onvif/switch.py @@ -28,6 +28,7 @@ class ONVIFSwitchEntityDescriptionMixin: ] turn_on_data: Any turn_off_data: Any + supported_fn: Callable[[ONVIFDevice], bool] @dataclass @@ -46,6 +47,7 @@ SWITCHES: tuple[ONVIFSwitchEntityDescription, ...] = ( turn_off_data={"Focus": {"AutoFocusMode": "MANUAL"}}, turn_on_fn=lambda device: device.async_set_imaging_settings, turn_off_fn=lambda device: device.async_set_imaging_settings, + supported_fn=lambda device: device.capabilities.imaging, ), ONVIFSwitchEntityDescription( key="ir_lamp", @@ -55,6 +57,7 @@ SWITCHES: tuple[ONVIFSwitchEntityDescription, ...] = ( turn_off_data={"IrCutFilter": "ON"}, turn_on_fn=lambda device: device.async_set_imaging_settings, turn_off_fn=lambda device: device.async_set_imaging_settings, + supported_fn=lambda device: device.capabilities.imaging, ), ONVIFSwitchEntityDescription( key="wiper", @@ -64,6 +67,7 @@ SWITCHES: tuple[ONVIFSwitchEntityDescription, ...] = ( turn_off_data="tt:Wiper|Off", turn_on_fn=lambda device: device.async_run_aux_command, turn_off_fn=lambda device: device.async_run_aux_command, + supported_fn=lambda device: device.capabilities.ptz, ), ) @@ -76,7 +80,11 @@ async def async_setup_entry( """Set up a ONVIF switch platform.""" device = hass.data[DOMAIN][config_entry.unique_id] - async_add_entities(ONVIFSwitch(device, description) for description in SWITCHES) + async_add_entities( + ONVIFSwitch(device, description) + for description in SWITCHES + if description.supported_fn(device) + ) class ONVIFSwitch(ONVIFBaseEntity, SwitchEntity): diff --git a/homeassistant/components/onvif/util.py b/homeassistant/components/onvif/util.py new file mode 100644 index 0000000000..6f03af3629 --- /dev/null +++ b/homeassistant/components/onvif/util.py @@ -0,0 +1,49 @@ +"""ONVIF util.""" +from __future__ import annotations + +from typing import Any + +from zeep.exceptions import Fault + + +def extract_subcodes_as_strings(subcodes: Any) -> list[str]: + """Stringify ONVIF subcodes.""" + if isinstance(subcodes, list): + return [code.text if hasattr(code, "text") else str(code) for code in subcodes] + return [str(subcodes)] + + +def stringify_onvif_error(error: Exception) -> str: + """Stringify ONVIF error.""" + if isinstance(error, Fault): + message = error.message + if error.detail: + message += ": " + error.detail + if error.code: + message += f" (code:{error.code})" + if error.subcodes: + message += ( + f" (subcodes:{','.join(extract_subcodes_as_strings(error.subcodes))})" + ) + if error.actor: + message += f" (actor:{error.actor})" + else: + message = str(error) + return message or "Device sent empty error" + + +def is_auth_error(error: Exception) -> bool: + """Return True if error is an authentication error. + + Most of the tested cameras do not return a proper error code when + authentication fails, so we need to check the error message as well. + """ + if not isinstance(error, Fault): + return False + return ( + any( + "NotAuthorized" in code + for code in extract_subcodes_as_strings(error.subcodes) + ) + or "auth" in stringify_onvif_error(error).lower() + ) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 355b7764b0..c1b569ce9e 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -3,26 +3,27 @@ from __future__ import annotations from functools import partial import logging +from typing import Literal import openai from openai import error from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, TemplateError -from homeassistant.helpers import area_registry as ar, intent, template +from homeassistant.helpers import intent, template from homeassistant.util import ulid from .const import ( + CONF_CHAT_MODEL, CONF_MAX_TOKENS, - CONF_MODEL, CONF_PROMPT, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_CHAT_MODEL, DEFAULT_MAX_TOKENS, - DEFAULT_MODEL, DEFAULT_PROMPT, DEFAULT_TEMPERATURE, DEFAULT_TOP_P, @@ -63,26 +64,31 @@ class OpenAIAgent(conversation.AbstractConversationAgent): """Initialize the agent.""" self.hass = hass self.entry = entry - self.history: dict[str, str] = {} + self.history: dict[str, list[dict]] = {} @property def attribution(self): """Return the attribution.""" return {"name": "Powered by OpenAI", "url": "https://www.openai.com"} + @property + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + async def async_process( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" raw_prompt = self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT) - model = self.entry.options.get(CONF_MODEL, DEFAULT_MODEL) + model = self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) max_tokens = self.entry.options.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS) top_p = self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P) temperature = self.entry.options.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) if user_input.conversation_id in self.history: conversation_id = user_input.conversation_id - prompt = self.history[conversation_id] + messages = self.history[conversation_id] else: conversation_id = ulid.ulid() try: @@ -97,25 +103,16 @@ class OpenAIAgent(conversation.AbstractConversationAgent): return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id ) + messages = [{"role": "system", "content": prompt}] - user_name = "User" - if ( - user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) - ) - and user.name - ): - user_name = user.name + messages.append({"role": "user", "content": user_input.text}) - prompt += f"\n{user_name}: {user_input.text}\nSmart home: " - - _LOGGER.debug("Prompt for %s: %s", model, prompt) + _LOGGER.debug("Prompt for %s: %s", model, messages) try: - result = await openai.Completion.acreate( - engine=model, - prompt=prompt, + result = await openai.ChatCompletion.acreate( + model=model, + messages=messages, max_tokens=max_tokens, top_p=top_p, temperature=temperature, @@ -132,15 +129,12 @@ class OpenAIAgent(conversation.AbstractConversationAgent): ) _LOGGER.debug("Response %s", result) - response = result["choices"][0]["text"].strip() - self.history[conversation_id] = prompt + response - - stripped_response = response - if response.startswith("Smart home:"): - stripped_response = response[11:].strip() + response = result["choices"][0]["message"] + messages.append(response) + self.history[conversation_id] = messages intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(stripped_response) + intent_response.async_set_speech(response["content"]) return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id ) @@ -150,7 +144,6 @@ class OpenAIAgent(conversation.AbstractConversationAgent): return template.Template(raw_prompt, self.hass).async_render( { "ha_name": self.hass.config.location_name, - "areas": list(ar.async_get(self.hass).areas.values()), }, parse_result=False, ) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 2db5e98a1f..b391f531eb 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -22,13 +22,13 @@ from homeassistant.helpers.selector import ( ) from .const import ( + CONF_CHAT_MODEL, CONF_MAX_TOKENS, - CONF_MODEL, CONF_PROMPT, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_CHAT_MODEL, DEFAULT_MAX_TOKENS, - DEFAULT_MODEL, DEFAULT_PROMPT, DEFAULT_TEMPERATURE, DEFAULT_TOP_P, @@ -46,7 +46,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( DEFAULT_OPTIONS = types.MappingProxyType( { CONF_PROMPT: DEFAULT_PROMPT, - CONF_MODEL: DEFAULT_MODEL, + CONF_CHAT_MODEL: DEFAULT_CHAT_MODEL, CONF_MAX_TOKENS: DEFAULT_MAX_TOKENS, CONF_TOP_P: DEFAULT_TOP_P, CONF_TEMPERATURE: DEFAULT_TEMPERATURE, @@ -72,9 +72,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - if user_input is None: return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA @@ -131,13 +128,32 @@ def openai_config_option_schema(options: MappingProxyType[str, Any]) -> dict: if not options: options = DEFAULT_OPTIONS return { - vol.Required(CONF_PROMPT, default=options.get(CONF_PROMPT)): TemplateSelector(), - vol.Required(CONF_MODEL, default=options.get(CONF_MODEL)): str, - vol.Required(CONF_MAX_TOKENS, default=options.get(CONF_MAX_TOKENS)): int, - vol.Required(CONF_TOP_P, default=options.get(CONF_TOP_P)): NumberSelector( - NumberSelectorConfig(min=0, max=1, step=0.05) - ), - vol.Required( - CONF_TEMPERATURE, default=options.get(CONF_TEMPERATURE) + vol.Optional( + CONF_PROMPT, + description={"suggested_value": options[CONF_PROMPT]}, + default=DEFAULT_PROMPT, + ): TemplateSelector(), + vol.Optional( + CONF_CHAT_MODEL, + description={ + # New key in HA 2023.4 + "suggested_value": options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) + }, + default=DEFAULT_CHAT_MODEL, + ): str, + vol.Optional( + CONF_MAX_TOKENS, + description={"suggested_value": options[CONF_MAX_TOKENS]}, + default=DEFAULT_MAX_TOKENS, + ): int, + vol.Optional( + CONF_TOP_P, + description={"suggested_value": options[CONF_TOP_P]}, + default=DEFAULT_TOP_P, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TEMPERATURE, + description={"suggested_value": options[CONF_TEMPERATURE]}, + default=DEFAULT_TEMPERATURE, ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), } diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index ed914efeb6..46f8603c5f 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -5,13 +5,13 @@ CONF_PROMPT = "prompt" DEFAULT_PROMPT = """This smart home is controlled by Home Assistant. An overview of the areas and the devices in this smart home: -{%- for area in areas %} +{%- for area in areas() %} {%- set area_info = namespace(printed=false) %} - {%- for device in area_devices(area.name) -%} + {%- for device in area_devices(area) -%} {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} {%- if not area_info.printed %} -{{ area.name }}: +{{ area_name(area) }}: {%- set area_info.printed = true %} {%- endif %} - {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} @@ -22,13 +22,9 @@ An overview of the areas and the devices in this smart home: Answer the user's questions about the world truthfully. If the user wants to control a device, reject the request and suggest using the Home Assistant app. - -Now finish this conversation: - -Smart home: How can I assist? """ -CONF_MODEL = "model" -DEFAULT_MODEL = "text-davinci-003" +CONF_CHAT_MODEL = "chat_model" +DEFAULT_CHAT_MODEL = "gpt-3.5-turbo" CONF_MAX_TOKENS = "max_tokens" DEFAULT_MAX_TOKENS = 150 CONF_TOP_P = "top_p" diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 0e245eb78b..88d347355e 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==0.26.2"] + "requirements": ["openai==0.27.2"] } diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index f7af4618a9..9583e759bd 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -11,9 +11,6 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, "options": { diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 68357c862c..b625d9976d 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -347,7 +347,9 @@ class OpenhomeDevice(MediaPlayerEntity): await self._device.set_mute(mute) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py index 5999eb9158..0e918103cd 100644 --- a/homeassistant/components/opensensemap/air_quality.py +++ b/homeassistant/components/opensensemap/air_quality.py @@ -20,7 +20,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by openSenseMap" CONF_STATION_ID = "station_id" @@ -59,6 +58,8 @@ async def async_setup_platform( class OpenSenseMapQuality(AirQualityEntity): """Implementation of an openSenseMap air quality entity.""" + _attr_attribution = "Data provided by openSenseMap" + def __init__(self, name, osm): """Initialize the air quality entity.""" self._name = name @@ -79,11 +80,6 @@ class OpenSenseMapQuality(AirQualityEntity): """Return the particulate matter 10 level.""" return self._osm.api.pm10 - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - async def async_update(self): """Get the latest data from the openSenseMap API.""" await self._osm.async_update() diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index aebf1e26c3..3efe911b27 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -142,7 +142,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def register_services(hass): +def register_services(hass: HomeAssistant) -> None: """Register services for the component.""" service_reset_schema = vol.Schema( { @@ -260,9 +260,7 @@ def register_services(hass): """Reset the OpenTherm Gateway.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] mode_rst = gw_vars.OTGW_MODE_RESET - status = await gw_dev.gateway.set_mode(mode_rst) - gw_dev.status = status - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_mode(mode_rst) hass.services.async_register( DOMAIN, SERVICE_RESET_GATEWAY, reset_gateway, service_reset_schema @@ -283,10 +281,7 @@ def register_services(hass): async def set_control_setpoint(call: ServiceCall) -> None: """Set the control setpoint on the OpenTherm Gateway.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] - gw_var = gw_vars.DATA_CONTROL_SETPOINT - value = await gw_dev.gateway.set_control_setpoint(call.data[ATTR_TEMPERATURE]) - gw_dev.status.update({gw_var: value}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_control_setpoint(call.data[ATTR_TEMPERATURE]) hass.services.async_register( DOMAIN, @@ -298,10 +293,7 @@ def register_services(hass): async def set_dhw_ovrd(call: ServiceCall) -> None: """Set the domestic hot water override on the OpenTherm Gateway.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] - gw_var = gw_vars.OTGW_DHW_OVRD - value = await gw_dev.gateway.set_hot_water_ovrd(call.data[ATTR_DHW_OVRD]) - gw_dev.status.update({gw_var: value}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_hot_water_ovrd(call.data[ATTR_DHW_OVRD]) hass.services.async_register( DOMAIN, @@ -313,10 +305,7 @@ def register_services(hass): async def set_dhw_setpoint(call: ServiceCall) -> None: """Set the domestic hot water setpoint on the OpenTherm Gateway.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] - gw_var = gw_vars.DATA_DHW_SETPOINT - value = await gw_dev.gateway.set_dhw_setpoint(call.data[ATTR_TEMPERATURE]) - gw_dev.status.update({gw_var: value}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_dhw_setpoint(call.data[ATTR_TEMPERATURE]) hass.services.async_register( DOMAIN, @@ -341,10 +330,7 @@ def register_services(hass): gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] gpio_id = call.data[ATTR_ID] gpio_mode = call.data[ATTR_MODE] - mode = await gw_dev.gateway.set_gpio_mode(gpio_id, gpio_mode) - gpio_var = getattr(gw_vars, f"OTGW_GPIO_{gpio_id}") - gw_dev.status.update({gpio_var: mode}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_gpio_mode(gpio_id, gpio_mode) hass.services.async_register( DOMAIN, SERVICE_SET_GPIO_MODE, set_gpio_mode, service_set_gpio_mode_schema @@ -355,10 +341,7 @@ def register_services(hass): gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] led_id = call.data[ATTR_ID] led_mode = call.data[ATTR_MODE] - mode = await gw_dev.gateway.set_led_mode(led_id, led_mode) - led_var = getattr(gw_vars, f"OTGW_LED_{led_id}") - gw_dev.status.update({led_var: mode}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_led_mode(led_id, led_mode) hass.services.async_register( DOMAIN, SERVICE_SET_LED_MODE, set_led_mode, service_set_led_mode_schema @@ -367,14 +350,11 @@ def register_services(hass): async def set_max_mod(call: ServiceCall) -> None: """Set the max modulation level.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] - gw_var = gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD level = call.data[ATTR_LEVEL] if level == -1: # Backend only clears setting on non-numeric values. level = "-" - value = await gw_dev.gateway.set_max_relative_mod(level) - gw_dev.status.update({gw_var: value}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_max_relative_mod(level) hass.services.async_register( DOMAIN, SERVICE_SET_MAX_MOD, set_max_mod, service_set_max_mod_schema @@ -383,10 +363,7 @@ def register_services(hass): async def set_outside_temp(call: ServiceCall) -> None: """Provide the outside temperature to the OpenTherm Gateway.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] - gw_var = gw_vars.DATA_OUTSIDE_TEMP - value = await gw_dev.gateway.set_outside_temp(call.data[ATTR_TEMPERATURE]) - gw_dev.status.update({gw_var: value}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_outside_temp(call.data[ATTR_TEMPERATURE]) hass.services.async_register( DOMAIN, SERVICE_SET_OAT, set_outside_temp, service_set_oat_schema @@ -395,10 +372,7 @@ def register_services(hass): async def set_setback_temp(call: ServiceCall) -> None: """Set the OpenTherm Gateway SetBack temperature.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] - gw_var = gw_vars.OTGW_SB_TEMP - value = await gw_dev.gateway.set_setback_temp(call.data[ATTR_TEMPERATURE]) - gw_dev.status.update({gw_var: value}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_setback_temp(call.data[ATTR_TEMPERATURE]) hass.services.async_register( DOMAIN, SERVICE_SET_SB_TEMP, set_setback_temp, service_set_sb_temp_schema @@ -416,7 +390,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class OpenThermGatewayDevice: """OpenTherm Gateway device class.""" - def __init__(self, hass, config_entry): + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the OpenTherm Gateway.""" self.hass = hass self.device_path = config_entry.data[CONF_DEVICE] @@ -424,19 +398,19 @@ class OpenThermGatewayDevice: self.name = config_entry.data[CONF_NAME] self.climate_config = config_entry.options self.config_entry_id = config_entry.entry_id - self.status = {} + self.status = gw_vars.DEFAULT_STATUS self.update_signal = f"{DATA_OPENTHERM_GW}_{self.gw_id}_update" self.options_update_signal = f"{DATA_OPENTHERM_GW}_{self.gw_id}_options_update" self.gateway = pyotgw.OpenThermGateway() self.gw_version = None - async def cleanup(self, event=None): + async def cleanup(self, event=None) -> None: """Reset overrides on the gateway.""" await self.gateway.set_control_setpoint(0) await self.gateway.set_max_relative_mod("-") await self.gateway.disconnect() - async def connect_and_subscribe(self): + async def connect_and_subscribe(self) -> None: """Connect to serial device and subscribe report handler.""" self.status = await self.gateway.connect(self.device_path) if not self.status: diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json index a1071cc0a1..adf72f5fe5 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -11,6 +11,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/oralb", "iot_class": "local_push", - "loggers": ["oralb-ble"], + "loggers": ["oralb_ble"], "requirements": ["oralb-ble==0.17.6"] } diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 602c76f77e..5942d67b50 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -2,91 +2,20 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine -import dataclasses -from functools import wraps -from typing import Any, Concatenate, ParamSpec, TypeVar import aiohttp import python_otbr_api -from python_otbr_api import tlv_parser -from python_otbr_api.pskc import compute_pskc from homeassistant.components.thread import async_add_dataset from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from . import websocket_api from .const import DOMAIN - -_R = TypeVar("_R") -_P = ParamSpec("_P") - -INSECURE_NETWORK_KEYS = ( - # Thread web UI default - bytes.fromhex("00112233445566778899AABBCCDDEEFF"), -) - -INSECURE_PASSPHRASES = ( - # Thread web UI default - "j01Nme", - # Thread documentation default - "J01NME", -) - - -def _handle_otbr_error( - func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]] -) -> Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]]: - """Handle OTBR errors.""" - - @wraps(func) - async def _func(self: OTBRData, *args: _P.args, **kwargs: _P.kwargs) -> _R: - try: - return await func(self, *args, **kwargs) - except python_otbr_api.OTBRError as exc: - raise HomeAssistantError("Failed to call OTBR API") from exc - - return _func - - -@dataclasses.dataclass -class OTBRData: - """Container for OTBR data.""" - - url: str - api: python_otbr_api.OTBR - - @_handle_otbr_error - 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_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 create_active_dataset( - self, dataset: python_otbr_api.OperationalDataSet - ) -> None: - """Create an active operational dataset.""" - return await self.api.create_active_dataset(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 get_extended_address(self) -> bytes: - """Get extended address (EUI-64).""" - return await self.api.get_extended_address() +from .util import OTBRData, update_issues async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -95,54 +24,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def _warn_on_default_network_settings( - hass: HomeAssistant, entry: ConfigEntry, dataset_tlvs: bytes -) -> None: - """Warn user if insecure default network settings are used.""" - dataset = tlv_parser.parse_tlv(dataset_tlvs.hex()) - insecure = False - - if ( - network_key := dataset.get(tlv_parser.MeshcopTLVType.NETWORKKEY) - ) is not None and bytes.fromhex(network_key) 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 - ): - ext_pan_id = dataset[tlv_parser.MeshcopTLVType.EXTPANID] - network_name = dataset[tlv_parser.MeshcopTLVType.NETWORKNAME] - pskc = bytes.fromhex(dataset[tlv_parser.MeshcopTLVType.PSKC]) - for passphrase in INSECURE_PASSPHRASES: - if pskc == compute_pskc(ext_pan_id, network_name, passphrase): - insecure = True - break - - if insecure: - ir.async_create_issue( - hass, - DOMAIN, - f"insecure_thread_network_{entry.entry_id}", - is_fixable=False, - is_persistent=False, - severity=ir.IssueSeverity.WARNING, - translation_key="insecure_thread_network", - ) - else: - ir.async_delete_issue( - hass, - DOMAIN, - f"insecure_thread_network_{entry.entry_id}", - ) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an Open Thread Border Router config entry.""" api = python_otbr_api.OTBR(entry.data["url"], async_get_clientsession(hass), 10) - otbrdata = OTBRData(entry.data["url"], api) + otbrdata = OTBRData(entry.data["url"], api, entry.entry_id) try: dataset_tlvs = await otbrdata.get_active_dataset_tlvs() except ( @@ -152,9 +38,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) as err: raise ConfigEntryNotReady("Unable to connect") from err if dataset_tlvs: - _warn_on_default_network_settings(hass, entry, dataset_tlvs) + await update_issues(hass, otbrdata, dataset_tlvs) await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + hass.data[DOMAIN] = otbrdata return True @@ -166,6 +54,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_get_active_dataset_tlvs(hass: HomeAssistant) -> bytes | None: """Get current active operational dataset in TLVS format, or None. diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 0e9c8e9606..32842ad6cc 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -8,15 +8,17 @@ import aiohttp import python_otbr_api from python_otbr_api import tlv_parser import voluptuous as vol +import yarl from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.thread import async_get_preferred_dataset -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow from homeassistant.const import CONF_URL from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_CHANNEL, DOMAIN +from .util import get_allowed_channel _LOGGER = logging.getLogger(__name__) @@ -26,13 +28,12 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _connect_and_create_dataset(self, url: str) -> None: - """Connect to the OTBR and create a dataset if it doesn't have one.""" - api = python_otbr_api.OTBR(url, async_get_clientsession(self.hass), 10) + async def _connect_and_set_dataset(self, otbr_url: str) -> None: + """Connect to the OTBR and create or apply a dataset if it doesn't have one.""" + api = python_otbr_api.OTBR(otbr_url, async_get_clientsession(self.hass), 10) if await api.get_active_dataset_tlvs() is None: - # We currently have no way to know which channel zha is using, assume it's - # the default - zha_channel = DEFAULT_CHANNEL + allowed_channel = await get_allowed_channel(self.hass, otbr_url) + thread_dataset_channel = None thread_dataset_tlv = await async_get_preferred_dataset(self.hass) if thread_dataset_tlv: @@ -40,7 +41,9 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): thread_dataset_channel = int(channel_str, base=16) - if thread_dataset_tlv is not None and zha_channel == thread_dataset_channel: + if thread_dataset_tlv is not None and ( + not allowed_channel or allowed_channel == thread_dataset_channel + ): await api.set_active_dataset_tlvs(bytes.fromhex(thread_dataset_tlv)) else: _LOGGER.debug( @@ -48,7 +51,8 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): ) await api.create_active_dataset( python_otbr_api.OperationalDataSet( - channel=zha_channel, network_name="home-assistant" + channel=allowed_channel if allowed_channel else DEFAULT_CHANNEL, + network_name="home-assistant", ) ) await api.set_enabled(True) @@ -65,7 +69,7 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: url = user_input[CONF_URL] try: - await self._connect_and_create_dataset(url) + await self._connect_and_set_dataset(url) except ( python_otbr_api.OTBRError, aiohttp.ClientError, @@ -86,20 +90,38 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Handle hassio discovery.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - config = discovery_info.config url = f"http://{config['host']}:{config['port']}" + config_entry_data = {"url": url} + + if current_entries := self._async_current_entries(): + for current_entry in current_entries: + if current_entry.source != SOURCE_HASSIO: + continue + if current_entry.unique_id != discovery_info.uuid: + self.hass.config_entries.async_update_entry( + current_entry, unique_id=discovery_info.uuid + ) + current_url = yarl.URL(current_entry.data["url"]) + if ( + current_url.host != config["host"] + or current_url.port == config["port"] + ): + continue + # Update URL with the new port + self.hass.config_entries.async_update_entry( + current_entry, data=config_entry_data + ) + return self.async_abort(reason="single_instance_allowed") try: - await self._connect_and_create_dataset(url) + await self._connect_and_set_dataset(url) except python_otbr_api.OTBRError as exc: _LOGGER.warning("Failed to communicate with OTBR@%s: %s", url, exc) return self.async_abort(reason="unknown") - await self.async_set_unique_id(DOMAIN) + await self.async_set_unique_id(discovery_info.uuid) return self.async_create_entry( title="Open Thread Border Router", - data={"url": url}, + data=config_entry_data, ) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 2590e92210..c10a2417dc 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -1,10 +1,10 @@ { "domain": "otbr", "name": "Open Thread Border Router", - "after_dependencies": ["hassio"], + "after_dependencies": ["hassio", "homeassistant_yellow", "zha"], "codeowners": ["@home-assistant/core"], "config_flow": true, - "dependencies": ["thread"], + "dependencies": ["homeassistant_hardware", "thread"], "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", diff --git a/homeassistant/components/otbr/strings.json b/homeassistant/components/otbr/strings.json index f2efea0c1e..129cbec446 100644 --- a/homeassistant/components/otbr/strings.json +++ b/homeassistant/components/otbr/strings.json @@ -19,6 +19,10 @@ "insecure_thread_network": { "title": "Insecure Thread network settings detected", "description": "Your Thread network is using a default network key or pass phrase.\n\nThis is a security risk, please create a new Thread network." + }, + "otbr_zha_channel_collision": { + "title": "OTBR and ZHA share the same radio but use different channels", + "description": "When OTBR and ZHA share the radio, they must use the same network channel.\n\nIf OTBR and ZHA attempt to connect to networks on different channels, neither Thread/Matter nor Zigbee will work.\n\nOTBR is configured with a Thread network on channel {otbr_channel}, ZHA is configured with a Zigbee network on channel {zha_channel}." } } } diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py new file mode 100644 index 0000000000..b2ce05f280 --- /dev/null +++ b/homeassistant/components/otbr/util.py @@ -0,0 +1,229 @@ +"""Utility functions for the Open Thread Border Router integration.""" +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 + +import python_otbr_api +from python_otbr_api import tlv_parser +from python_otbr_api.pskc import compute_pskc + +from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + 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 + +from .const import DOMAIN + +_R = TypeVar("_R") +_P = ParamSpec("_P") + +INFO_URL_SKY_CONNECT = ( + "https://skyconnect.home-assistant.io/multiprotocol-channel-missmatch" +) +INFO_URL_YELLOW = "https://yellow.home-assistant.io/multiprotocol-channel-missmatch" + +INSECURE_NETWORK_KEYS = ( + # Thread web UI default + bytes.fromhex("00112233445566778899AABBCCDDEEFF"), +) + +INSECURE_PASSPHRASES = ( + # Thread web UI default + "j01Nme", + # Thread documentation default + "J01NME", +) + + +def _handle_otbr_error( + func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]] +) -> Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]]: + """Handle OTBR errors.""" + + @wraps(func) + async def _func(self: OTBRData, *args: _P.args, **kwargs: _P.kwargs) -> _R: + try: + return await func(self, *args, **kwargs) + except python_otbr_api.OTBRError as exc: + raise HomeAssistantError("Failed to call OTBR API") from exc + + return _func + + +@dataclasses.dataclass +class OTBRData: + """Container for OTBR data.""" + + url: str + api: python_otbr_api.OTBR + entry_id: str + + @_handle_otbr_error + 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_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 create_active_dataset( + self, dataset: python_otbr_api.OperationalDataSet + ) -> None: + """Create an active operational dataset.""" + return await self.api.create_active_dataset(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 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) + + +async def _warn_on_channel_collision( + hass: HomeAssistant, otbrdata: OTBRData, dataset_tlvs: bytes +) -> None: + """Warn user if OTBR and ZHA attempt to use different channels.""" + + def delete_issue() -> None: + ir.async_delete_issue( + hass, + DOMAIN, + f"otbr_zha_channel_collision_{otbrdata.entry_id}", + ) + + if (allowed_channel := await get_allowed_channel(hass, otbrdata.url)) is None: + delete_issue() + return + + 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: + delete_issue() + return + + if channel == allowed_channel: + delete_issue() + return + + yellow = await multi_pan_addon_using_device(hass, YELLOW_RADIO) + learn_more_url = INFO_URL_YELLOW if yellow else INFO_URL_SKY_CONNECT + + ir.async_create_issue( + hass, + DOMAIN, + f"otbr_zha_channel_collision_{otbrdata.entry_id}", + is_fixable=False, + is_persistent=False, + learn_more_url=learn_more_url, + severity=ir.IssueSeverity.WARNING, + translation_key="otbr_zha_channel_collision", + translation_placeholders={ + "otbr_channel": str(channel), + "zha_channel": str(allowed_channel), + }, + ) + + +def _warn_on_default_network_settings( + hass: HomeAssistant, otbrdata: OTBRData, dataset_tlvs: bytes +) -> None: + """Warn user if insecure default network settings are used.""" + dataset = tlv_parser.parse_tlv(dataset_tlvs.hex()) + insecure = False + + if ( + network_key := dataset.get(tlv_parser.MeshcopTLVType.NETWORKKEY) + ) is not None and bytes.fromhex(network_key) 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 + ): + ext_pan_id = dataset[tlv_parser.MeshcopTLVType.EXTPANID] + network_name = dataset[tlv_parser.MeshcopTLVType.NETWORKNAME] + pskc = bytes.fromhex(dataset[tlv_parser.MeshcopTLVType.PSKC]) + for passphrase in INSECURE_PASSPHRASES: + if pskc == compute_pskc(ext_pan_id, network_name, passphrase): + insecure = True + break + + if insecure: + ir.async_create_issue( + hass, + DOMAIN, + f"insecure_thread_network_{otbrdata.entry_id}", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="insecure_thread_network", + ) + else: + ir.async_delete_issue( + hass, + DOMAIN, + f"insecure_thread_network_{otbrdata.entry_id}", + ) + + +async def update_issues( + hass: HomeAssistant, otbrdata: OTBRData, dataset_tlvs: bytes +) -> None: + """Raise or clear repair issues related to network settings.""" + await _warn_on_channel_collision(hass, otbrdata, dataset_tlvs) + _warn_on_default_network_settings(hass, otbrdata, dataset_tlvs) diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index aa8c1dd2dd..2189df363b 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -1,5 +1,4 @@ """Websocket API for OTBR.""" -from typing import TYPE_CHECKING import python_otbr_api from python_otbr_api import tlv_parser @@ -11,9 +10,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from .const import DEFAULT_CHANNEL, DOMAIN - -if TYPE_CHECKING: - from . import OTBRData +from .util import OTBRData, get_allowed_channel, update_issues @callback @@ -72,11 +69,8 @@ async def websocket_create_network( connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") return - # We currently have no way to know which channel zha is using, assume it's - # the default - zha_channel = DEFAULT_CHANNEL - data: OTBRData = hass.data[DOMAIN] + channel = await get_allowed_channel(hass, data.url) or DEFAULT_CHANNEL try: await data.set_enabled(False) @@ -87,7 +81,7 @@ async def websocket_create_network( try: await data.create_active_dataset( python_otbr_api.OperationalDataSet( - channel=zha_channel, network_name="home-assistant" + channel=channel, network_name="home-assistant" ) ) except HomeAssistantError as exc: @@ -111,6 +105,9 @@ async def websocket_create_network( await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) + # Update repair issues + await update_issues(hass, data, dataset_tlvs) + connection.send_result(msg["id"]) @@ -139,21 +136,18 @@ async def websocket_set_network( if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): thread_dataset_channel = int(channel_str, base=16) - # We currently have no way to know which channel zha is using, assume it's - # the default - zha_channel = DEFAULT_CHANNEL + data: OTBRData = hass.data[DOMAIN] + allowed_channel = await get_allowed_channel(hass, data.url) - if thread_dataset_channel != zha_channel: + if allowed_channel and thread_dataset_channel != allowed_channel: connection.send_error( msg["id"], "channel_conflict", f"Can't connect to network on channel {thread_dataset_channel}, ZHA is " - f"using channel {zha_channel}", + f"using channel {allowed_channel}", ) return - data: OTBRData = hass.data[DOMAIN] - try: await data.set_enabled(False) except HomeAssistantError as exc: @@ -172,6 +166,9 @@ async def websocket_set_network( connection.send_error(msg["id"], "set_enabled_failed", str(exc)) return + # Update repair issues + await update_issues(hass, data, bytes.fromhex(dataset_tlv)) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py index 499c9b129f..7c7c30df97 100644 --- a/homeassistant/components/otp/sensor.py +++ b/homeassistant/components/otp/sensor.py @@ -17,7 +17,6 @@ DEFAULT_NAME = "OTP Sensor" TIME_STEP = 30 # Default time step assumed by Google Authenticator -ICON = "mdi:update" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -44,6 +43,7 @@ async def async_setup_platform( class TOTPSensor(SensorEntity): """Representation of a TOTP sensor.""" + _attr_icon = "mdi:update" _attr_should_poll = False def __init__(self, name, token): @@ -76,8 +76,3 @@ class TOTPSensor(SensorEntity): def native_value(self): """Return the state of the sensor.""" return self._state - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 6086ee1efd..560493888d 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -24,6 +24,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_when_setup +from homeassistant.util.json import json_loads from .config_flow import CONF_SECRET from .const import DOMAIN @@ -133,10 +134,11 @@ async def async_connect_mqtt(hass, component): """Subscribe to MQTT topic.""" context = hass.data[DOMAIN]["context"] - async def async_handle_mqtt_message(msg): + @callback + def async_handle_mqtt_message(msg): """Handle incoming OwnTracks message.""" try: - message = json.loads(msg.payload) + message = json_loads(msg.payload) except ValueError: # If invalid JSON _LOGGER.error("Unable to parse payload as JSON: %s", msg.payload) diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index 8b676f37c2..5e2ed77233 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -203,7 +203,9 @@ class PanasonicVieraTVEntity(MediaPlayerEntity): await self._remote.async_play_media(media_type, media_id) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media(self.hass, media_content_id) diff --git a/homeassistant/components/panasonic_viera/strings.json b/homeassistant/components/panasonic_viera/strings.json index a04f942daf..0947b1ad0d 100644 --- a/homeassistant/components/panasonic_viera/strings.json +++ b/homeassistant/components/panasonic_viera/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Set up your TV", - "description": "Enter your Panasonic Viera TV's [%key:common::config_flow::data::ip%]", + "description": "Enter your Panasonic Viera TV's IP address", "data": { "host": "[%key:common::config_flow::data::ip%]", "name": "[%key:common::config_flow::data::name%]" @@ -11,7 +11,7 @@ }, "pairing": { "title": "Pairing", - "description": "Enter the [%key:common::config_flow::data::pin%] displayed on your TV", + "description": "Enter the PIN displayed on your TV", "data": { "pin": "[%key:common::config_flow::data::pin%]" } @@ -19,7 +19,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_pin_code": "The [%key:common::config_flow::data::pin%] you entered was invalid" + "invalid_pin_code": "The PIN you entered was invalid" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index a5e56d0073..fe6925b484 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -56,6 +56,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_SOURCE = "source" ATTR_USER_ID = "user_id" +ATTR_DEVICE_TRACKERS = "device_trackers" CONF_DEVICE_TRACKERS = "device_trackers" CONF_USER_ID = "user_id" @@ -188,7 +189,7 @@ class PersonStore(Store): return {"items": old_data["persons"]} -class PersonStorageCollection(collection.StorageCollection): +class PersonStorageCollection(collection.DictStorageCollection): """Person collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -197,15 +198,14 @@ class PersonStorageCollection(collection.StorageCollection): def __init__( self, store: Store, - logger: logging.Logger, id_manager: collection.IDManager, yaml_collection: collection.YamlCollection, ) -> None: """Initialize a person storage collection.""" - super().__init__(store, logger, id_manager) + super().__init__(store, id_manager) self.yaml_collection = yaml_collection - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data. A past bug caused onboarding to create invalid person objects. @@ -271,16 +271,16 @@ class PersonStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.UPDATE_SCHEMA(update_data) user_id = update_data.get(CONF_USER_ID) - if user_id is not None and user_id != data.get(CONF_USER_ID): + if user_id is not None and user_id != item.get(CONF_USER_ID): await self._validate_user_id(user_id) - return {**data, **update_data} + return {**item, **update_data} async def _validate_user_id(self, user_id): """Validate the used user_id.""" @@ -337,7 +337,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) storage_collection = PersonStorageCollection( PersonStore(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, yaml_collection, ) @@ -356,7 +355,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN] = (yaml_collection, storage_collection, entity_component) - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass, create_list=False) @@ -448,6 +447,7 @@ class Person(collection.CollectionEntity, RestoreEntity): data[ATTR_SOURCE] = self._source if (user_id := self._config.get(CONF_USER_ID)) is not None: data[ATTR_USER_ID] = user_id + data[ATTR_DEVICE_TRACKERS] = self.device_trackers return data @property diff --git a/homeassistant/components/person/recorder.py b/homeassistant/components/person/recorder.py new file mode 100644 index 0000000000..7c0fdf5225 --- /dev/null +++ b/homeassistant/components/person/recorder.py @@ -0,0 +1,12 @@ +"""Integration platform for recorder.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant, callback + +from . import ATTR_DEVICE_TRACKERS + + +@callback +def exclude_attributes(hass: HomeAssistant) -> set[str]: + """Exclude large and chatty update attributes from being recorded.""" + return {ATTR_DEVICE_TRACKERS} diff --git a/homeassistant/components/person/strings.json b/homeassistant/components/person/strings.json index 8ee8c3a56a..8a8915541d 100644 --- a/homeassistant/components/person/strings.json +++ b/homeassistant/components/person/strings.json @@ -6,6 +6,23 @@ "state": { "home": "[%key:common::state::home%]", "not_home": "[%key:common::state::not_home%]" + }, + "state_attributes": { + "device_trackers": { + "name": "Device trackers" + }, + "gps_accuracy": { + "name": "[%key:component::device_tracker::entity_component::_::state_attributes::gps_accuracy::name%]" + }, + "latitude": { + "name": "[%key:component::device_tracker::entity_component::_::state_attributes::latitude::name%]" + }, + "longitude": { + "name": "[%key:component::device_tracker::entity_component::_::state_attributes::longitude::name%]" + }, + "source": { + "name": "Source" + } } } } diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 3145e82a94..55ac33d198 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -19,8 +19,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, DOMAIN @@ -171,4 +172,4 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): except ConnectionFailure: pass except AutenticationFailure as exception: - raise UpdateFailed(str(exception)) from exception + raise ConfigEntryAuthFailed(str(exception)) from exception diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py index dab8d4fbe2..9b7e52c211 100644 --- a/homeassistant/components/philips_js/config_flow.py +++ b/homeassistant/components/philips_js/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Philips TV integration.""" from __future__ import annotations +from collections.abc import Mapping import platform from typing import Any @@ -20,6 +21,18 @@ from homeassistant.data_entry_flow import FlowResult from . import LOGGER from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, CONST_APP_ID, CONST_APP_NAME, DOMAIN +USER_SCHEMA = vol.Schema( + { + vol.Required( + CONF_HOST, + ): str, + vol.Required( + CONF_API_VERSION, + default=1, + ): vol.In([1, 5, 6]), + } +) + async def _validate_input( hass: core.HomeAssistant, host: str, api_version: int @@ -47,9 +60,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._current: dict[str, Any] = {} self._hub: PhilipsTV | None = None self._pair_state: Any = None + self._entry: config_entries.ConfigEntry | None = None async def _async_create_current(self) -> FlowResult: system = self._current[CONF_SYSTEM] + if self._entry: + self.hass.config_entries.async_update_entry( + self._entry, data=self._entry.data | self._current + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=f"{system['name']} ({system['serialnumber']})", data=self._current, @@ -108,6 +131,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._current[CONF_PASSWORD] = password return await self._async_create_current() + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + self._current[CONF_HOST] = entry_data[CONF_HOST] + self._current[CONF_API_VERSION] = entry_data[CONF_API_VERSION] + return await self.async_step_user() + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -128,7 +158,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: if serialnumber := hub.system.get("serialnumber"): await self.async_set_unique_id(serialnumber) - self._abort_if_unique_id_configured() + if self._entry is None: + self._abort_if_unique_id_configured() self._current[CONF_SYSTEM] = hub.system self._current[CONF_API_VERSION] = hub.api_version @@ -138,14 +169,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_pair() return await self._async_create_current() - schema = vol.Schema( - { - vol.Required(CONF_HOST, default=self._current.get(CONF_HOST)): str, - vol.Required( - CONF_API_VERSION, default=self._current.get(CONF_API_VERSION, 1) - ): vol.In([1, 5, 6]), - } - ) + schema = self.add_suggested_values_to_schema(USER_SCHEMA, self._current) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) @staticmethod diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index 89cb29f0a0..c6ca70bdc8 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -391,7 +391,9 @@ class PhilipsTVMediaPlayer( ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" if not self._tv.on: @@ -413,7 +415,7 @@ class PhilipsTVMediaPlayer( async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json index dc25838580..302e1b9acc 100644 --- a/homeassistant/components/philips_js/strings.json +++ b/homeassistant/components/philips_js/strings.json @@ -22,7 +22,8 @@ "invalid_pin": "Invalid PIN" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index 49f1697adc..96cdd7ab10 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -16,9 +16,9 @@ from homeassistant.const import ( CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -64,6 +64,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Setting up %s integration with host %s", DOMAIN, host) + name_to_key = { + "Core Update Available": "core_update_available", + "Web Update Available": "web_update_available", + "FTL Update Available": "ftl_update_available", + "Status": "status", + "Ads Blocked Today": "ads_blocked_today", + "Ads Percentage Blocked Today": "ads_percentage_today", + "Seen Clients": "clients_ever_seen", + "DNS Queries Today": "dns_queries_today", + "Domains Blocked": "domains_being_blocked", + "DNS Queries Cached": "queries_cached", + "DNS Queries Forwarded": "queries_forwarded", + "DNS Unique Clients": "unique_clients", + "DNS Unique Domains": "unique_domains", + } + + @callback + def update_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + unique_id_parts = entity_entry.unique_id.split("/") + if len(unique_id_parts) == 2 and unique_id_parts[1] in name_to_key: + name = unique_id_parts[1] + new_unique_id = entity_entry.unique_id.replace(name, name_to_key[name]) + _LOGGER.debug("Migrate %s to %s", entity_entry.unique_id, new_unique_id) + return {"new_unique_id": new_unique_id} + + return None + + await er.async_migrate_entries(hass, entry.entry_id, update_unique_id) + session = async_get_clientsession(hass, verify_tls) api = Hole( host, diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 4aa391b567..7ec1bf40c6 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -77,7 +77,7 @@ BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = ( ), PiHoleBinarySensorEntityDescription( key="status", - name="Status", + translation_key="status", icon="mdi:pi-hole", state_value=lambda api: bool(api.data.get("status") == "enabled"), ), @@ -109,6 +109,7 @@ class PiHoleBinarySensor(PiHoleEntity, BinarySensorEntity): """Representation of a Pi-hole binary sensor.""" entity_description: PiHoleBinarySensorEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -121,12 +122,7 @@ class PiHoleBinarySensor(PiHoleEntity, BinarySensorEntity): """Initialize a Pi-hole sensor.""" super().__init__(api, coordinator, name, server_unique_id) self.entity_description = description - - if description.key == "status": - self._attr_name = f"{name}" - else: - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = f"{self._server_unique_id}/{description.name}" + self._attr_unique_id = f"{self._server_unique_id}/{description.key}" @property def is_on(self) -> bool: diff --git a/homeassistant/components/pi_hole/diagnostics.py b/homeassistant/components/pi_hole/diagnostics.py new file mode 100644 index 0000000000..8b3c32b0ac --- /dev/null +++ b/homeassistant/components/pi_hole/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for the Pi-hole integration.""" +from __future__ import annotations + +from typing import Any + +from hole import Hole + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from .const import DATA_KEY_API, DOMAIN + +TO_REDACT = {CONF_API_KEY} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + api: Hole = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "data": api.data, + "versions": api.versions, + } diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 5d36ba67e8..c6a8d5da83 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -17,55 +17,55 @@ from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN as PIHOLE_DOMAIN SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="ads_blocked_today", - name="Ads Blocked Today", + translation_key="ads_blocked_today", native_unit_of_measurement="ads", icon="mdi:close-octagon-outline", ), SensorEntityDescription( key="ads_percentage_today", - name="Ads Percentage Blocked Today", + translation_key="ads_percentage_today", native_unit_of_measurement=PERCENTAGE, icon="mdi:close-octagon-outline", ), SensorEntityDescription( key="clients_ever_seen", - name="Seen Clients", + translation_key="clients_ever_seen", native_unit_of_measurement="clients", icon="mdi:account-outline", ), SensorEntityDescription( key="dns_queries_today", - name="DNS Queries Today", + translation_key="dns_queries_today", native_unit_of_measurement="queries", icon="mdi:comment-question-outline", ), SensorEntityDescription( key="domains_being_blocked", - name="Domains Blocked", + translation_key="domains_being_blocked", native_unit_of_measurement="domains", icon="mdi:block-helper", ), SensorEntityDescription( key="queries_cached", - name="DNS Queries Cached", + translation_key="queries_cached", native_unit_of_measurement="queries", icon="mdi:comment-question-outline", ), SensorEntityDescription( key="queries_forwarded", - name="DNS Queries Forwarded", + translation_key="queries_forwarded", native_unit_of_measurement="queries", icon="mdi:comment-question-outline", ), SensorEntityDescription( key="unique_clients", - name="DNS Unique Clients", + translation_key="unique_clients", native_unit_of_measurement="clients", icon="mdi:account-outline", ), SensorEntityDescription( key="unique_domains", - name="DNS Unique Domains", + translation_key="unique_domains", native_unit_of_measurement="domains", icon="mdi:domain", ), @@ -95,6 +95,7 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): """Representation of a Pi-hole sensor.""" entity_description: SensorEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -108,8 +109,7 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): super().__init__(api, coordinator, name, server_unique_id) self.entity_description = description - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = f"{self._server_unique_id}/{description.name}" + self._attr_unique_id = f"{self._server_unique_id}/{description.key}" @property def native_value(self) -> StateType: diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json index 2f04b8fe47..eb12811722 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -17,7 +17,7 @@ } }, "reauth_confirm": { - "title": "PI-Hole [%key:common::config_flow::title::reauth%]", + "title": "Reauthenticate PI-Hole", "description": "Please enter a new api key for PI-Hole at {host}/{location}", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" @@ -32,5 +32,26 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "status": { "name": "Status" } + }, + "sensor": { + "ads_blocked_today": { "name": "Ads blocked today" }, + "ads_percentage_today": { "name": "Ads percentage blocked today" }, + "clients_ever_seen": { "name": "Seen clients" }, + "dns_queries_today": { "name": "DNS queries today" }, + "domains_being_blocked": { "name": "Domains blocked" }, + "queries_cached": { "name": "DNS queries cached" }, + "queries_forwarded": { "name": "DNS queries forwarded" }, + "unique_clients": { "name": "DNS unique clients" }, + "unique_domains": { "name": "DNS unique domains" } + }, + "update": { + "core_update_available": { "name": "Core update available" }, + "ftl_update_available": { "name": "FTL update available" }, + "web_update_available": { "name": "Web update available" } + } } } diff --git a/homeassistant/components/pi_hole/update.py b/homeassistant/components/pi_hole/update.py index d92d78dbb5..b9d8bf828d 100644 --- a/homeassistant/components/pi_hole/update.py +++ b/homeassistant/components/pi_hole/update.py @@ -30,7 +30,7 @@ class PiHoleUpdateEntityDescription(UpdateEntityDescription): UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = ( PiHoleUpdateEntityDescription( key="core_update_available", - name="Core Update Available", + translation_key="core_update_available", title="Pi-hole Core", entity_category=EntityCategory.DIAGNOSTIC, installed_version=lambda versions: versions.get("core_current"), @@ -39,7 +39,7 @@ UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = ( ), PiHoleUpdateEntityDescription( key="web_update_available", - name="Web Update Available", + translation_key="web_update_available", title="Pi-hole Web interface", entity_category=EntityCategory.DIAGNOSTIC, installed_version=lambda versions: versions.get("web_current"), @@ -48,7 +48,7 @@ UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = ( ), PiHoleUpdateEntityDescription( key="ftl_update_available", - name="FTL Update Available", + translation_key="ftl_update_available", title="Pi-hole FTL DNS", entity_category=EntityCategory.DIAGNOSTIC, installed_version=lambda versions: versions.get("FTL_current"), @@ -81,6 +81,7 @@ class PiHoleUpdateEntity(PiHoleEntity, UpdateEntity): """Representation of a Pi-hole update entity.""" entity_description: PiHoleUpdateEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -94,8 +95,7 @@ class PiHoleUpdateEntity(PiHoleEntity, UpdateEntity): super().__init__(api, coordinator, name, server_unique_id) self.entity_description = description - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = f"{self._server_unique_id}/{description.name}" + self._attr_unique_id = f"{self._server_unique_id}/{description.key}" self._attr_title = description.title @property diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index 85a7acadae..7e983321f3 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -1,16 +1,6 @@ """Constants for the Picnic integration.""" from __future__ import annotations -from collections.abc import Callable -from dataclasses import dataclass -from datetime import datetime -from typing import Any, Literal - -from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription -from homeassistant.const import CURRENCY_EURO -from homeassistant.helpers.typing import StateType -from homeassistant.util import dt as dt_util - DOMAIN = "picnic" CONF_API = "api" @@ -49,163 +39,3 @@ SENSOR_NEXT_DELIVERY_ETA_START = "next_delivery_eta_start" SENSOR_NEXT_DELIVERY_ETA_END = "next_delivery_eta_end" SENSOR_NEXT_DELIVERY_SLOT_START = "next_delivery_slot_start" SENSOR_NEXT_DELIVERY_SLOT_END = "next_delivery_slot_end" - - -@dataclass -class PicnicRequiredKeysMixin: - """Mixin for required keys.""" - - data_type: Literal[ - "cart_data", "slot_data", "next_delivery_data", "last_order_data" - ] - value_fn: Callable[[Any], StateType | datetime] - - -@dataclass -class PicnicSensorEntityDescription(SensorEntityDescription, PicnicRequiredKeysMixin): - """Describes Picnic sensor entity.""" - - entity_registry_enabled_default: bool = False - - -SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( - PicnicSensorEntityDescription( - key=SENSOR_CART_ITEMS_COUNT, - icon="mdi:format-list-numbered", - data_type="cart_data", - value_fn=lambda cart: cart.get("total_count", 0), - ), - PicnicSensorEntityDescription( - key=SENSOR_CART_TOTAL_PRICE, - native_unit_of_measurement=CURRENCY_EURO, - icon="mdi:currency-eur", - entity_registry_enabled_default=True, - data_type="cart_data", - value_fn=lambda cart: cart.get("total_price", 0) / 100, - ), - PicnicSensorEntityDescription( - key=SENSOR_SELECTED_SLOT_START, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:calendar-start", - entity_registry_enabled_default=True, - data_type="slot_data", - value_fn=lambda slot: dt_util.parse_datetime(str(slot.get("window_start"))), - ), - PicnicSensorEntityDescription( - key=SENSOR_SELECTED_SLOT_END, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:calendar-end", - entity_registry_enabled_default=True, - data_type="slot_data", - value_fn=lambda slot: dt_util.parse_datetime(str(slot.get("window_end"))), - ), - PicnicSensorEntityDescription( - key=SENSOR_SELECTED_SLOT_MAX_ORDER_TIME, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:clock-alert-outline", - entity_registry_enabled_default=True, - data_type="slot_data", - value_fn=lambda slot: dt_util.parse_datetime(str(slot.get("cut_off_time"))), - ), - PicnicSensorEntityDescription( - key=SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, - native_unit_of_measurement=CURRENCY_EURO, - icon="mdi:currency-eur", - entity_registry_enabled_default=True, - data_type="slot_data", - value_fn=lambda slot: ( - slot["minimum_order_value"] / 100 - if slot.get("minimum_order_value") - else None - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_LAST_ORDER_SLOT_START, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:calendar-start", - data_type="last_order_data", - value_fn=lambda last_order: dt_util.parse_datetime( - str(last_order.get("slot", {}).get("window_start")) - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_LAST_ORDER_SLOT_END, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:calendar-end", - data_type="last_order_data", - value_fn=lambda last_order: dt_util.parse_datetime( - str(last_order.get("slot", {}).get("window_end")) - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_LAST_ORDER_STATUS, - icon="mdi:list-status", - data_type="last_order_data", - value_fn=lambda last_order: last_order.get("status"), - ), - PicnicSensorEntityDescription( - key=SENSOR_LAST_ORDER_MAX_ORDER_TIME, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:clock-alert-outline", - entity_registry_enabled_default=True, - data_type="last_order_data", - value_fn=lambda last_order: dt_util.parse_datetime( - str(last_order.get("slot", {}).get("cut_off_time")) - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_LAST_ORDER_DELIVERY_TIME, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:timeline-clock", - entity_registry_enabled_default=True, - data_type="last_order_data", - value_fn=lambda last_order: dt_util.parse_datetime( - str(last_order.get("delivery_time", {}).get("start")) - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_LAST_ORDER_TOTAL_PRICE, - native_unit_of_measurement=CURRENCY_EURO, - icon="mdi:cash-marker", - data_type="last_order_data", - value_fn=lambda last_order: last_order.get("total_price", 0) / 100, - ), - PicnicSensorEntityDescription( - key=SENSOR_NEXT_DELIVERY_ETA_START, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:clock-start", - entity_registry_enabled_default=True, - data_type="next_delivery_data", - value_fn=lambda next_delivery: dt_util.parse_datetime( - str(next_delivery.get("eta", {}).get("start")) - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_NEXT_DELIVERY_ETA_END, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:clock-end", - entity_registry_enabled_default=True, - data_type="next_delivery_data", - value_fn=lambda next_delivery: dt_util.parse_datetime( - str(next_delivery.get("eta", {}).get("end")) - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_NEXT_DELIVERY_SLOT_START, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:calendar-start", - data_type="next_delivery_data", - value_fn=lambda next_delivery: dt_util.parse_datetime( - str(next_delivery.get("slot", {}).get("window_start")) - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_NEXT_DELIVERY_SLOT_END, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:calendar-end", - data_type="next_delivery_data", - value_fn=lambda next_delivery: dt_util.parse_datetime( - str(next_delivery.get("slot", {}).get("window_end")) - ), - ), -) diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index e992945c51..74c37e9d5c 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -1,11 +1,18 @@ """Definition of Picnic sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime -from typing import Any, cast +from typing import Any, Literal, cast -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CURRENCY_EURO from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -15,14 +22,205 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) +from homeassistant.util import dt as dt_util from .const import ( ADDRESS, ATTRIBUTION, CONF_COORDINATOR, DOMAIN, - SENSOR_TYPES, - PicnicSensorEntityDescription, + SENSOR_CART_ITEMS_COUNT, + SENSOR_CART_TOTAL_PRICE, + SENSOR_LAST_ORDER_DELIVERY_TIME, + SENSOR_LAST_ORDER_MAX_ORDER_TIME, + SENSOR_LAST_ORDER_SLOT_END, + SENSOR_LAST_ORDER_SLOT_START, + SENSOR_LAST_ORDER_STATUS, + SENSOR_LAST_ORDER_TOTAL_PRICE, + SENSOR_NEXT_DELIVERY_ETA_END, + SENSOR_NEXT_DELIVERY_ETA_START, + SENSOR_NEXT_DELIVERY_SLOT_END, + SENSOR_NEXT_DELIVERY_SLOT_START, + SENSOR_SELECTED_SLOT_END, + SENSOR_SELECTED_SLOT_MAX_ORDER_TIME, + SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, + SENSOR_SELECTED_SLOT_START, +) + + +@dataclass +class PicnicRequiredKeysMixin: + """Mixin for required keys.""" + + data_type: Literal[ + "cart_data", "slot_data", "next_delivery_data", "last_order_data" + ] + value_fn: Callable[[Any], StateType | datetime] + + +@dataclass +class PicnicSensorEntityDescription(SensorEntityDescription, PicnicRequiredKeysMixin): + """Describes Picnic sensor entity.""" + + entity_registry_enabled_default: bool = False + + +SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( + PicnicSensorEntityDescription( + key=SENSOR_CART_ITEMS_COUNT, + translation_key=SENSOR_CART_ITEMS_COUNT, + icon="mdi:format-list-numbered", + data_type="cart_data", + value_fn=lambda cart: cart.get("total_count", 0), + ), + PicnicSensorEntityDescription( + key=SENSOR_CART_TOTAL_PRICE, + translation_key=SENSOR_CART_TOTAL_PRICE, + native_unit_of_measurement=CURRENCY_EURO, + icon="mdi:currency-eur", + entity_registry_enabled_default=True, + data_type="cart_data", + value_fn=lambda cart: cart.get("total_price", 0) / 100, + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_START, + translation_key=SENSOR_SELECTED_SLOT_START, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:calendar-start", + entity_registry_enabled_default=True, + data_type="slot_data", + value_fn=lambda slot: dt_util.parse_datetime(str(slot.get("window_start"))), + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_END, + translation_key=SENSOR_SELECTED_SLOT_END, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:calendar-end", + entity_registry_enabled_default=True, + data_type="slot_data", + value_fn=lambda slot: dt_util.parse_datetime(str(slot.get("window_end"))), + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_MAX_ORDER_TIME, + translation_key=SENSOR_SELECTED_SLOT_MAX_ORDER_TIME, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:clock-alert-outline", + entity_registry_enabled_default=True, + data_type="slot_data", + value_fn=lambda slot: dt_util.parse_datetime(str(slot.get("cut_off_time"))), + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, + translation_key=SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, + native_unit_of_measurement=CURRENCY_EURO, + icon="mdi:currency-eur", + entity_registry_enabled_default=True, + data_type="slot_data", + value_fn=lambda slot: ( + slot["minimum_order_value"] / 100 + if slot.get("minimum_order_value") + else None + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_SLOT_START, + translation_key=SENSOR_LAST_ORDER_SLOT_START, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:calendar-start", + data_type="last_order_data", + value_fn=lambda last_order: dt_util.parse_datetime( + str(last_order.get("slot", {}).get("window_start")) + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_SLOT_END, + translation_key=SENSOR_LAST_ORDER_SLOT_END, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:calendar-end", + data_type="last_order_data", + value_fn=lambda last_order: dt_util.parse_datetime( + str(last_order.get("slot", {}).get("window_end")) + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_STATUS, + translation_key=SENSOR_LAST_ORDER_STATUS, + icon="mdi:list-status", + data_type="last_order_data", + value_fn=lambda last_order: last_order.get("status"), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_MAX_ORDER_TIME, + translation_key=SENSOR_LAST_ORDER_MAX_ORDER_TIME, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:clock-alert-outline", + entity_registry_enabled_default=True, + data_type="last_order_data", + value_fn=lambda last_order: dt_util.parse_datetime( + str(last_order.get("slot", {}).get("cut_off_time")) + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_DELIVERY_TIME, + translation_key=SENSOR_LAST_ORDER_DELIVERY_TIME, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:timeline-clock", + entity_registry_enabled_default=True, + data_type="last_order_data", + value_fn=lambda last_order: dt_util.parse_datetime( + str(last_order.get("delivery_time", {}).get("start")) + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_TOTAL_PRICE, + translation_key=SENSOR_LAST_ORDER_TOTAL_PRICE, + native_unit_of_measurement=CURRENCY_EURO, + icon="mdi:cash-marker", + data_type="last_order_data", + value_fn=lambda last_order: last_order.get("total_price", 0) / 100, + ), + PicnicSensorEntityDescription( + key=SENSOR_NEXT_DELIVERY_ETA_START, + translation_key=SENSOR_NEXT_DELIVERY_ETA_START, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:clock-start", + entity_registry_enabled_default=True, + data_type="next_delivery_data", + value_fn=lambda next_delivery: dt_util.parse_datetime( + str(next_delivery.get("eta", {}).get("start")) + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_NEXT_DELIVERY_ETA_END, + translation_key=SENSOR_NEXT_DELIVERY_ETA_END, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:clock-end", + entity_registry_enabled_default=True, + data_type="next_delivery_data", + value_fn=lambda next_delivery: dt_util.parse_datetime( + str(next_delivery.get("eta", {}).get("end")) + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_NEXT_DELIVERY_SLOT_START, + translation_key=SENSOR_NEXT_DELIVERY_SLOT_START, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:calendar-start", + data_type="next_delivery_data", + value_fn=lambda next_delivery: dt_util.parse_datetime( + str(next_delivery.get("slot", {}).get("window_start")) + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_NEXT_DELIVERY_SLOT_END, + translation_key=SENSOR_NEXT_DELIVERY_SLOT_END, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:calendar-end", + data_type="next_delivery_data", + value_fn=lambda next_delivery: dt_util.parse_datetime( + str(next_delivery.get("slot", {}).get("window_end")) + ), + ), ) @@ -44,6 +242,7 @@ async def async_setup_entry( class PicnicSensor(SensorEntity, CoordinatorEntity): """The CoordinatorEntity subclass representing Picnic sensors.""" + _attr_has_entity_name = True _attr_attribution = ATTRIBUTION entity_description: PicnicSensorEntityDescription @@ -60,7 +259,6 @@ class PicnicSensor(SensorEntity, CoordinatorEntity): self.entity_id = f"sensor.picnic_{description.key}" self._service_unique_id = config_entry.unique_id - self._attr_name = self._to_capitalized_name(description.key) self._attr_unique_id = f"{config_entry.unique_id}.{description.key}" @property @@ -88,7 +286,3 @@ class PicnicSensor(SensorEntity, CoordinatorEntity): model=self._service_unique_id, name=f"Picnic: {self.coordinator.data[ADDRESS]}", ) - - @staticmethod - def _to_capitalized_name(name: str) -> str: - return name.replace("_", " ").capitalize() diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json index 9eb51b2fd2..ff91b5259b 100644 --- a/homeassistant/components/picnic/strings.json +++ b/homeassistant/components/picnic/strings.json @@ -19,5 +19,57 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "sensor": { + "cart_items_count": { + "name": "Cart items count" + }, + "cart_total_price": { + "name": "Cart total price" + }, + "selected_slot_start": { + "name": "Start of selected slot" + }, + "selected_slot_end": { + "name": "End of selected slot" + }, + "selected_slot_max_order_time": { + "name": "Max order time of selected slot" + }, + "selected_slot_min_order_value": { + "name": "Minimum order value for selected slot" + }, + "last_order_slot_start": { + "name": "Start of last order's slot" + }, + "last_order_slot_end": { + "name": "End of last order's slot" + }, + "last_order_status": { + "name": "Status of last order" + }, + "last_order_max_order_time": { + "name": "Max order time of last slot" + }, + "last_order_delivery_time": { + "name": "Last order delivery time" + }, + "last_order_total_price": { + "name": "Total price of last order" + }, + "next_delivery_eta_start": { + "name": "Expected start of next delivery" + }, + "next_delivery_eta_end": { + "name": "Expected end of next delivery" + }, + "next_delivery_slot_start": { + "name": "Start of next delivery's slot" + }, + "next_delivery_slot_end": { + "name": "End of next delivery's slot" + } + } } } diff --git a/homeassistant/components/pjlink/const.py b/homeassistant/components/pjlink/const.py new file mode 100644 index 0000000000..95e29e5bf2 --- /dev/null +++ b/homeassistant/components/pjlink/const.py @@ -0,0 +1,8 @@ +"""Constants for the PJLink integration.""" + +CONF_ENCODING = "encoding" + +DEFAULT_PORT = 4352 +DEFAULT_ENCODING = "utf-8" + +DOMAIN = "pjlink" diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py index 3fb5facac5..4bbf1225a9 100644 --- a/homeassistant/components/pjlink/media_player.py +++ b/homeassistant/components/pjlink/media_player.py @@ -19,11 +19,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -CONF_ENCODING = "encoding" - -DEFAULT_PORT = 4352 -DEFAULT_ENCODING = "utf-8" -DEFAULT_TIMEOUT = 10 +from .const import CONF_ENCODING, DEFAULT_ENCODING, DEFAULT_PORT, DOMAIN ERR_PROJECTOR_UNAVAILABLE = "projector unavailable" @@ -51,9 +47,9 @@ def setup_platform( encoding = config.get(CONF_ENCODING) password = config.get(CONF_PASSWORD) - if "pjlink" not in hass.data: - hass.data["pjlink"] = {} - hass_data = hass.data["pjlink"] + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + hass_data = hass.data[DOMAIN] device_label = f"{host}:{port}" if device_label in hass_data: diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py index 35273e6fce..e385156c6d 100644 --- a/homeassistant/components/plant/__init__.py +++ b/homeassistant/components/plant/__init__.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.components.recorder import get_instance, history from homeassistant.const import ( - ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CONDUCTIVITY, CONF_SENSORS, @@ -29,48 +28,44 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +from .const import ( + ATTR_DICT_OF_UNITS_OF_MEASUREMENT, + ATTR_MAX_BRIGHTNESS_HISTORY, + ATTR_PROBLEM, + ATTR_SENSORS, + CONF_CHECK_DAYS, + CONF_MAX_BRIGHTNESS, + CONF_MAX_CONDUCTIVITY, + CONF_MAX_MOISTURE, + CONF_MAX_TEMPERATURE, + CONF_MIN_BATTERY_LEVEL, + CONF_MIN_BRIGHTNESS, + CONF_MIN_CONDUCTIVITY, + CONF_MIN_MOISTURE, + CONF_MIN_TEMPERATURE, + DEFAULT_CHECK_DAYS, + DEFAULT_MAX_CONDUCTIVITY, + DEFAULT_MAX_MOISTURE, + DEFAULT_MIN_BATTERY_LEVEL, + DEFAULT_MIN_CONDUCTIVITY, + DEFAULT_MIN_MOISTURE, + DOMAIN, + PROBLEM_NONE, + READING_BATTERY, + READING_BRIGHTNESS, + READING_CONDUCTIVITY, + READING_MOISTURE, + READING_TEMPERATURE, +) + _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "plant" - -READING_BATTERY = "battery" -READING_TEMPERATURE = ATTR_TEMPERATURE -READING_MOISTURE = "moisture" -READING_CONDUCTIVITY = "conductivity" -READING_BRIGHTNESS = "brightness" - -ATTR_PROBLEM = "problem" -ATTR_SENSORS = "sensors" -PROBLEM_NONE = "none" -ATTR_MAX_BRIGHTNESS_HISTORY = "max_brightness" - -# we're not returning only one value, we're returning a dict here. So we need -# to have a separate literal for it to avoid confusion. -ATTR_DICT_OF_UNITS_OF_MEASUREMENT = "unit_of_measurement_dict" - -CONF_MIN_BATTERY_LEVEL = f"min_{READING_BATTERY}" -CONF_MIN_TEMPERATURE = f"min_{READING_TEMPERATURE}" -CONF_MAX_TEMPERATURE = f"max_{READING_TEMPERATURE}" -CONF_MIN_MOISTURE = f"min_{READING_MOISTURE}" -CONF_MAX_MOISTURE = f"max_{READING_MOISTURE}" -CONF_MIN_CONDUCTIVITY = f"min_{READING_CONDUCTIVITY}" -CONF_MAX_CONDUCTIVITY = f"max_{READING_CONDUCTIVITY}" -CONF_MIN_BRIGHTNESS = f"min_{READING_BRIGHTNESS}" -CONF_MAX_BRIGHTNESS = f"max_{READING_BRIGHTNESS}" -CONF_CHECK_DAYS = "check_days" - CONF_SENSOR_BATTERY_LEVEL = READING_BATTERY CONF_SENSOR_MOISTURE = READING_MOISTURE CONF_SENSOR_CONDUCTIVITY = READING_CONDUCTIVITY CONF_SENSOR_TEMPERATURE = READING_TEMPERATURE CONF_SENSOR_BRIGHTNESS = READING_BRIGHTNESS -DEFAULT_MIN_BATTERY_LEVEL = 20 -DEFAULT_MIN_MOISTURE = 20 -DEFAULT_MAX_MOISTURE = 60 -DEFAULT_MIN_CONDUCTIVITY = 500 -DEFAULT_MAX_CONDUCTIVITY = 3000 -DEFAULT_CHECK_DAYS = 3 SCHEMA_SENSORS = vol.Schema( { @@ -104,8 +99,6 @@ PLANT_SCHEMA = vol.Schema( } ) -DOMAIN = "plant" - CONFIG_SCHEMA = vol.Schema({DOMAIN: {cv.string: PLANT_SCHEMA}}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/plant/const.py b/homeassistant/components/plant/const.py new file mode 100644 index 0000000000..0368c55e15 --- /dev/null +++ b/homeassistant/components/plant/const.py @@ -0,0 +1,37 @@ +"""Const for Plant.""" +from typing import Final + +DOMAIN: Final = "plant" + +READING_MOISTURE = "moisture" +READING_BATTERY = "battery" +READING_TEMPERATURE = "temperature" +READING_CONDUCTIVITY = "conductivity" +READING_BRIGHTNESS = "brightness" + +CONF_MIN_BATTERY_LEVEL = f"min_{READING_BATTERY}" +CONF_MIN_TEMPERATURE = f"min_{READING_TEMPERATURE}" +CONF_MAX_TEMPERATURE = f"max_{READING_TEMPERATURE}" +CONF_MIN_MOISTURE = f"min_{READING_MOISTURE}" +CONF_MAX_MOISTURE = f"max_{READING_MOISTURE}" +CONF_MIN_CONDUCTIVITY = f"min_{READING_CONDUCTIVITY}" +CONF_MAX_CONDUCTIVITY = f"max_{READING_CONDUCTIVITY}" +CONF_MIN_BRIGHTNESS = f"min_{READING_BRIGHTNESS}" +CONF_MAX_BRIGHTNESS = f"max_{READING_BRIGHTNESS}" +CONF_CHECK_DAYS = "check_days" + +DEFAULT_MIN_BATTERY_LEVEL = 20 +DEFAULT_MIN_MOISTURE = 20 +DEFAULT_MAX_MOISTURE = 60 +DEFAULT_MIN_CONDUCTIVITY = 500 +DEFAULT_MAX_CONDUCTIVITY = 3000 +DEFAULT_CHECK_DAYS = 3 + +ATTR_PROBLEM = "problem" +ATTR_SENSORS = "sensors" +PROBLEM_NONE = "none" +ATTR_MAX_BRIGHTNESS_HISTORY = "max_brightness" + +# we're not returning only one value, we're returning a dict here. So we need +# to have a separate literal for it to avoid confusion. +ATTR_DICT_OF_UNITS_OF_MEASUREMENT = "unit_of_measurement_dict" diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 559f4440ae..59ae14b8ca 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -35,6 +35,7 @@ from .const import ( CONF_SERVER_IDENTIFIER, DISPATCHERS, DOMAIN, + INVALID_TOKEN_MESSAGE, PLATFORMS, PLATFORMS_COMPLETED, PLEX_SERVER_CONFIG, @@ -153,6 +154,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: plexapi.exceptions.BadRequest, plexapi.exceptions.NotFound, ) as error: + if INVALID_TOKEN_MESSAGE in str(error): + raise ConfigEntryAuthFailed( + "Token not accepted, please reauthenticate Plex server" + f" '{entry.data[CONF_SERVER]}'" + ) from error _LOGGER.error( "Login to %s failed, verify token and SSL settings: [%s]", entry.data[CONF_SERVER], diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index 3f761c9748..7936cb6e6c 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -57,3 +57,5 @@ SERVICE_REFRESH_LIBRARY = "refresh_library" SERVICE_SCAN_CLIENTS = "scan_for_clients" PLEX_URI_SCHEME = "plex://" + +INVALID_TOKEN_MESSAGE = "Invalid token" diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index c1a3ac5bd3..be57267960 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -541,7 +541,9 @@ class PlexMediaPlayer(MediaPlayerEntity): ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" is_internal = is_internal_request(self.hass) diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index 89349e1f0b..bfae7772b9 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -1,23 +1,84 @@ """Plugwise platform for Home Assistant Core.""" +from __future__ import annotations + +from typing import Any from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er -from .gateway import async_setup_entry_gw, async_unload_entry_gw +from .const import DOMAIN, LOGGER, PLATFORMS +from .coordinator import PlugwiseDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Plugwise components from a config entry.""" - if entry.data.get(CONF_HOST): - return await async_setup_entry_gw(hass, entry) - # PLACEHOLDER USB entry setup - return False + await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry) + + coordinator = PlugwiseDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + migrate_sensor_entities(hass, coordinator) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, str(coordinator.api.gateway_id))}, + manufacturer="Plugwise", + model=coordinator.api.smile_model, + name=coordinator.api.smile_name, + sw_version=coordinator.api.smile_version[0], + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload the Plugwise components.""" - if entry.data.get(CONF_HOST): - return await async_unload_entry_gw(hass, entry) - # PLACEHOLDER USB entry setup - return False + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +@callback +def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None: + """Migrate Plugwise entity entries. + + - Migrates unique ID from old relay switches to the new unique ID + """ + if entry.domain == Platform.SWITCH and entry.unique_id.endswith("-plug"): + return {"new_unique_id": entry.unique_id.replace("-plug", "-relay")} + + # No migration needed + return None + + +def migrate_sensor_entities( + hass: HomeAssistant, + coordinator: PlugwiseDataUpdateCoordinator, +) -> None: + """Migrate Sensors if needed.""" + ent_reg = er.async_get(hass) + + # Migrating opentherm_outdoor_temperature + # to opentherm_outdoor_air_temperature sensor + for device_id, device in coordinator.data.devices.items(): + if device.get("dev_class") != "heater_central": + continue + + old_unique_id = f"{device_id}-outdoor_temperature" + if entity_id := ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, old_unique_id + ): + new_unique_id = f"{device_id}-outdoor_air_temperature" + LOGGER.debug( + "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", + entity_id, + old_unique_id, + new_unique_id, + ) + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 8040e33972..3fce1509c0 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -31,26 +31,27 @@ class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( PlugwiseBinarySensorEntityDescription( key="compressor_state", - name="Compressor state", + translation_key="compressor_state", icon="mdi:hvac", icon_off="mdi:hvac-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="cooling_enabled", - name="Cooling enabled", + translation_key="cooling_enabled", icon="mdi:snowflake-thermometer", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="dhw_state", - name="DHW state", + translation_key="dhw_state", icon="mdi:water-pump", icon_off="mdi:water-pump-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="flame_state", + translation_key="flame_state", name="Flame state", icon="mdi:fire", icon_off="mdi:fire-off", @@ -58,28 +59,28 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( ), PlugwiseBinarySensorEntityDescription( key="heating_state", - name="Heating", + translation_key="heating_state", icon="mdi:radiator", icon_off="mdi:radiator-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="cooling_state", - name="Cooling", + translation_key="cooling_state", icon="mdi:snowflake", icon_off="mdi:snowflake-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="slave_boiler_state", - name="Secondary boiler state", + translation_key="slave_boiler_state", icon="mdi:fire", icon_off="mdi:circle-off-outline", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="plugwise_notification", - name="Plugwise notification", + translation_key="plugwise_notification", icon="mdi:mailbox-up-outline", icon_off="mdi:mailbox-outline", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index d36ae1ba04..89c1b6eab5 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from plugwise import Smile from plugwise.exceptions import ( ConnectionFailedError, InvalidAuthentication, @@ -11,7 +12,6 @@ from plugwise.exceptions import ( ResponseError, UnsupportedDeviceError, ) -from plugwise.smile import Smile import voluptuous as vol from homeassistant.components.zeroconf import ZeroconfServiceInfo diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index dd13e0e509..34bb5c926a 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -21,7 +21,7 @@ SMILE: Final = "smile" STRETCH: Final = "stretch" STRETCH_USERNAME: Final = "stretch" -PLATFORMS_GATEWAY: Final[list[str]] = [ +PLATFORMS: Final[list[str]] = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.NUMBER, diff --git a/homeassistant/components/plugwise/gateway.py b/homeassistant/components/plugwise/gateway.py deleted file mode 100644 index 282fd163e8..0000000000 --- a/homeassistant/components/plugwise/gateway.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Plugwise platform for Home Assistant Core.""" -from __future__ import annotations - -from typing import Any - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, entity_registry as er - -from .const import DOMAIN, LOGGER, PLATFORMS_GATEWAY, Platform -from .coordinator import PlugwiseDataUpdateCoordinator - - -async def async_setup_entry_gw(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Plugwise Smiles from a config entry.""" - await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry) - - coordinator = PlugwiseDataUpdateCoordinator(hass, entry) - await coordinator.async_config_entry_first_refresh() - migrate_sensor_entities(hass, coordinator) - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, str(coordinator.api.gateway_id))}, - manufacturer="Plugwise", - model=coordinator.api.smile_model, - name=coordinator.api.smile_name, - sw_version=coordinator.api.smile_version[0], - ) - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS_GATEWAY) - - return True - - -async def async_unload_entry_gw(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms( - entry, PLATFORMS_GATEWAY - ): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok - - -@callback -def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None: - """Migrate Plugwise entity entries. - - - Migrates unique ID from old relay switches to the new unique ID - """ - if entry.domain == Platform.SWITCH and entry.unique_id.endswith("-plug"): - return {"new_unique_id": entry.unique_id.replace("-plug", "-relay")} - - # No migration needed - return None - - -def migrate_sensor_entities( - hass: HomeAssistant, - coordinator: PlugwiseDataUpdateCoordinator, -) -> None: - """Migrate Sensors if needed.""" - ent_reg = er.async_get(hass) - - # Migrating opentherm_outdoor_temperature - # to opentherm_outdoor_air_temperature sensor - for device_id, device in coordinator.data.devices.items(): - if device.get("dev_class") != "heater_central": - continue - - old_unique_id = f"{device_id}-outdoor_temperature" - if entity_id := ent_reg.async_get_entity_id( - Platform.SENSOR, DOMAIN, old_unique_id - ): - new_unique_id = f"{device_id}-outdoor_air_temperature" - LOGGER.debug( - "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", - entity_id, - old_unique_id, - new_unique_id, - ) - ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index f012e52b26..a8a1744c95 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -1,12 +1,12 @@ { "domain": "plugwise", "name": "Plugwise", - "codeowners": ["@CoMPaTech", "@bouwew", "@brefra", "@frenck"], + "codeowners": ["@CoMPaTech", "@bouwew", "@frenck"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/plugwise", "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.27.5"], + "requirements": ["plugwise==0.31.0"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 904b0de7a4..5a3e394b11 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -43,9 +43,9 @@ class PlugwiseNumberEntityDescription( NUMBER_TYPES = ( PlugwiseNumberEntityDescription( key="maximum_boiler_temperature", + translation_key="maximum_boiler_temperature", command=lambda api, number, value: api.set_number_setpoint(number, value), device_class=NumberDeviceClass.TEMPERATURE, - name="Maximum boiler temperature setpoint", entity_category=EntityCategory.CONFIG, native_max_value_key="upper_bound", native_min_value_key="lower_bound", diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 67779386a3..c45d47004b 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -37,7 +37,7 @@ class PlugwiseSelectEntityDescription( SELECT_TYPES = ( PlugwiseSelectEntityDescription( key="select_schedule", - name="Thermostat schedule", + 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", @@ -45,20 +45,18 @@ SELECT_TYPES = ( ), PlugwiseSelectEntityDescription( key="select_regulation_mode", - name="Regulation mode", + translation_key="regulation_mode", icon="mdi:hvac", entity_category=EntityCategory.CONFIG, - translation_key="regulation_mode", command=lambda api, loc, opt: api.set_regulation_mode(opt), current_option_key="regulation_mode", options_key="regulation_modes", ), PlugwiseSelectEntityDescription( key="select_dhw_mode", - name="DHW mode", + translation_key="dhw_mode", icon="mdi:shower", entity_category=EntityCategory.CONFIG, - translation_key="dhw_mode", command=lambda api, loc, opt: api.set_dhw_mode(opt), current_option_key="dhw_mode", options_key="dhw_modes", diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 7d9f32f265..afc921f110 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -26,38 +26,94 @@ } }, "entity": { + "binary_sensor": { + "compressor_state": { + "name": "Compressor state" + }, + "cooling_enabled": { + "name": "Cooling enabled" + }, + "dhw_state": { + "name": "DHW state" + }, + "flame_state": { + "name": "Flame state" + }, + "heating_state": { + "name": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::heating%]" + }, + "cooling_state": { + "name": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]" + }, + "slave_boiler_state": { + "name": "Secondary boiler state" + }, + "plugwise_notification": { + "name": "Plugwise notification" + } + }, "climate": { "plugwise": { "state_attributes": { + "available_schemas": { + "name": "Available schemas" + }, "preset_mode": { "state": { "asleep": "Night", - "away": "Away", - "home": "Home", + "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "home": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::home%]", "no_frost": "Anti-frost", "vacation": "Vacation" } + }, + "selected_schema": { + "name": "Selected schema" } } } }, + "number": { + "maximum_boiler_temperature": { + "name": "Maximum boiler temperature setpoint" + } + }, "select": { "dhw_mode": { + "name": "DHW mode", "state": { - "off": "Off", + "off": "[%key:common::state::off%]", "auto": "Auto", - "boost": "Boost", - "comfort": "Comfort" + "boost": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::boost%]", + "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]" } }, "regulation_mode": { + "name": "Regulation mode", "state": { "bleeding_cold": "Bleeding cold", "bleeding_hot": "Bleeding hot", - "cooling": "Cooling", - "heating": "Heating", - "off": "Off" + "cooling": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]", + "heating": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::heating%]", + "off": "[%key:common::state::off%]" } + }, + "select_schedule": { + "name": "Thermostat schedule" + } + }, + "switch": { + "cooling_ena_switch": { + "name": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]" + }, + "dhw_cm_switch": { + "name": "DHW comfort mode" + }, + "lock": { + "name": "[%key:component::lock::entity_component::_::name%]" + }, + "relay": { + "name": "Relay" } } } diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index 2050de5fb9..d6f8a420cc 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -21,19 +21,19 @@ from .util import plugwise_command SWITCHES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( key="dhw_cm_switch", - name="DHW comfort mode", + translation_key="dhw_cm_switch", icon="mdi:water-plus", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key="lock", - name="Lock", + translation_key="lock", icon="mdi:lock", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key="relay", - name="Relay", + translation_key="relay", device_class=SwitchDeviceClass.SWITCH, ), SwitchEntityDescription( diff --git a/homeassistant/components/pocketcasts/sensor.py b/homeassistant/components/pocketcasts/sensor.py index 3962ae4c06..c541e2cc0f 100644 --- a/homeassistant/components/pocketcasts/sensor.py +++ b/homeassistant/components/pocketcasts/sensor.py @@ -16,7 +16,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -ICON = "mdi:rss" SENSOR_NAME = "Pocketcasts unlistened episodes" @@ -48,6 +47,8 @@ def setup_platform( class PocketCastsSensor(SensorEntity): """Representation of a pocket casts sensor.""" + _attr_icon = "mdi:rss" + def __init__(self, api): """Initialize the sensor.""" self._api = api @@ -63,11 +64,6 @@ class PocketCastsSensor(SensorEntity): """Return the sensor state.""" return self._state - @property - def icon(self): - """Return the icon for the sensor.""" - return ICON - def update(self) -> None: """Update sensor values.""" try: diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index 213b7cc03d..6306d52838 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -3,15 +3,15 @@ "flow_title": "{name} ({ip_address})", "step": { "user": { - "title": "Connect to the powerwall", - "description": "The password is usually the last 5 characters of the serial number for Backup Gateway and can be found in the Tesla app or the last 5 characters of the password found inside the door for Backup Gateway 2.", + "title": "Connect to the Powerwall", + "description": "The default password is printed inside the Backup Gateway for newer models. For older models, the default password is the last five characters of the serial number for Backup Gateway and can be found in the Tesla app.", "data": { "ip_address": "[%key:common::config_flow::data::ip%]", "password": "[%key:common::config_flow::data::password%]" } }, - "reauth_confim": { - "title": "Reauthenticate the powerwall", + "reauth_confirm": { + "title": "Reauthenticate the Powerwall", "description": "[%key:component::powerwall::config::step::user::description%]", "data": { "password": "[%key:common::config_flow::data::password%]" @@ -24,7 +24,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "wrong_version": "Your powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved.", + "wrong_version": "Your Powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved.", "unknown": "[%key:common::config_flow::error::unknown%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index fab6932edd..f558b7301c 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -1,6 +1,8 @@ """The profiler integration.""" import asyncio +from contextlib import suppress from datetime import timedelta +from functools import _lru_cache_wrapper import logging import reprlib import sys @@ -9,12 +11,13 @@ import time import traceback from typing import Any, cast +from lru import LRU # pylint: disable=no-name-in-module import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval @@ -26,10 +29,25 @@ SERVICE_START = "start" SERVICE_MEMORY = "memory" SERVICE_START_LOG_OBJECTS = "start_log_objects" SERVICE_STOP_LOG_OBJECTS = "stop_log_objects" +SERVICE_START_LOG_OBJECT_SOURCES = "start_log_object_sources" +SERVICE_STOP_LOG_OBJECT_SOURCES = "stop_log_object_sources" SERVICE_DUMP_LOG_OBJECTS = "dump_log_objects" +SERVICE_LRU_STATS = "lru_stats" SERVICE_LOG_THREAD_FRAMES = "log_thread_frames" SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled" +_LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__ +_SQLALCHEMY_LRU_OBJECT = "LRUCache" + +_KNOWN_LRU_CLASSES = ( + "EventDataManager", + "EventTypeManager", + "StatesMetaManager", + "StateAttributesManager", + "StatisticsMetaManager", + "DomainData", + "IntegrationMatcher", +) SERVICES = ( SERVICE_START, @@ -37,20 +55,27 @@ SERVICES = ( SERVICE_START_LOG_OBJECTS, SERVICE_STOP_LOG_OBJECTS, SERVICE_DUMP_LOG_OBJECTS, + SERVICE_LRU_STATS, SERVICE_LOG_THREAD_FRAMES, SERVICE_LOG_EVENT_LOOP_SCHEDULED, ) DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) +DEFAULT_MAX_OBJECTS = 5 + CONF_SECONDS = "seconds" +CONF_MAX_OBJECTS = "max_objects" LOG_INTERVAL_SUB = "log_interval_subscription" + _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( # noqa: C901 + hass: HomeAssistant, entry: ConfigEntry +) -> bool: """Set up Profiler from a config entry.""" lock = asyncio.Lock() domain_data = hass.data[DOMAIN] = {} @@ -65,7 +90,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_start_log_objects(call: ServiceCall) -> None: if LOG_INTERVAL_SUB in domain_data: - domain_data[LOG_INTERVAL_SUB]() + raise HomeAssistantError("Object logging already started") persistent_notification.async_create( hass, @@ -83,21 +108,53 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_stop_log_objects(call: ServiceCall) -> None: if LOG_INTERVAL_SUB not in domain_data: - return + raise HomeAssistantError("Object logging not running") persistent_notification.async_dismiss(hass, "profile_object_logging") domain_data.pop(LOG_INTERVAL_SUB)() - def _safe_repr(obj: Any) -> str: - """Get the repr of an object but keep going if there is an exception. + async def _async_start_object_sources(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB in domain_data: + raise HomeAssistantError("Object logging already started") - We wrap repr to ensure if one object cannot be serialized, we can - still get the rest. - """ - try: - return repr(obj) - except Exception: # pylint: disable=broad-except - return f"Failed to serialize {type(obj)}" + persistent_notification.async_create( + hass, + ( + "Object source logging has started. See [the logs](/config/logs) to" + " track the growth of new objects." + ), + title="Object source logging started", + notification_id="profile_object_source_logging", + ) + + last_ids: set[int] = set() + last_stats: dict[str, int] = {} + + async def _log_object_sources_with_max(*_: Any) -> None: + await hass.async_add_executor_job( + _log_object_sources, call.data[CONF_MAX_OBJECTS], last_ids, last_stats + ) + + await _log_object_sources_with_max() + cancel_track = async_track_time_interval( + hass, _log_object_sources_with_max, call.data[CONF_SCAN_INTERVAL] + ) + + @callback + def _cancel(): + cancel_track() + last_ids.clear() + last_stats.clear() + + domain_data[LOG_INTERVAL_SUB] = _cancel + + @callback + def _async_stop_object_sources(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB not in domain_data: + raise HomeAssistantError("Object logging not running") + + persistent_notification.async_dismiss(hass, "profile_object_source_logging") + domain_data.pop(LOG_INTERVAL_SUB)() def _dump_log_objects(call: ServiceCall) -> None: # Imports deferred to avoid loading modules @@ -107,11 +164,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: obj_type = call.data[CONF_TYPE] - _LOGGER.critical( - "%s objects in memory: %s", - obj_type, - [_safe_repr(obj) for obj in objgraph.by_type(obj_type)], - ) + for obj in objgraph.by_type(obj_type): + _LOGGER.critical( + "%s object in memory: %s", + obj_type, + _safe_repr(obj), + ) persistent_notification.create( hass, @@ -123,6 +181,50 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: notification_id="profile_object_dump", ) + def _lru_stats(call: ServiceCall) -> None: + """Log the stats of all lru caches.""" + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import objgraph # pylint: disable=import-outside-toplevel + + for lru in objgraph.by_type(_LRU_CACHE_WRAPPER_OBJECT): + lru = cast(_lru_cache_wrapper, lru) + _LOGGER.critical( + "Cache stats for lru_cache %s at %s: %s", + lru.__wrapped__, + _get_function_absfile(lru.__wrapped__) or "unknown", + lru.cache_info(), + ) + + for _class in _KNOWN_LRU_CLASSES: + for class_with_lru_attr in objgraph.by_type(_class): + for maybe_lru in class_with_lru_attr.__dict__.values(): + if isinstance(maybe_lru, LRU): + _LOGGER.critical( + "Cache stats for LRU %s at %s: %s", + type(class_with_lru_attr), + _get_function_absfile(class_with_lru_attr) or "unknown", + maybe_lru.get_stats(), + ) + + for lru in objgraph.by_type(_SQLALCHEMY_LRU_OBJECT): + if (data := getattr(lru, "_data", None)) and isinstance(data, dict): + for key, value in dict(data).items(): + _LOGGER.critical( + "Cache data for sqlalchemy LRUCache %s: %s: %s", lru, key, value + ) + + persistent_notification.create( + hass, + ( + "LRU cache states have been dumped to the log. See [the" + " logs](/config/logs) to review the stats." + ), + title="LRU stats completed", + notification_id="profile_lru_stats", + ) + async def _async_dump_thread_frames(call: ServiceCall) -> None: """Log all thread frames.""" frames = sys._current_frames() # pylint: disable=protected-access @@ -194,6 +296,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _async_stop_log_objects, ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_START_LOG_OBJECT_SOURCES, + _async_start_object_sources, + schema=vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + vol.Optional(CONF_MAX_OBJECTS, default=DEFAULT_MAX_OBJECTS): vol.Range( + min=1, max=1024 + ), + } + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_STOP_LOG_OBJECT_SOURCES, + _async_stop_object_sources, + ) + async_register_admin_service( hass, DOMAIN, @@ -202,6 +328,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: schema=vol.Schema({vol.Required(CONF_TYPE): str}), ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LRU_STATS, + _lru_stats, + ) + async_register_admin_service( hass, DOMAIN, @@ -323,4 +456,102 @@ def _log_objects(*_): # integration is used at a time import objgraph # pylint: disable=import-outside-toplevel - _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=100)) + _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000)) + + +def _get_function_absfile(func: Any) -> str | None: + """Get the absolute file path of a function.""" + import inspect # pylint: disable=import-outside-toplevel + + abs_file: str | None = None + with suppress(Exception): + abs_file = inspect.getabsfile(func) + return abs_file + + +def _safe_repr(obj: Any) -> str: + """Get the repr of an object but keep going if there is an exception. + + We wrap repr to ensure if one object cannot be serialized, we can + still get the rest. + """ + try: + return repr(obj) + except Exception: # pylint: disable=broad-except + return f"Failed to serialize {type(obj)}" + + +def _find_backrefs_not_to_self(_object: Any) -> list[str]: + import objgraph # pylint: disable=import-outside-toplevel + + return [ + _safe_repr(backref) + for backref in objgraph.find_backref_chain( + _object, lambda obj: obj is not _object + ) + ] + + +def _log_object_sources( + max_objects: int, last_ids: set[int], last_stats: dict[str, int] +) -> None: + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import gc # pylint: disable=import-outside-toplevel + + gc.collect() + + objects = gc.get_objects() + new_objects: list[object] = [] + new_objects_overflow: dict[str, int] = {} + current_ids = set() + new_stats: dict[str, int] = {} + had_new_object_growth = False + try: + for _object in objects: + object_type = type(_object).__name__ + new_stats[object_type] = new_stats.get(object_type, 0) + 1 + + for _object in objects: + id_ = id(_object) + current_ids.add(id_) + if id_ in last_ids: + continue + object_type = type(_object).__name__ + if last_stats.get(object_type, 0) < new_stats[object_type]: + if len(new_objects) < max_objects: + new_objects.append(_object) + else: + new_objects_overflow.setdefault(object_type, 0) + new_objects_overflow[object_type] += 1 + + for _object in new_objects: + had_new_object_growth = True + object_type = type(_object).__name__ + _LOGGER.critical( + "New object %s (%s/%s) at %s: %s", + object_type, + last_stats.get(object_type, 0), + new_stats[object_type], + _get_function_absfile(_object) or _find_backrefs_not_to_self(_object), + _safe_repr(_object), + ) + + for object_type, count in last_stats.items(): + new_stats[object_type] = max(new_stats.get(object_type, 0), count) + finally: + # Break reference cycles + del objects + del new_objects + last_ids.clear() + last_ids.update(current_ids) + last_stats.clear() + last_stats.update(new_stats) + del new_stats + del current_ids + + if new_objects_overflow: + _LOGGER.critical("New objects overflowed by %s", new_objects_overflow) + elif not had_new_object_growth: + _LOGGER.critical("No new object growth found") diff --git a/homeassistant/components/profiler/services.yaml b/homeassistant/components/profiler/services.yaml index 8d9ae35ed1..3bd6d7636a 100644 --- a/homeassistant/components/profiler/services.yaml +++ b/homeassistant/components/profiler/services.yaml @@ -25,7 +25,7 @@ memory: max: 3600 unit_of_measurement: seconds start_log_objects: - name: Start log objects + name: Start logging objects description: Start logging growth of objects in memory fields: scan_interval: @@ -38,7 +38,7 @@ start_log_objects: max: 3600 unit_of_measurement: seconds stop_log_objects: - name: Stop log objects + name: Stop logging objects description: Stop logging growth of objects in memory. dump_log_objects: name: Dump log objects @@ -51,6 +51,34 @@ dump_log_objects: example: State selector: text: +start_log_object_sources: + name: Start logging object sources + description: Start logging sources of new objects in memory + fields: + scan_interval: + name: Scan interval + description: The number of seconds between logging objects. + default: 30.0 + selector: + number: + min: 1 + max: 3600 + unit_of_measurement: seconds + max_objects: + name: Maximum objects + description: The maximum number of objects to log. + default: 5 + selector: + number: + min: 1 + max: 30 + unit_of_measurement: objects +stop_log_object_sources: + name: Stop logging object sources + description: Stop logging sources of new objects in memory. +lru_stats: + name: Log LRU stats + description: Log the stats of all lru caches. log_thread_frames: name: Log thread frames description: Log the current frames for all threads. diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index cfcb07773f..b05a5f245f 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -72,7 +72,7 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): """Update alarm status.""" try: - self._installation = await Installation.retrieve(self._auth) + self._installation = await Installation.retrieve(self._auth, self.contract) except ConnectionError as err: _LOGGER.error(err) self._attr_available = False diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py index 848b763903..9041a6526f 100644 --- a/homeassistant/components/prosegur/camera.py +++ b/homeassistant/components/prosegur/camera.py @@ -34,7 +34,9 @@ async def async_setup_entry( "async_request_image", ) - _installation = await Installation.retrieve(hass.data[DOMAIN][entry.entry_id]) + _installation = await Installation.retrieve( + hass.data[DOMAIN][entry.entry_id], entry.data["contract"] + ) async_add_entities( [ diff --git a/homeassistant/components/prosegur/config_flow.py b/homeassistant/components/prosegur/config_flow.py index ee2fa795f2..ea975529b0 100644 --- a/homeassistant/components/prosegur/config_flow.py +++ b/homeassistant/components/prosegur/config_flow.py @@ -11,9 +11,9 @@ from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client +from homeassistant.helpers import aiohttp_client, selector -from .const import CONF_COUNTRY, DOMAIN +from .const import CONF_CONTRACT, CONF_COUNTRY, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -31,27 +31,22 @@ async def validate_input(hass: core.HomeAssistant, data): session = aiohttp_client.async_get_clientsession(hass) auth = Auth(session, data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_COUNTRY]) try: - install = await Installation.retrieve(auth) + contracts = await Installation.list(auth) + return auth, contracts except ConnectionRefusedError: raise InvalidAuth from ConnectionRefusedError except ConnectionError: raise CannotConnect from ConnectionError - # Info to store in the config entry. - return { - "title": f"Contract {install.contract}", - "contract": install.contract, - "username": data[CONF_USERNAME], - "password": data[CONF_PASSWORD], - "country": data[CONF_COUNTRY], - } - class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Prosegur Alarm.""" VERSION = 1 entry: ConfigEntry + auth: Auth + user_input: dict + contracts: list[dict[str, str]] async def async_step_user(self, user_input=None): """Handle the initial step.""" @@ -59,7 +54,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input: try: - info = await validate_input(self.hass, user_input) + self.auth, self.contracts = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -68,16 +63,44 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception(exception) errors["base"] = "unknown" else: - await self.async_set_unique_id(info["contract"]) - self._abort_if_unique_id_configured() - - user_input["contract"] = info["contract"] - return self.async_create_entry(title=info["title"], data=user_input) + self.user_input = user_input + return await self.async_step_choose_contract() return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_choose_contract( + self, user_input: Any | None = None + ) -> FlowResult: + """Let user decide which contract is being setup.""" + + if user_input: + await self.async_set_unique_id(user_input[CONF_CONTRACT]) + self._abort_if_unique_id_configured() + + self.user_input[CONF_CONTRACT] = user_input[CONF_CONTRACT] + + return self.async_create_entry( + title=f"Contract {user_input[CONF_CONTRACT]}", data=self.user_input + ) + + contract_options = [ + selector.SelectOptionDict(value=c["contractId"], label=c["description"]) + for c in self.contracts + ] + + return self.async_show_form( + step_id="choose_contract", + data_schema=vol.Schema( + { + vol.Required(CONF_CONTRACT): selector.SelectSelector( + selector.SelectSelectorConfig(options=contract_options) + ), + } + ), + ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Prosegur.""" self.entry = cast( @@ -93,7 +116,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input: try: user_input[CONF_COUNTRY] = self.entry.data[CONF_COUNTRY] - await validate_input(self.hass, user_input) + self.auth, self.contracts = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/prosegur/const.py b/homeassistant/components/prosegur/const.py index 3f5b869197..ea823e7606 100644 --- a/homeassistant/components/prosegur/const.py +++ b/homeassistant/components/prosegur/const.py @@ -3,5 +3,6 @@ DOMAIN = "prosegur" CONF_COUNTRY = "country" +CONF_CONTRACT = "contract" SERVICE_REQUEST_IMAGE = "request_image" diff --git a/homeassistant/components/prosegur/diagnostics.py b/homeassistant/components/prosegur/diagnostics.py index d244569834..59b51f5b5d 100644 --- a/homeassistant/components/prosegur/diagnostics.py +++ b/homeassistant/components/prosegur/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import CONF_CONTRACT, DOMAIN TO_REDACT = {"description", "latitude", "longitude", "contractId", "address"} @@ -19,7 +19,9 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - installation = await Installation.retrieve(hass.data[DOMAIN][entry.entry_id]) + installation = await Installation.retrieve( + hass.data[DOMAIN][entry.entry_id], entry.data[CONF_CONTRACT] + ) activity = await installation.activity(hass.data[DOMAIN][entry.entry_id]) diff --git a/homeassistant/components/prosegur/manifest.json b/homeassistant/components/prosegur/manifest.json index d5081a82db..adf5e985fe 100644 --- a/homeassistant/components/prosegur/manifest.json +++ b/homeassistant/components/prosegur/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/prosegur", "iot_class": "cloud_polling", "loggers": ["pyprosegur"], - "requirements": ["pyprosegur==0.0.8"] + "requirements": ["pyprosegur==0.0.9"] } diff --git a/homeassistant/components/prosegur/strings.json b/homeassistant/components/prosegur/strings.json index bf0beb4e76..a6c7fcc4a7 100644 --- a/homeassistant/components/prosegur/strings.json +++ b/homeassistant/components/prosegur/strings.json @@ -8,6 +8,11 @@ "country": "Country" } }, + "choose_contract": { + "data": { + "contract": "Contract" + } + }, "reauth_confirm": { "data": { "description": "Re-authenticate with Prosegur account.", diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 8e8842abf4..1c22ca50c2 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -2,10 +2,9 @@ from __future__ import annotations from datetime import timedelta -import logging +from typing import Any -from proxmoxer import ProxmoxAPI -from proxmoxer.backends.https import AuthenticationError +from proxmoxer import AuthenticationError, ProxmoxAPI from proxmoxer.core import ResourceException import requests.exceptions from requests.exceptions import ConnectTimeout, SSLError @@ -23,31 +22,27 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + _LOGGER, + CONF_CONTAINERS, + CONF_NODE, + CONF_NODES, + CONF_REALM, + CONF_VMS, + COORDINATORS, + DEFAULT_PORT, + DEFAULT_REALM, + DEFAULT_VERIFY_SSL, + DOMAIN, + PROXMOX_CLIENTS, + TYPE_CONTAINER, + TYPE_VM, + UPDATE_INTERVAL, ) PLATFORMS = [Platform.BINARY_SENSOR] -DOMAIN = "proxmoxve" -PROXMOX_CLIENTS = "proxmox_clients" -CONF_REALM = "realm" -CONF_NODE = "node" -CONF_NODES = "nodes" -CONF_VMS = "vms" -CONF_CONTAINERS = "containers" - -COORDINATORS = "coordinators" -API_DATA = "api_data" - -DEFAULT_PORT = 8006 -DEFAULT_REALM = "pam" -DEFAULT_VERIFY_SSL = True -TYPE_VM = 0 -TYPE_CONTAINER = 1 -UPDATE_INTERVAL = 60 - -_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( { @@ -139,7 +134,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await hass.async_add_executor_job(build_client) - coordinators: dict[str, dict[str, dict[int, DataUpdateCoordinator]]] = {} + coordinators: dict[ + str, dict[str, dict[int, DataUpdateCoordinator[dict[str, Any] | None]]] + ] = {} hass.data[DOMAIN][COORDINATORS] = coordinators # Create a coordinator for each vm/container @@ -188,14 +185,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: def create_coordinator_container_vm( - hass, proxmox, host_name, node_name, vm_id, vm_type -): + hass: HomeAssistant, + proxmox: ProxmoxAPI, + host_name: str, + node_name: str, + vm_id: int, + vm_type: int, +) -> DataUpdateCoordinator[dict[str, Any] | None]: """Create and return a DataUpdateCoordinator for a vm/container.""" - async def async_update_data(): + async def async_update_data() -> dict[str, Any] | None: """Call the api and handle the response.""" - def poll_api(): + def poll_api() -> dict[str, Any] | None: """Call the api.""" vm_status = call_api_container_vm(proxmox, node_name, vm_id, vm_type) return vm_status @@ -219,7 +221,7 @@ def create_coordinator_container_vm( ) -def parse_api_container_vm(status): +def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]: """Get the container or vm api data and return it formatted in a dictionary. It is implemented in this way to allow for more data to be added for sensors @@ -229,7 +231,12 @@ def parse_api_container_vm(status): return {"status": status["status"], "name": status["name"]} -def call_api_container_vm(proxmox, node_name, vm_id, machine_type): +def call_api_container_vm( + proxmox: ProxmoxAPI, + node_name: str, + vm_id: int, + machine_type: int, +) -> dict[str, Any] | None: """Make proper api calls.""" status = None @@ -244,58 +251,20 @@ def call_api_container_vm(proxmox, node_name, vm_id, machine_type): return status -class ProxmoxEntity(CoordinatorEntity): - """Represents any entity created for the Proxmox VE platform.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - unique_id, - name, - icon, - host_name, - node_name, - vm_id=None, - ) -> None: - """Initialize the Proxmox entity.""" - super().__init__(coordinator) - - self.coordinator = coordinator - self._unique_id = unique_id - self._name = name - self._host_name = host_name - self._icon = icon - self._available = True - self._node_name = node_name - self._vm_id = vm_id - - self._state = None - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self) -> str: - """Return the mdi icon of the entity.""" - return self._icon - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.coordinator.last_update_success and self._available - - class ProxmoxClient: """A wrapper for the proxmoxer ProxmoxAPI client.""" - def __init__(self, host, port, user, realm, password, verify_ssl): + _proxmox: ProxmoxAPI + + def __init__( + self, + host: str, + port: int, + user: str, + realm: str, + password: str, + verify_ssl: bool, + ) -> None: """Initialize the ProxmoxClient.""" self._host = host @@ -305,10 +274,7 @@ class ProxmoxClient: self._password = password self._verify_ssl = verify_ssl - self._proxmox = None - self._connection_start_time = None - - def build_client(self): + def build_client(self) -> None: """Construct the ProxmoxAPI client. Allows inserting the realm within the `user` value. @@ -327,6 +293,6 @@ class ProxmoxClient: verify_ssl=self._verify_ssl, ) - def get_api_client(self): + def get_api_client(self) -> ProxmoxAPI: """Return the ProxmoxAPI client.""" return self._proxmox diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index 9bb78d46ea..ea02e547e9 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -10,7 +10,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import COORDINATORS, DOMAIN, PROXMOX_CLIENTS, ProxmoxEntity +from .const import COORDINATORS, DOMAIN, PROXMOX_CLIENTS +from .entity import ProxmoxEntity async def async_setup_platform( @@ -51,7 +52,13 @@ async def async_setup_platform( add_entities(sensors) -def create_binary_sensor(coordinator, host_name, node_name, vm_id, name): +def create_binary_sensor( + coordinator, + host_name: str, + node_name: str, + vm_id: int, + name: str, +) -> ProxmoxBinarySensor: """Create a binary sensor based on the given data.""" return ProxmoxBinarySensor( coordinator=coordinator, @@ -72,12 +79,12 @@ class ProxmoxBinarySensor(ProxmoxEntity, BinarySensorEntity): def __init__( self, coordinator: DataUpdateCoordinator, - unique_id, - name, - icon, - host_name, - node_name, - vm_id, + unique_id: str, + name: str, + icon: str, + host_name: str, + node_name: str, + vm_id: int, ) -> None: """Create the binary sensor for vms or containers.""" super().__init__( @@ -85,7 +92,7 @@ class ProxmoxBinarySensor(ProxmoxEntity, BinarySensorEntity): ) @property - def is_on(self): + def is_on(self) -> bool | None: """Return the state of the binary sensor.""" if (data := self.coordinator.data) is None: return None diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py new file mode 100644 index 0000000000..6477c08146 --- /dev/null +++ b/homeassistant/components/proxmoxve/const.py @@ -0,0 +1,22 @@ +"""Constants for ProxmoxVE.""" + +import logging + +DOMAIN = "proxmoxve" +PROXMOX_CLIENTS = "proxmox_clients" +CONF_REALM = "realm" +CONF_NODE = "node" +CONF_NODES = "nodes" +CONF_VMS = "vms" +CONF_CONTAINERS = "containers" + +COORDINATORS = "coordinators" + +DEFAULT_PORT = 8006 +DEFAULT_REALM = "pam" +DEFAULT_VERIFY_SSL = True +TYPE_VM = 0 +TYPE_CONTAINER = 1 +UPDATE_INTERVAL = 60 + +_LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/proxmoxve/entity.py b/homeassistant/components/proxmoxve/entity.py new file mode 100644 index 0000000000..5dfd264df2 --- /dev/null +++ b/homeassistant/components/proxmoxve/entity.py @@ -0,0 +1,39 @@ +"""Proxmox parent entity class.""" + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + + +class ProxmoxEntity(CoordinatorEntity): + """Represents any entity created for the Proxmox VE platform.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + unique_id: str, + name: str, + icon: str, + host_name: str, + node_name: str, + vm_id: int | None = None, + ) -> None: + """Initialize the Proxmox entity.""" + super().__init__(coordinator) + + self.coordinator = coordinator + self._attr_unique_id = unique_id + self._attr_name = name + self._host_name = host_name + self._attr_icon = icon + self._available = True + self._node_name = node_name + self._vm_id = vm_id + + self._state = None + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.coordinator.last_update_success and self._available diff --git a/homeassistant/components/proxmoxve/manifest.json b/homeassistant/components/proxmoxve/manifest.json index 1c68069579..8cf3bc7932 100644 --- a/homeassistant/components/proxmoxve/manifest.json +++ b/homeassistant/components/proxmoxve/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/proxmoxve", "iot_class": "local_polling", "loggers": ["proxmoxer"], - "requirements": ["proxmoxer==1.3.1"] + "requirements": ["proxmoxer==2.0.1"] } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index ae977b16a4..7ebaa6e53d 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -3,5 +3,5 @@ "name": "Camera Proxy", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["pillow==9.4.0"] + "requirements": ["pillow==9.5.0"] } diff --git a/homeassistant/components/prusalink/button.py b/homeassistant/components/prusalink/button.py index f2dd939122..7e95b209ba 100644 --- a/homeassistant/components/prusalink/button.py +++ b/homeassistant/components/prusalink/button.py @@ -38,7 +38,7 @@ BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = { "printer": ( PrusaLinkButtonEntityDescription[PrinterInfo]( key="printer.cancel_job", - name="Cancel Job", + translation_key="cancel_job", icon="mdi:cancel", press_fn=lambda api: cast(Coroutine, api.cancel_job()), available_fn=lambda data: any( @@ -48,7 +48,7 @@ BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = { ), PrusaLinkButtonEntityDescription[PrinterInfo]( key="job.pause_job", - name="Pause Job", + translation_key="pause_job", icon="mdi:pause", press_fn=lambda api: cast(Coroutine, api.pause_job()), available_fn=lambda data: ( @@ -58,7 +58,7 @@ BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = { ), PrusaLinkButtonEntityDescription[PrinterInfo]( key="job.resume_job", - name="Resume Job", + translation_key="resume_job", icon="mdi:play", press_fn=lambda api: cast(Coroutine, api.resume_job()), available_fn=lambda data: cast(bool, data["state"]["flags"]["paused"]), diff --git a/homeassistant/components/prusalink/camera.py b/homeassistant/components/prusalink/camera.py index a6c16e2f5f..a8b8f387ef 100644 --- a/homeassistant/components/prusalink/camera.py +++ b/homeassistant/components/prusalink/camera.py @@ -24,7 +24,7 @@ class PrusaLinkJobPreviewEntity(PrusaLinkEntity, Camera): last_path = "" last_image: bytes - _attr_name = "Job Preview" + _attr_translation_key = "job_preview" def __init__(self, coordinator: JobUpdateCoordinator) -> None: """Initialize a PrusaLink camera entity.""" diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index 6b0e6189f4..cef2bdf2f6 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -12,6 +12,7 @@ from pyprusalink import InvalidAuth, PrusaLink import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -24,8 +25,8 @@ _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required("host"): str, - vol.Required("api_key"): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_API_KEY): str, } ) @@ -35,7 +36,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - api = PrusaLink(async_get_clientsession(hass), data["host"], data["api_key"]) + api = PrusaLink(async_get_clientsession(hass), data[CONF_HOST], data[CONF_API_KEY]) try: async with async_timeout.timeout(5): @@ -51,7 +52,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, except AwesomeVersionException as err: raise NotSupported from err - return {"title": version["hostname"]} + return {"title": version["hostname"] or version["text"]} class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -68,13 +69,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - host = user_input["host"].rstrip("/") + host = user_input[CONF_HOST].rstrip("/") if not host.startswith(("http://", "https://")): host = f"http://{host}" data = { - "host": host, - "api_key": user_input["api_key"], + CONF_HOST: host, + CONF_API_KEY: user_input[CONF_API_KEY], } errors = {} diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index 9edd09f0ff..4f93fd3407 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -65,7 +65,7 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { ), PrusaLinkSensorEntityDescription[PrinterInfo]( key="printer.telemetry.temp-bed", - name="Heatbed", + translation_key="heatbed_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -74,18 +74,57 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { ), PrusaLinkSensorEntityDescription[PrinterInfo]( key="printer.telemetry.temp-nozzle", - name="Nozzle Temperature", + translation_key="nozzle_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: cast(float, data["telemetry"]["temp-nozzle"]), entity_registry_enabled_default=False, ), + PrusaLinkSensorEntityDescription[PrinterInfo]( + key="printer.telemetry.temp-bed.target", + translation_key="heatbed_target_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data["temperature"]["bed"]["target"]), + entity_registry_enabled_default=False, + ), + PrusaLinkSensorEntityDescription[PrinterInfo]( + key="printer.telemetry.temp-nozzle.target", + translation_key="nozzle_target_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data["temperature"]["tool0"]["target"]), + entity_registry_enabled_default=False, + ), + PrusaLinkSensorEntityDescription[PrinterInfo]( + key="printer.telemetry.z-height", + translation_key="z_height", + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data["telemetry"]["z-height"]), + entity_registry_enabled_default=False, + ), + PrusaLinkSensorEntityDescription[PrinterInfo]( + key="printer.telemetry.print-speed", + translation_key="print_speed", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: cast(float, data["telemetry"]["print-speed"]), + ), + PrusaLinkSensorEntityDescription[PrinterInfo]( + key="printer.telemetry.material", + translation_key="material", + icon="mdi:palette-swatch-variant", + value_fn=lambda data: cast(str, data["telemetry"]["material"]), + ), ), "job": ( PrusaLinkSensorEntityDescription[JobInfo]( key="job.progress", - name="Progress", + translation_key="progress", icon="mdi:progress-clock", native_unit_of_measurement=PERCENTAGE, value_fn=lambda data: cast(float, data["progress"]["completion"]) * 100, @@ -93,14 +132,14 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.filename", - name="Filename", + translation_key="filename", icon="mdi:file-image-outline", value_fn=lambda data: cast(str, data["job"]["file"]["display"]), available_fn=lambda data: data.get("job") is not None, ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.start", - name="Print Start", + translation_key="print_start", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:clock-start", value_fn=ignore_variance( @@ -113,7 +152,7 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.finish", - name="Print Finish", + translation_key="print_finish", icon="mdi:clock-end", device_class=SensorDeviceClass.TIMESTAMP, value_fn=ignore_variance( diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json index 9d6a1a2ed5..53f5f0153f 100644 --- a/homeassistant/components/prusalink/strings.json +++ b/homeassistant/components/prusalink/strings.json @@ -25,6 +25,55 @@ "pausing": "Pausing", "printing": "Printing" } + }, + "heatbed_temperature": { + "name": "Heatbed temperature" + }, + "heatbed_target_temperature": { + "name": "Heatbed target temperature" + }, + "nozzle_temperature": { + "name": "Nozzle temperature" + }, + "nozzle_target_temperature": { + "name": "Nozzle target temperature" + }, + "progress": { + "name": "Progress" + }, + "filename": { + "name": "Filename" + }, + "material": { + "name": "Material" + }, + "print_start": { + "name": "Print start" + }, + "print_finish": { + "name": "Print finish" + }, + "print_speed": { + "name": "Print speed" + }, + "z_height": { + "name": "Z-Height" + } + }, + "button": { + "cancel_job": { + "name": "Cancel job" + }, + "pause_job": { + "name": "Pause job" + }, + "resume_job": { + "name": "Resume job" + } + }, + "camera": { + "job_preview": { + "name": "Preview" } } } diff --git a/homeassistant/components/ps4/strings.json b/homeassistant/components/ps4/strings.json index b44862c527..9518af77db 100644 --- a/homeassistant/components/ps4/strings.json +++ b/homeassistant/components/ps4/strings.json @@ -7,7 +7,7 @@ "mode": { "data": { "mode": "Config Mode", - "ip_address": "[%key:common::config_flow::data::ip%] (Leave empty if using Auto Discovery)." + "ip_address": "IP address (Leave empty if using Auto Discovery)." }, "data_description": { "ip_address": "Leave blank if selecting auto-discovery." @@ -28,8 +28,8 @@ "error": { "credential_timeout": "Credential service timed out. Press submit to restart.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "login_failed": "Failed to pair to PlayStation 4. Verify [%key:common::config_flow::data::pin%] is correct.", - "no_ipaddress": "Enter the [%key:common::config_flow::data::ip%] of the PlayStation 4 you would like to configure." + "login_failed": "Failed to pair to PlayStation 4. Verify PIN is correct.", + "no_ipaddress": "Enter the IP address of the PlayStation 4 you would like to configure." }, "abort": { "credential_error": "Error fetching credentials.", diff --git a/homeassistant/components/pushbullet/config_flow.py b/homeassistant/components/pushbullet/config_flow.py index e6259fa8ce..1eca2bd890 100644 --- a/homeassistant/components/pushbullet/config_flow.py +++ b/homeassistant/components/pushbullet/config_flow.py @@ -24,11 +24,6 @@ CONFIG_SCHEMA = vol.Schema( class PushBulletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for pushbullet integration.""" - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Handle import from config.""" - import_config[CONF_NAME] = import_config.get(CONF_NAME, DEFAULT_NAME) - return await self.async_step_user(import_config) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index fcc9d00dc7..1cc851bdb9 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import mimetypes -from typing import Any +from typing import TYPE_CHECKING, Any from pushbullet import PushBullet, PushError from pushbullet.channel import Channel @@ -15,23 +15,16 @@ from homeassistant.components.notify import ( ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, - PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ATTR_FILE, ATTR_FILE_URL, ATTR_URL, DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string}) - async def async_get_service( hass: HomeAssistant, @@ -39,25 +32,8 @@ async def async_get_service( discovery_info: DiscoveryInfoType | None = None, ) -> PushBulletNotificationService | None: """Get the Pushbullet notification service.""" - if discovery_info is None: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.2.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, - ) - ) - return None - + if TYPE_CHECKING: + assert discovery_info is not None pushbullet: PushBullet = hass.data[DOMAIN][discovery_info["entry_id"]].pushbullet return PushBulletNotificationService(hass, pushbullet) diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index aef97991c6..b61469f6b2 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -1,23 +1,14 @@ """Pushbullet platform for sensor component.""" from __future__ import annotations -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.dispatcher import async_dispatcher_connect 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 .api import PushBulletNotificationProvider from .const import DATA_UPDATED, DOMAIN @@ -75,40 +66,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=["title", "body"]): vol.All( - cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_KEYS)] - ), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Pushbullet Sensor platform.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.2.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 diff --git a/homeassistant/components/pushbullet/strings.json b/homeassistant/components/pushbullet/strings.json index 92d22d117d..a6571ae7bf 100644 --- a/homeassistant/components/pushbullet/strings.json +++ b/homeassistant/components/pushbullet/strings.json @@ -15,11 +15,5 @@ } } } - }, - "issues": { - "deprecated_yaml": { - "title": "The Pushbullet YAML configuration is being removed", - "description": "Configuring Pushbullet using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Pushbullet YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index b8869cdee7..b78f49b74f 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["pvo==0.2.2"] + "requirements": ["pvo==1.0.0"] } diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index a5274f7a5a..5154ae155e 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -1 +1,54 @@ """The qbittorrent component.""" +import logging + +from qbittorrent.client import LoginRequired +from requests.exceptions import RequestException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .helpers import setup_client + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up qBittorrent from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + try: + hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( + setup_client, + entry.data[CONF_URL], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_VERIFY_SSL], + ) + except LoginRequired as err: + _LOGGER.error("Invalid credentials") + raise ConfigEntryNotReady from err + except RequestException as err: + _LOGGER.error("Failed to connect") + raise ConfigEntryNotReady from err + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload qBittorrent config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + if not hass.data[DOMAIN]: + del hass.data[DOMAIN] + return unload_ok diff --git a/homeassistant/components/qbittorrent/config_flow.py b/homeassistant/components/qbittorrent/config_flow.py new file mode 100644 index 0000000000..54c47c5389 --- /dev/null +++ b/homeassistant/components/qbittorrent/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for qBittorrent.""" +from __future__ import annotations + +import logging +from typing import Any + +from qbittorrent.client import LoginRequired +from requests.exceptions import RequestException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DEFAULT_URL, DOMAIN +from .helpers import setup_client + +_LOGGER = logging.getLogger(__name__) + +USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default=DEFAULT_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + } +) + + +class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for the qBittorrent integration.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a user-initiated config flow.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + try: + await self.hass.async_add_executor_job( + setup_client, + user_input[CONF_URL], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[CONF_VERIFY_SSL], + ) + except LoginRequired: + errors = {"base": "invalid_auth"} + except RequestException: + errors = {"base": "cannot_connect"} + else: + return self.async_create_entry(title=DEFAULT_NAME, data=user_input) + + schema = self.add_suggested_values_to_schema(USER_DATA_SCHEMA, user_input) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + self._async_abort_entries_match({CONF_URL: config[CONF_URL]}) + return self.async_create_entry( + title=config.get(CONF_NAME, DEFAULT_NAME), + data={ + CONF_URL: config[CONF_URL], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_VERIFY_SSL: True, + }, + ) diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py new file mode 100644 index 0000000000..0a79c67f40 --- /dev/null +++ b/homeassistant/components/qbittorrent/const.py @@ -0,0 +1,7 @@ +"""Constants for qBittorrent.""" +from typing import Final + +DOMAIN: Final = "qbittorrent" + +DEFAULT_NAME = "qBittorrent" +DEFAULT_URL = "http://127.0.0.1:8080" diff --git a/homeassistant/components/qbittorrent/helpers.py b/homeassistant/components/qbittorrent/helpers.py new file mode 100644 index 0000000000..7f7833e912 --- /dev/null +++ b/homeassistant/components/qbittorrent/helpers.py @@ -0,0 +1,11 @@ +"""Helper functions for qBittorrent.""" +from qbittorrent.client import Client + + +def setup_client(url: str, username: str, password: str, verify_ssl: bool) -> Client: + """Create a qBittorrent client.""" + client = Client(url, verify=verify_ssl) + client.login(username, password) + # Get an arbitrary attribute to test if connection succeeds + client.get_alternative_speed_status() + return client diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index 2c1a7be74f..c56bb8102b 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -2,7 +2,9 @@ "domain": "qbittorrent", "name": "qBittorrent", "codeowners": ["@geoffreylagaisse"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/qbittorrent", + "integration_type": "service", "iot_class": "local_polling", "loggers": ["qbittorrent"], "requirements": ["python-qbittorrent==0.4.2"] diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index e7b75954d5..6b758daab0 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -23,19 +24,19 @@ from homeassistant.const import ( UnitOfDataRate, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DEFAULT_NAME, DOMAIN + _LOGGER = logging.getLogger(__name__) SENSOR_TYPE_CURRENT_STATUS = "current_status" SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" -DEFAULT_NAME = "qBittorrent" - SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SENSOR_TYPE_CURRENT_STATUS, @@ -44,6 +45,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, name="Down Speed", + icon="mdi:cloud-download", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, @@ -51,6 +53,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, name="Up Speed", + icon="mdi:cloud-upload", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, @@ -67,32 +70,41 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the qBittorrent sensors.""" + """Set up the qBittorrent platform.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) - try: - client = Client(config[CONF_URL]) - client.login(config[CONF_USERNAME], config[CONF_PASSWORD]) - except LoginRequired: - _LOGGER.error("Invalid authentication") - return - except RequestException as err: - _LOGGER.error("Connection failed") - raise PlatformNotReady from err - - name = config.get(CONF_NAME) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entites: AddEntitiesCallback, +) -> None: + """Set up qBittorrent sensor entries.""" + client: Client = hass.data[DOMAIN][config_entry.entry_id] entities = [ - QBittorrentSensor(description, client, name, LoginRequired) + QBittorrentSensor(description, client, config_entry) for description in SENSOR_TYPES ] - - add_entities(entities, True) + async_add_entites(entities, True) def format_speed(speed): @@ -107,16 +119,15 @@ class QBittorrentSensor(SensorEntity): def __init__( self, description: SensorEntityDescription, - qbittorrent_client, - client_name, - exception, + qbittorrent_client: Client, + config_entry: ConfigEntry, ) -> None: """Initialize the qBittorrent sensor.""" self.entity_description = description self.client = qbittorrent_client - self._exception = exception - self._attr_name = f"{client_name} {description.name}" + self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" + self._attr_name = f"{config_entry.title} {description.name}" self._attr_available = False def update(self) -> None: @@ -128,7 +139,7 @@ class QBittorrentSensor(SensorEntity): _LOGGER.error("Connection lost") self._attr_available = False return - except self._exception: + except LoginRequired: _LOGGER.error("Invalid authentication") return diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json new file mode 100644 index 0000000000..24d1885a91 --- /dev/null +++ b/homeassistant/components/qbittorrent/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The qBittorrent YAML configuration is being removed", + "description": "Configuring qBittorrent using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the qBittorrent YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 86bdf6c2dc..787255187c 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/qrcode", "iot_class": "calculated", "loggers": ["pyzbar"], - "requirements": ["pillow==9.4.0", "pyzbar==0.1.7"] + "requirements": ["pillow==9.5.0", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/radarr/manifest.json b/homeassistant/components/radarr/manifest.json index 420c352e8e..ad5a4f0509 100644 --- a/homeassistant/components/radarr/manifest.json +++ b/homeassistant/components/radarr/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["aiopyarr"], - "requirements": ["aiopyarr==22.11.0"] + "requirements": ["aiopyarr==23.4.0"] } diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index a800061b58..2c71eac019 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -1,17 +1,14 @@ """Support for Radio Thermostat wifi-enabled home thermostats.""" from __future__ import annotations -import logging from typing import Any import radiotherm -import voluptuous as vol from homeassistant.components.climate import ( FAN_AUTO, FAN_OFF, FAN_ON, - PLATFORM_SCHEMA, PRESET_AWAY, PRESET_HOME, ClimateEntity, @@ -19,25 +16,15 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_HOST, - PRECISION_HALVES, - UnitOfTemperature, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN from .coordinator import RadioThermUpdateCoordinator from .entity import RadioThermostatEntity -_LOGGER = logging.getLogger(__name__) - ATTR_FAN_ACTION = "fan_action" PRESET_HOLIDAY = "holiday" @@ -102,14 +89,6 @@ def round_temp(temperature): return round(temperature * 2.0) / 2.0 -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_HOLD_TEMP, default=False): cv.boolean, - } -) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -120,51 +99,6 @@ async def async_setup_entry( async_add_entities([RadioThermostat(coordinator)]) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Radio Thermostat.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2022.9.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - _LOGGER.warning( - "Configuration of the Radio Thermostat climate platform in YAML is deprecated" - " and will be removed in Home Assistant 2022.9; Your existing configuration has" - " been imported into the UI automatically and can be safely removed from your" - " configuration.yaml file" - ) - - hosts: list[str] = [] - if CONF_HOST in config: - hosts = config[CONF_HOST] - else: - hosts.append( - await hass.async_add_executor_job(radiotherm.discover.discover_address) - ) - - if not hosts: - _LOGGER.error("No Radiotherm Thermostats detected") - return - - for host in hosts: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: host}, - ) - ) - - class RadioThermostat(RadioThermostatEntity, ClimateEntity): """Representation of a Radio Thermostat.""" diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py index a3acc2e438..ca488ade46 100644 --- a/homeassistant/components/radiotherm/config_flow.py +++ b/homeassistant/components/radiotherm/config_flow.py @@ -83,25 +83,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders=placeholders, ) - async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: - """Import from yaml.""" - host = import_info[CONF_HOST] - self._async_abort_entries_match({CONF_HOST: host}) - _LOGGER.debug("Importing entry for host: %s", host) - try: - init_data = await validate_connection(self.hass, host) - except CannotConnect as ex: - _LOGGER.debug("Importing failed for %s", host, exc_info=ex) - return self.async_abort(reason="cannot_connect") - await self.async_set_unique_id(init_data.mac, raise_on_progress=False) - self._abort_if_unique_id_configured( - updates={CONF_HOST: host}, reload_on_update=False - ) - return self.async_create_entry( - title=init_data.name, - data={CONF_HOST: import_info[CONF_HOST]}, - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/radiotherm/strings.json b/homeassistant/components/radiotherm/strings.json index f0b31cdb4d..21f53d72bf 100644 --- a/homeassistant/components/radiotherm/strings.json +++ b/homeassistant/components/radiotherm/strings.json @@ -19,12 +19,6 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, - "issues": { - "deprecated_yaml": { - "title": "The Radio Thermostat YAML configuration is being removed", - "description": "Configuring the Radio Thermostat climate platform using YAML is being removed in Home Assistant 2022.9.\n\nYour existing configuration has been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - }, "options": { "step": { "init": { diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 19cf403eab..d4db30fd61 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -24,7 +24,6 @@ DEFAULT_NAME = "Random Sensor" DEFAULT_MIN = 0 DEFAULT_MAX = 20 -ICON = "mdi:hanger" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -54,6 +53,8 @@ async def async_setup_platform( class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" + _attr_icon = "mdi:hanger" + def __init__(self, name, minimum, maximum, unit_of_measurement): """Initialize the Random sensor.""" self._name = name @@ -72,11 +73,6 @@ class RandomSensor(SensorEntity): """Return the state of the device.""" return self._state - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" diff --git a/homeassistant/components/rapt_ble/__init__.py b/homeassistant/components/rapt_ble/__init__.py new file mode 100644 index 0000000000..1b3d65ee2a --- /dev/null +++ b/homeassistant/components/rapt_ble/__init__.py @@ -0,0 +1,49 @@ +"""The rapt_ble integration.""" +from __future__ import annotations + +import logging + +from rapt_ble import RAPTPillBluetoothDeviceData + +from homeassistant.components.bluetooth import BluetoothScanningMode +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothProcessorCoordinator, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up RAPT BLE device from a config entry.""" + address = entry.unique_id + assert address is not None + data = RAPTPillBluetoothDeviceData() + coordinator = hass.data.setdefault(DOMAIN, {})[ + entry.entry_id + ] = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=data.update, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload( + coordinator.async_start() + ) # only start after all platforms have had a chance to subscribe + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/rapt_ble/config_flow.py b/homeassistant/components/rapt_ble/config_flow.py new file mode 100644 index 0000000000..9323ed4eb7 --- /dev/null +++ b/homeassistant/components/rapt_ble/config_flow.py @@ -0,0 +1,94 @@ +"""Config flow for rapt_ble.""" +from __future__ import annotations + +from typing import Any + +from rapt_ble import RAPTPillBluetoothDeviceData as DeviceData +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class RAPTPillConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for rapt_ble.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovered_device: DeviceData | None = None + self._discovered_devices: dict[str, str] = {} + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> FlowResult: + """Handle the bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + device = DeviceData() + if not device.supported(discovery_info): + return self.async_abort(reason="not_supported") + self._discovery_info = discovery_info + self._discovered_device = device + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + assert self._discovered_device is not None + device = self._discovered_device + assert self._discovery_info is not None + discovery_info = self._discovery_info + title = device.title or device.get_device_name() or discovery_info.name + if user_input is not None: + return self.async_create_entry(title=title, data={}) + + self._set_confirm_only() + placeholders = {"name": title} + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="bluetooth_confirm", description_placeholders=placeholders + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self._discovered_devices[address], data={} + ) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass, False): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + continue + device = DeviceData() + if device.supported(discovery_info): + self._discovered_devices[address] = ( + device.title or device.get_device_name() or discovery_info.name + ) + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)} + ), + ) diff --git a/homeassistant/components/rapt_ble/const.py b/homeassistant/components/rapt_ble/const.py new file mode 100644 index 0000000000..21993a0183 --- /dev/null +++ b/homeassistant/components/rapt_ble/const.py @@ -0,0 +1,3 @@ +"""Constants for the rapt_ble integration.""" + +DOMAIN = "rapt_ble" diff --git a/homeassistant/components/rapt_ble/manifest.json b/homeassistant/components/rapt_ble/manifest.json new file mode 100644 index 0000000000..c144251960 --- /dev/null +++ b/homeassistant/components/rapt_ble/manifest.json @@ -0,0 +1,20 @@ +{ + "domain": "rapt_ble", + "name": "RAPT Bluetooth", + "bluetooth": [ + { + "manufacturer_id": 16722, + "manufacturer_data_start": [80, 84] + }, + { + "manufacturer_id": 17739, + "manufacturer_data_start": [71] + } + ], + "codeowners": ["@sairon"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/rapt_ble", + "iot_class": "local_push", + "requirements": ["rapt-ble==0.1.0"] +} diff --git a/homeassistant/components/rapt_ble/sensor.py b/homeassistant/components/rapt_ble/sensor.py new file mode 100644 index 0000000000..9967a36fae --- /dev/null +++ b/homeassistant/components/rapt_ble/sensor.py @@ -0,0 +1,125 @@ +"""Support for RAPT Pill hydrometers.""" +from __future__ import annotations + +from rapt_ble import DeviceClass, DeviceKey, SensorUpdate, Units + +from homeassistant import config_entries +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothDataProcessor, + PassiveBluetoothDataUpdate, + PassiveBluetoothEntityKey, + PassiveBluetoothProcessorCoordinator, + PassiveBluetoothProcessorEntity, +) +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info + +from .const import DOMAIN + +SENSOR_DESCRIPTIONS = { + (DeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription( + key=f"{DeviceClass.TEMPERATURE}_{Units.TEMP_CELSIUS}", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + (DeviceClass.SPECIFIC_GRAVITY, Units.SPECIFIC_GRAVITY): SensorEntityDescription( + key=f"{DeviceClass.SPECIFIC_GRAVITY}_{Units.SPECIFIC_GRAVITY}", + state_class=SensorStateClass.MEASUREMENT, + ), + (DeviceClass.BATTERY, Units.PERCENTAGE): SensorEntityDescription( + key=f"{DeviceClass.BATTERY}_{Units.PERCENTAGE}", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ( + DeviceClass.SIGNAL_STRENGTH, + Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + ): SensorEntityDescription( + key=f"{DeviceClass.SIGNAL_STRENGTH}_{Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT}", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), +} + + +def _device_key_to_bluetooth_entity_key( + device_key: DeviceKey, +) -> PassiveBluetoothEntityKey: + """Convert a device key to an entity key.""" + return PassiveBluetoothEntityKey(device_key.key, device_key.device_id) + + +def sensor_update_to_bluetooth_data_update( + sensor_update: SensorUpdate, +) -> PassiveBluetoothDataUpdate: + """Convert a sensor update to a bluetooth data update.""" + return PassiveBluetoothDataUpdate( + devices={ + device_id: sensor_device_info_to_hass_device_info(device_info) + for device_id, device_info in sensor_update.devices.items() + }, + entity_descriptions={ + _device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ + (description.device_class, description.native_unit_of_measurement) + ] + for device_key, description in sensor_update.entity_descriptions.items() + if description.device_class and description.native_unit_of_measurement + }, + entity_data={ + _device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value + for device_key, sensor_values in sensor_update.entity_values.items() + }, + entity_names={ + _device_key_to_bluetooth_entity_key(device_key): sensor_values.name + for device_key, sensor_values in sensor_update.entity_values.items() + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the RAPT Pill BLE sensors.""" + coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + entry.async_on_unload( + processor.async_add_entities_listener( + RAPTPillBluetoothSensorEntity, async_add_entities + ) + ) + entry.async_on_unload(coordinator.async_register_processor(processor)) + + +class RAPTPillBluetoothSensorEntity( + PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + SensorEntity, +): + """Representation of a RAPT Pill BLE sensor.""" + + @property + def native_value(self) -> int | float | None: + """Return the native value.""" + return self.processor.entity_data.get(self.entity_key) diff --git a/homeassistant/components/rapt_ble/strings.json b/homeassistant/components/rapt_ble/strings.json new file mode 100644 index 0000000000..7111626cca --- /dev/null +++ b/homeassistant/components/rapt_ble/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:component::bluetooth::config::step::user::data::address%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index 2cb660921c..5ec3a6ae19 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["vehicle==0.4.0"] + "requirements": ["vehicle==1.0.0"] } diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 2621db9cb7..7b43abd8dd 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -28,7 +28,11 @@ from .const import ( # noqa: F401 EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, EXCLUDE_ATTRIBUTES, + INTEGRATION_PLATFORM_COMPILE_STATISTICS, + INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES, + INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD, SQLITE_URL_PREFIX, + SupportedDialect, ) from .core import Recorder from .services import async_register_services @@ -165,14 +169,40 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_services(hass, instance) websocket_api.async_setup(hass) entity_registry.async_setup(hass) - await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) + + await _async_setup_integration_platform( + hass, instance, exclude_attributes_by_domain + ) return await instance.async_db_ready -async def _process_recorder_platform( - hass: HomeAssistant, domain: str, platform: Any +async def _async_setup_integration_platform( + hass: HomeAssistant, + instance: Recorder, + exclude_attributes_by_domain: dict[str, set[str]], ) -> None: - """Process a recorder platform.""" - instance = get_instance(hass) - instance.queue_task(AddRecorderPlatformTask(domain, platform)) + """Set up a recorder integration platform.""" + + async def _process_recorder_platform( + hass: HomeAssistant, domain: str, platform: Any + ) -> None: + """Process a recorder platform.""" + # We need to add this before as soon as the component is loaded + # to ensure by the time the state is recorded that the excluded + # attributes are known. This is safe to modify in the event loop + # since exclude_attributes_by_domain is never iterated over. + if exclude_attributes := getattr( + platform, INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES, None + ): + exclude_attributes_by_domain[domain] = exclude_attributes(hass) + + # If the platform has a compile_statistics method, we need to + # add it to the recorder queue to be processed. + if any( + hasattr(platform, _attr) + for _attr in INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD + ): + instance.queue_task(AddRecorderPlatformTask(domain, platform)) + + await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) diff --git a/homeassistant/components/recorder/auto_repairs/events/__init__.py b/homeassistant/components/recorder/auto_repairs/events/__init__.py new file mode 100644 index 0000000000..66ae1a1407 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/events/__init__.py @@ -0,0 +1 @@ +"""events repairs for Recorder.""" diff --git a/homeassistant/components/recorder/auto_repairs/events/schema.py b/homeassistant/components/recorder/auto_repairs/events/schema.py new file mode 100644 index 0000000000..e32cbd4df7 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/events/schema.py @@ -0,0 +1,31 @@ +"""Events schema repairs.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...db_schema import EventData, Events +from ..schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, +) + +if TYPE_CHECKING: + from ... import Recorder + + +def validate_db_schema(instance: Recorder) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + return validate_table_schema_supports_utf8( + instance, EventData, (EventData.shared_data,) + ) | validate_db_schema_precision(instance, Events) + + +def correct_db_schema( + instance: Recorder, + schema_errors: set[str], +) -> None: + """Correct issues detected by validate_db_schema.""" + correct_db_schema_utf8(instance, EventData, schema_errors) + correct_db_schema_precision(instance, Events, schema_errors) diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py new file mode 100644 index 0000000000..ec05eafd14 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -0,0 +1,218 @@ +"""Schema repairs.""" +from __future__ import annotations + +from collections.abc import Iterable, Mapping +import logging +from typing import TYPE_CHECKING + +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm.attributes import InstrumentedAttribute + +from ..const import SupportedDialect +from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE +from ..util import session_scope + +if TYPE_CHECKING: + from .. import Recorder + +_LOGGER = logging.getLogger(__name__) + +MYSQL_ERR_INCORRECT_STRING_VALUE = 1366 + +# This name can't be represented unless 4-byte UTF-8 unicode is supported +UTF8_NAME = "𓆚𓃗" + +# This number can't be accurately represented as a 32-bit float +PRECISE_NUMBER = 1.000000000000001 + + +def _get_precision_column_types( + table_object: type[DeclarativeBase], +) -> list[str]: + """Get the column names for the columns that need to be checked for precision.""" + return [ + column.key + for column in table_object.__table__.columns + if column.type is DOUBLE_TYPE + ] + + +def validate_table_schema_supports_utf8( + instance: Recorder, + table_object: type[DeclarativeBase], + columns: tuple[InstrumentedAttribute, ...], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + # Lack of full utf8 support is only an issue for MySQL / MariaDB + if instance.dialect_name != SupportedDialect.MYSQL: + return schema_errors + + try: + schema_errors = _validate_table_schema_supports_utf8( + instance, table_object, columns + ) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + + _log_schema_errors(table_object, schema_errors) + return schema_errors + + +def _validate_table_schema_supports_utf8( + instance: Recorder, + table_object: type[DeclarativeBase], + columns: tuple[InstrumentedAttribute, ...], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=instance.get_session(), read_only=True) as session: + db_object = table_object(**{column.key: UTF8_NAME for column in columns}) + table = table_object.__tablename__ + # Try inserting some data which needs utf8mb4 support + session.add(db_object) + try: + session.flush() + except OperationalError as err: + if err.orig and err.orig.args[0] == MYSQL_ERR_INCORRECT_STRING_VALUE: + _LOGGER.debug( + "Database %s statistics_meta does not support 4-byte UTF-8", + table, + ) + schema_errors.add(f"{table}.4-byte UTF-8") + return schema_errors + raise + finally: + session.rollback() + return schema_errors + + +def validate_db_schema_precision( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL + if instance.dialect_name not in ( + SupportedDialect.MYSQL, + SupportedDialect.POSTGRESQL, + ): + return schema_errors + try: + schema_errors = _validate_db_schema_precision(instance, table_object) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + + _log_schema_errors(table_object, schema_errors) + return schema_errors + + +def _validate_db_schema_precision( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + columns = _get_precision_column_types(table_object) + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=instance.get_session(), read_only=True) as session: + db_object = table_object(**{column: PRECISE_NUMBER for column in columns}) + table = table_object.__tablename__ + try: + session.add(db_object) + session.flush() + session.refresh(db_object) + _check_columns( + schema_errors=schema_errors, + stored={column: getattr(db_object, column) for column in columns}, + expected={column: PRECISE_NUMBER for column in columns}, + columns=columns, + table_name=table, + supports="double precision", + ) + finally: + session.rollback() + return schema_errors + + +def _log_schema_errors( + table_object: type[DeclarativeBase], schema_errors: set[str] +) -> None: + """Log schema errors.""" + if not schema_errors: + return + _LOGGER.debug( + "Detected %s schema errors: %s", + table_object.__tablename__, + ", ".join(sorted(schema_errors)), + ) + + +def _check_columns( + schema_errors: set[str], + stored: Mapping, + expected: Mapping, + columns: Iterable[str], + table_name: str, + supports: str, +) -> None: + """Check that the columns in the table support the given feature. + + Errors are logged and added to the schema_errors set. + """ + for column in columns: + if stored[column] == expected[column]: + continue + schema_errors.add(f"{table_name}.{supports}") + _LOGGER.error( + "Column %s in database table %s does not support %s (stored=%s != expected=%s)", + column, + table_name, + supports, + stored[column], + expected[column], + ) + + +def correct_db_schema_utf8( + instance: Recorder, table_object: type[DeclarativeBase], schema_errors: set[str] +) -> None: + """Correct utf8 issues detected by validate_db_schema.""" + table_name = table_object.__tablename__ + if f"{table_name}.4-byte UTF-8" in schema_errors: + from ..migration import ( # pylint: disable=import-outside-toplevel + _correct_table_character_set_and_collation, + ) + + _correct_table_character_set_and_collation(table_name, instance.get_session) + + +def correct_db_schema_precision( + instance: Recorder, + table_object: type[DeclarativeBase], + schema_errors: set[str], +) -> None: + """Correct precision issues detected by validate_db_schema.""" + table_name = table_object.__tablename__ + + if f"{table_name}.double precision" in schema_errors: + from ..migration import ( # pylint: disable=import-outside-toplevel + _modify_columns, + ) + + precision_columns = _get_precision_column_types(table_object) + # Attempt to convert timestamp columns to µs precision + session_maker = instance.get_session + engine = instance.engine + assert engine is not None, "Engine should be set" + _modify_columns( + session_maker, + engine, + table_name, + [f"{column} {DOUBLE_PRECISION_TYPE_SQL}" for column in precision_columns], + ) diff --git a/homeassistant/components/recorder/auto_repairs/states/schema.py b/homeassistant/components/recorder/auto_repairs/states/schema.py new file mode 100644 index 0000000000..258e15cbb5 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/states/schema.py @@ -0,0 +1,39 @@ +"""States schema repairs.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...db_schema import StateAttributes, States +from ..schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, +) + +if TYPE_CHECKING: + from ... import Recorder + +TABLE_UTF8_COLUMNS = { + States: (States.state,), + StateAttributes: (StateAttributes.shared_attrs,), +} + + +def validate_db_schema(instance: Recorder) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + for table, columns in TABLE_UTF8_COLUMNS.items(): + schema_errors |= validate_table_schema_supports_utf8(instance, table, columns) + schema_errors |= validate_db_schema_precision(instance, States) + return schema_errors + + +def correct_db_schema( + instance: Recorder, + schema_errors: set[str], +) -> None: + """Correct issues detected by validate_db_schema.""" + for table in (States, StateAttributes): + correct_db_schema_utf8(instance, table, schema_errors) + correct_db_schema_precision(instance, States, schema_errors) diff --git a/homeassistant/components/recorder/auto_repairs/statistics/schema.py b/homeassistant/components/recorder/auto_repairs/statistics/schema.py index bbf59080ac..9b4687cb72 100644 --- a/homeassistant/components/recorder/auto_repairs/statistics/schema.py +++ b/homeassistant/components/recorder/auto_repairs/statistics/schema.py @@ -1,28 +1,16 @@ """Statistics schema repairs.""" from __future__ import annotations -from collections.abc import Callable, Mapping -import contextlib -from datetime import datetime import logging from typing import TYPE_CHECKING -from sqlalchemy import text -from sqlalchemy.engine import Engine -from sqlalchemy.exc import OperationalError, SQLAlchemyError -from sqlalchemy.orm.session import Session - -from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util - -from ...const import DOMAIN, SupportedDialect -from ...db_schema import Statistics, StatisticsShortTerm -from ...models import StatisticData, StatisticMetaData, datetime_to_timestamp_or_none -from ...statistics import ( - _import_statistics_with_session, - _statistics_during_period_with_session, +from ...db_schema import Statistics, StatisticsMeta, StatisticsShortTerm +from ..schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, ) -from ...util import session_scope if TYPE_CHECKING: from ... import Recorder @@ -30,200 +18,14 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -def _validate_db_schema_utf8( - instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: +def validate_db_schema(instance: Recorder) -> set[str]: """Do some basic checks for common schema errors caused by manual migration.""" schema_errors: set[str] = set() - - # Lack of full utf8 support is only an issue for MySQL / MariaDB - if instance.dialect_name != SupportedDialect.MYSQL: - return schema_errors - - # This name can't be represented unless 4-byte UTF-8 unicode is supported - utf8_name = "𓆚𓃗" - statistic_id = f"{DOMAIN}.db_test" - - metadata: StatisticMetaData = { - "has_mean": True, - "has_sum": True, - "name": utf8_name, - "source": DOMAIN, - "statistic_id": statistic_id, - "unit_of_measurement": None, - } - statistics_meta_manager = instance.statistics_meta_manager - - # Try inserting some metadata which needs utf8mb4 support - try: - # Mark the session as read_only to ensure that the test data is not committed - # to the database and we always rollback when the scope is exited - with session_scope(session=session_maker(), read_only=True) as session: - old_metadata_dict = statistics_meta_manager.get_many( - session, statistic_ids={statistic_id} - ) - try: - statistics_meta_manager.update_or_add( - session, metadata, old_metadata_dict - ) - statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) - except OperationalError as err: - if err.orig and err.orig.args[0] == 1366: - _LOGGER.debug( - "Database table statistics_meta does not support 4-byte UTF-8" - ) - schema_errors.add("statistics_meta.4-byte UTF-8") - session.rollback() - else: - raise - except Exception as exc: # pylint: disable=broad-except - _LOGGER.exception("Error when validating DB schema: %s", exc) - return schema_errors - - -def _get_future_year() -> int: - """Get a year in the future.""" - return datetime.now().year + 1 - - -def _validate_db_schema( - hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: - """Do some basic checks for common schema errors caused by manual migration.""" - schema_errors: set[str] = set() - statistics_meta_manager = instance.statistics_meta_manager - - # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL - if instance.dialect_name not in ( - SupportedDialect.MYSQL, - SupportedDialect.POSTGRESQL, - ): - return schema_errors - - # This number can't be accurately represented as a 32-bit float - precise_number = 1.000000000000001 - # This time can't be accurately represented unless datetimes have µs precision - # - # We want to insert statistics for a time in the future, in case they - # have conflicting metadata_id's with existing statistics that were - # never cleaned up. By inserting in the future, we can be sure that - # that by selecting the last inserted row, we will get the one we - # just inserted. - # - future_year = _get_future_year() - precise_time = datetime(future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - start_time = datetime(future_year, 10, 6, tzinfo=dt_util.UTC) - statistic_id = f"{DOMAIN}.db_test" - - metadata: StatisticMetaData = { - "has_mean": True, - "has_sum": True, - "name": None, - "source": DOMAIN, - "statistic_id": statistic_id, - "unit_of_measurement": None, - } - statistics: StatisticData = { - "last_reset": precise_time, - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time, - "state": precise_number, - "sum": precise_number, - } - - def check_columns( - schema_errors: set[str], - stored: Mapping, - expected: Mapping, - columns: tuple[str, ...], - table_name: str, - supports: str, - ) -> None: - for column in columns: - if stored[column] != expected[column]: - schema_errors.add(f"{table_name}.{supports}") - _LOGGER.error( - "Column %s in database table %s does not support %s (stored=%s != expected=%s)", - column, - table_name, - supports, - stored[column], - expected[column], - ) - - # Insert / adjust a test statistics row in each of the tables - tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( - Statistics, - StatisticsShortTerm, + schema_errors |= validate_table_schema_supports_utf8( + instance, StatisticsMeta, (StatisticsMeta.statistic_id,) ) - try: - # Mark the session as read_only to ensure that the test data is not committed - # to the database and we always rollback when the scope is exited - with session_scope(session=session_maker(), read_only=True) as session: - for table in tables: - _import_statistics_with_session( - instance, session, metadata, (statistics,), table - ) - stored_statistics = _statistics_during_period_with_session( - hass, - session, - start_time, - None, - {statistic_id}, - "hour" if table == Statistics else "5minute", - None, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - if not (stored_statistic := stored_statistics.get(statistic_id)): - _LOGGER.warning( - "Schema validation failed for table: %s", table.__tablename__ - ) - continue - - # We want to look at the last inserted row to make sure there - # is not previous garbage data in the table that would cause - # the test to produce an incorrect result. To achieve this, - # we inserted a row in the future, and now we select the last - # inserted row back. - last_stored_statistic = stored_statistic[-1] - check_columns( - schema_errors, - last_stored_statistic, - statistics, - ("max", "mean", "min", "state", "sum"), - table.__tablename__, - "double precision", - ) - assert statistics["last_reset"] - check_columns( - schema_errors, - last_stored_statistic, - { - "last_reset": datetime_to_timestamp_or_none( - statistics["last_reset"] - ), - "start": datetime_to_timestamp_or_none(statistics["start"]), - }, - ("start", "last_reset"), - table.__tablename__, - "µs precision", - ) - statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) - except Exception as exc: # pylint: disable=broad-except - _LOGGER.exception("Error when validating DB schema: %s", exc) - - return schema_errors - - -def validate_db_schema( - hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: - """Do some basic checks for common schema errors caused by manual migration.""" - schema_errors: set[str] = set() - schema_errors |= _validate_db_schema_utf8(instance, session_maker) - schema_errors |= _validate_db_schema(hass, instance, session_maker) + for table in (Statistics, StatisticsShortTerm): + schema_errors |= validate_db_schema_precision(instance, table) if schema_errors: _LOGGER.debug( "Detected statistics schema errors: %s", ", ".join(sorted(schema_errors)) @@ -233,63 +35,9 @@ def validate_db_schema( def correct_db_schema( instance: Recorder, - engine: Engine, - session_maker: Callable[[], Session], schema_errors: set[str], ) -> None: """Correct issues detected by validate_db_schema.""" - from ...migration import _modify_columns # pylint: disable=import-outside-toplevel - - if "statistics_meta.4-byte UTF-8" in schema_errors: - # Attempt to convert the table to utf8mb4 - _LOGGER.warning( - ( - "Updating character set and collation of table %s to utf8mb4. " - "Note: this can take several minutes on large databases and slow " - "computers. Please be patient!" - ), - "statistics_meta", - ) - with contextlib.suppress(SQLAlchemyError), session_scope( - session=session_maker() - ) as session: - connection = session.connection() - connection.execute( - # Using LOCK=EXCLUSIVE to prevent the database from corrupting - # https://github.com/home-assistant/core/issues/56104 - text( - "ALTER TABLE statistics_meta CONVERT TO CHARACTER SET utf8mb4" - " COLLATE utf8mb4_unicode_ci, LOCK=EXCLUSIVE" - ) - ) - - tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( - Statistics, - StatisticsShortTerm, - ) - for table in tables: - if f"{table.__tablename__}.double precision" in schema_errors: - # Attempt to convert float columns to double precision - _modify_columns( - session_maker, - engine, - table.__tablename__, - [ - "mean DOUBLE PRECISION", - "min DOUBLE PRECISION", - "max DOUBLE PRECISION", - "state DOUBLE PRECISION", - "sum DOUBLE PRECISION", - ], - ) - if f"{table.__tablename__}.µs precision" in schema_errors: - # Attempt to convert timestamp columns to µs precision - _modify_columns( - session_maker, - engine, - table.__tablename__, - [ - "last_reset_ts DOUBLE PRECISION", - "start_ts DOUBLE PRECISION", - ], - ) + correct_db_schema_utf8(instance, StatisticsMeta, schema_errors) + for table in (Statistics, StatisticsShortTerm): + correct_db_schema_precision(instance, table, schema_errors) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 6bf46efd36..ec5c5c984b 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -19,7 +19,9 @@ EVENT_RECORDER_HOURLY_STATISTICS_GENERATED = "recorder_hourly_statistics_generat CONF_DB_INTEGRITY_CHECK = "db_integrity_check" -MAX_QUEUE_BACKLOG = 65000 +MAX_QUEUE_BACKLOG_MIN_VALUE = 65000 +ESTIMATED_QUEUE_ITEM_SIZE = 10240 +QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY = 0.65 # The maximum number of rows (events) we purge in one delete statement @@ -51,6 +53,19 @@ STATES_META_SCHEMA_VERSION = 38 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 +INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES = "exclude_attributes" + +INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics" +INTEGRATION_PLATFORM_VALIDATE_STATISTICS = "validate_statistics" +INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids" + +INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD = { + INTEGRATION_PLATFORM_COMPILE_STATISTICS, + INTEGRATION_PLATFORM_VALIDATE_STATISTICS, + INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, +} + + class SupportedDialect(StrEnum): """Supported dialects.""" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index bbdab2690d..43915c0187 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -11,11 +11,13 @@ import queue import sqlite3 import threading import time -from typing import Any, TypeVar +from typing import Any, TypeVar, cast import async_timeout +import psutil_home_assistant as ha_psutil from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select from sqlalchemy.engine import Engine +from sqlalchemy.engine.interfaces import DBAPIConnection from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.orm.session import Session @@ -23,8 +25,8 @@ from sqlalchemy.orm.session import Session from homeassistant.components import persistent_notification from homeassistant.const import ( ATTR_ENTITY_ID, + EVENT_HOMEASSISTANT_CLOSE, EVENT_HOMEASSISTANT_FINAL_WRITE, - EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, MATCH_ALL, ) @@ -44,20 +46,23 @@ from .const import ( CONTEXT_ID_AS_BINARY_SCHEMA_VERSION, DB_WORKER_PREFIX, DOMAIN, + ESTIMATED_QUEUE_ITEM_SIZE, EVENT_TYPE_IDS_SCHEMA_VERSION, KEEPALIVE_TIME, LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION, MARIADB_PYMYSQL_URL_PREFIX, MARIADB_URL_PREFIX, - MAX_QUEUE_BACKLOG, + MAX_QUEUE_BACKLOG_MIN_VALUE, MYSQLDB_PYMYSQL_URL_PREFIX, MYSQLDB_URL_PREFIX, + QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY, SQLITE_URL_PREFIX, STATES_META_SCHEMA_VERSION, STATISTICS_ROWS_SCHEMA_VERSION, SupportedDialect, ) from .db_schema import ( + LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX, LEGACY_STATES_EVENT_ID_INDEX, SCHEMA_VERSION, TABLE_STATES, @@ -96,6 +101,7 @@ from .tasks import ( CompileMissingStatisticsTask, DatabaseLockTask, EntityIDMigrationTask, + EntityIDPostMigrationTask, EventIdMigrationTask, EventsContextIDMigrationTask, EventTask, @@ -145,7 +151,7 @@ WAIT_TASK = WaitTask() ADJUST_LRU_SIZE_TASK = AdjustLRUSizeTask() DB_LOCK_TIMEOUT = 30 -DB_LOCK_QUEUE_CHECK_TIMEOUT = 1 +DB_LOCK_QUEUE_CHECK_TIMEOUT = 10 # check every 10 seconds INVALIDATED_ERR = "Database connection invalidated" @@ -198,6 +204,8 @@ class Recorder(threading.Thread): self.async_recorder_ready = asyncio.Event() self._queue_watch = threading.Event() self.engine: Engine | None = None + self.max_backlog: int = MAX_QUEUE_BACKLOG_MIN_VALUE + self._psutil: ha_psutil.PsutilWrapper | None = None # The entity_filter is exposed on the recorder instance so that # it can be used to see if an entity is being recorded and is called @@ -296,7 +304,10 @@ class Recorder(threading.Thread): run_immediately=True, ) self._queue_watcher = async_track_time_interval( - self.hass, self._async_check_queue, timedelta(minutes=10) + self.hass, + self._async_check_queue, + timedelta(minutes=10), + name="Recorder queue watcher", ) @callback @@ -337,7 +348,7 @@ class Recorder(threading.Thread): """ size = self.backlog _LOGGER.debug("Recorder queue size is: %s", size) - if size <= MAX_QUEUE_BACKLOG: + if not self._reached_max_backlog_percentage(100): return _LOGGER.error( ( @@ -346,10 +357,33 @@ class Recorder(threading.Thread): "is corrupt due to a disk problem; The recorder will stop " "recording events to avoid running out of memory" ), - MAX_QUEUE_BACKLOG, + self.backlog, ) self._async_stop_queue_watcher_and_event_listener() + def _available_memory(self) -> int: + """Return the available memory in bytes.""" + if not self._psutil: + self._psutil = ha_psutil.PsutilWrapper() + return cast(int, self._psutil.psutil.virtual_memory().available) + + def _reached_max_backlog_percentage(self, percentage: int) -> bool: + """Check if the system has reached the max queue backlog and return the maximum if it has.""" + percentage_modifier = percentage / 100 + current_backlog = self.backlog + # First check the minimum value since its cheap + if current_backlog < (MAX_QUEUE_BACKLOG_MIN_VALUE * percentage_modifier): + return False + # If they have more RAM available, keep filling the backlog + # since we do not want to stop recording events or give the + # user a bad backup when they have plenty of RAM available. + max_queue_backlog = int( + QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY + * (self._available_memory() / ESTIMATED_QUEUE_ITEM_SIZE) + ) + self.max_backlog = max(max_queue_backlog, MAX_QUEUE_BACKLOG_MIN_VALUE) + return current_backlog >= (max_queue_backlog * percentage_modifier) + @callback def _async_stop_queue_watcher_and_event_listener(self) -> None: """Stop watching the queue and listening for events.""" @@ -398,9 +432,8 @@ class Recorder(threading.Thread): # Unknown what it is. return True - @callback - def _async_empty_queue(self, event: Event) -> None: - """Empty the queue if its still present at final write.""" + async def _async_close(self, event: Event) -> None: + """Empty the queue if its still present at close.""" # If the queue is full of events to be processed because # the database is so broken that every event results in a retry @@ -415,9 +448,10 @@ class Recorder(threading.Thread): except queue.Empty: break self.queue_task(StopTask()) + await self.hass.async_add_executor_job(self.join) async def _async_shutdown(self, event: Event) -> None: - """Shut down the Recorder.""" + """Shut down the Recorder at final write.""" if not self._hass_started.done(): self._hass_started.set_result(SHUTDOWN_TASK) self.queue_task(StopTask()) @@ -433,15 +467,22 @@ class Recorder(threading.Thread): def async_register(self) -> None: """Post connection initialize.""" bus = self.hass.bus - bus.async_listen_once(EVENT_HOMEASSISTANT_FINAL_WRITE, self._async_empty_queue) - bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown) + bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, self._async_close) + bus.async_listen_once(EVENT_HOMEASSISTANT_FINAL_WRITE, self._async_shutdown) async_at_started(self.hass, self._async_hass_started) @callback - def async_connection_failed(self) -> None: - """Connect failed tasks.""" - self.async_db_connected.set_result(False) - self.async_db_ready.set_result(False) + def _async_startup_failed(self) -> None: + """Report startup failure.""" + # If a live migration failed, we were able to connect (async_db_connected + # marked True), the database was marked ready (async_db_ready marked + # True), the data in the queue cannot be written to the database because + # the schema not in the correct format so we must stop listeners and report + # failure. + if not self.async_db_connected.done(): + self.async_db_connected.set_result(False) + if not self.async_db_ready.done(): + self.async_db_ready.set_result(False) persistent_notification.async_create( self.hass, "The recorder could not start, check [the logs](/config/logs)", @@ -596,13 +637,19 @@ class Recorder(threading.Thread): # to prevent errors from unexpected disconnects if self.dialect_name != SupportedDialect.SQLITE: self._keep_alive_listener = async_track_time_interval( - self.hass, self._async_keep_alive, timedelta(seconds=KEEPALIVE_TIME) + self.hass, + self._async_keep_alive, + timedelta(seconds=KEEPALIVE_TIME), + name="Recorder keep alive", ) # If the commit interval is not 0, we need to commit periodically if self.commit_interval: self._commit_listener = async_track_time_interval( - self.hass, self._async_commit, timedelta(seconds=self.commit_interval) + self.hass, + self._async_commit, + timedelta(seconds=self.commit_interval), + name="Recorder commit", ) # Run nightly tasks at 4:12am @@ -633,19 +680,26 @@ class Recorder(threading.Thread): return SHUTDOWN_TASK def run(self) -> None: + """Run the recorder thread.""" + try: + self._run() + finally: + # Ensure shutdown happens cleanly if + # anything goes wrong in the run loop + self._shutdown() + + def _run(self) -> None: """Start processing events to save.""" self.thread_id = threading.get_ident() setup_result = self._setup_recorder() if not setup_result: # Give up if we could not connect - self.hass.add_job(self.async_connection_failed) return schema_status = migration.validate_db_schema(self.hass, self, self.get_session) if schema_status is None: # Give up if we could not validate the schema - self.hass.add_job(self.async_connection_failed) return self.schema_version = schema_status.current_version @@ -672,7 +726,6 @@ class Recorder(threading.Thread): self.migration_in_progress = False # Make sure we cleanly close the run if # we restart before startup finishes - self._shutdown() return if not schema_status.valid: @@ -680,8 +733,8 @@ class Recorder(threading.Thread): self.schema_version = SCHEMA_VERSION if not self._event_listener: # If the schema migration takes so long that the end - # queue watcher safety kicks in because MAX_QUEUE_BACKLOG - # is reached, we need to reinitialize the listener. + # queue watcher safety kicks in because _reached_max_backlog + # was True, we need to reinitialize the listener. self.hass.add_job(self.async_initialize) else: persistent_notification.create( @@ -690,8 +743,6 @@ class Recorder(threading.Thread): "Database Migration Failed", "recorder_database_migration", ) - self.hass.add_job(self.async_set_db_ready) - self._shutdown() return if not database_was_ready: @@ -703,7 +754,6 @@ class Recorder(threading.Thread): self._adjust_lru_size() self.hass.add_job(self._async_set_recorder_ready_migration_done) self._run_event_loop() - self._shutdown() def _activate_and_set_db_ready(self) -> None: """Activate the table managers or schedule migrations and mark the db as ready.""" @@ -718,7 +768,7 @@ class Recorder(threading.Thread): if ( self.schema_version < CONTEXT_ID_AS_BINARY_SCHEMA_VERSION or execute_stmt_lambda_element( - session, has_events_context_ids_to_migrate() + session, has_states_context_ids_to_migrate() ) ): self.queue_task(StatesContextIDMigrationTask()) @@ -726,7 +776,7 @@ class Recorder(threading.Thread): if ( self.schema_version < CONTEXT_ID_AS_BINARY_SCHEMA_VERSION or execute_stmt_lambda_element( - session, has_states_context_ids_to_migrate() + session, has_events_context_ids_to_migrate() ) ): self.queue_task(EventsContextIDMigrationTask()) @@ -748,6 +798,18 @@ class Recorder(threading.Thread): else: _LOGGER.debug("Activating states_meta manager as all data is migrated") self.states_meta_manager.active = True + with contextlib.suppress(SQLAlchemyError): + # If ix_states_entity_id_last_updated_ts still exists + # on the states table it means the entity id migration + # finished by the EntityIDPostMigrationTask did not + # because they restarted in the middle of it. We need + # to pick back up where we left off. + if get_index_by_name( + session, + TABLE_STATES, + LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX, + ): + self.queue_task(EntityIDPostMigrationTask()) if self.schema_version > LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION: with contextlib.suppress(SQLAlchemyError): @@ -779,6 +841,10 @@ class Recorder(threading.Thread): for task in startup_tasks: self._guarded_process_one_task_or_recover(task) + # Clear startup tasks since this thread runs forever + # and we don't want to hold them in memory + del startup_tasks + self.stop_requested = False while not self.stop_requested: self._guarded_process_one_task_or_recover(queue_.get()) @@ -908,12 +974,14 @@ class Recorder(threading.Thread): # Notify that lock is being held, wait until database can be used again. self.hass.add_job(_async_set_database_locked, task) while not task.database_unlock.wait(timeout=DB_LOCK_QUEUE_CHECK_TIMEOUT): - if self.backlog > MAX_QUEUE_BACKLOG * 0.9: + if self._reached_max_backlog_percentage(90): _LOGGER.warning( - "Database queue backlog reached more than 90% of maximum queue " + "Database queue backlog reached more than %s (%s events) of maximum queue " "length while waiting for backup to finish; recorder will now " "resume writing to database. The backup cannot be trusted and " - "must be restarted" + "must be restarted", + "90%", + self.backlog, ) task.queue_overflow = True break @@ -943,7 +1011,7 @@ class Recorder(threading.Thread): event_type_manager = self.event_type_manager if pending_event_types := event_type_manager.get_pending(event.event_type): dbevent.event_type_rel = pending_event_types - elif event_type_id := event_type_manager.get(event.event_type, session): + elif event_type_id := event_type_manager.get(event.event_type, session, True): dbevent.event_type_id = event_type_id else: event_types = EventTypes(event_type=event.event_type) @@ -1266,25 +1334,25 @@ class Recorder(threading.Thread): return success + def _setup_recorder_connection( + self, dbapi_connection: DBAPIConnection, connection_record: Any + ) -> None: + """Dbapi specific connection settings.""" + assert self.engine is not None + if database_engine := setup_connection_for_dialect( + self, + self.engine.dialect.name, + dbapi_connection, + not self._completed_first_database_setup, + ): + self.database_engine = database_engine + self._completed_first_database_setup = True + def _setup_connection(self) -> None: """Ensure database is ready to fly.""" kwargs: dict[str, Any] = {} self._completed_first_database_setup = False - def setup_recorder_connection( - dbapi_connection: Any, connection_record: Any - ) -> None: - """Dbapi specific connection settings.""" - assert self.engine is not None - if database_engine := setup_connection_for_dialect( - self, - self.engine.dialect.name, - dbapi_connection, - not self._completed_first_database_setup, - ): - self.database_engine = database_engine - self._completed_first_database_setup = True - if self.db_url == SQLITE_URL_PREFIX or ":memory:" in self.db_url: kwargs["connect_args"] = {"check_same_thread": False} kwargs["poolclass"] = MutexPool @@ -1319,7 +1387,7 @@ class Recorder(threading.Thread): self.engine = create_engine(self.db_url, **kwargs, future=True) self._dialect_name = try_parse_enum(SupportedDialect, self.engine.dialect.name) - sqlalchemy_event.listen(self.engine, "connect", setup_recorder_connection) + sqlalchemy_event.listen(self.engine, "connect", self._setup_recorder_connection) Base.metadata.create_all(self.engine) self._get_session = scoped_session(sessionmaker(bind=self.engine, future=True)) @@ -1327,9 +1395,9 @@ class Recorder(threading.Thread): def _close_connection(self) -> None: """Close the connection.""" - assert self.engine is not None - self.engine.dispose() - self.engine = None + if self.engine: + self.engine.dispose() + self.engine = None self._get_session = None def _setup_run(self) -> None: @@ -1361,9 +1429,19 @@ class Recorder(threading.Thread): def _shutdown(self) -> None: """Save end time for current run.""" _LOGGER.debug("Shutting down recorder") - self.hass.add_job(self._async_stop_listeners) - self._stop_executor() + if not self.schema_version or self.schema_version != SCHEMA_VERSION: + # If the schema version is not set, we never had a working + # connection to the database or the schema never reached a + # good state. + # + # In either case, we want to mark startup as failed. + # + self.hass.add_job(self._async_startup_failed) + else: + self.hass.add_job(self._async_stop_listeners) + try: self._end_session() finally: + self._stop_executor() self._close_connection() diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 0bb0b846a3..0743864aaf 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -3,14 +3,14 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta -from functools import lru_cache import logging import time from typing import Any, cast import ciso8601 -from fnvhash import fnv1a_32 +from fnv_hash_fast import fnv1a_32 from sqlalchemy import ( + CHAR, JSON, BigInteger, Boolean, @@ -25,17 +25,18 @@ from sqlalchemy import ( SmallInteger, String, Text, + case, type_coerce, ) from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite from sqlalchemy.engine.interfaces import Dialect +from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import DeclarativeBase, Mapped, aliased, mapped_column, relationship +from sqlalchemy.types import TypeDecorator from typing_extensions import Self from homeassistant.const import ( - MAX_LENGTH_EVENT_CONTEXT_ID, MAX_LENGTH_EVENT_EVENT_TYPE, - MAX_LENGTH_EVENT_ORIGIN, MAX_LENGTH_STATE_ENTITY_ID, MAX_LENGTH_STATE_STATE, ) @@ -88,6 +89,8 @@ TABLE_STATISTICS_SHORT_TERM = "statistics_short_term" STATISTICS_TABLES = ("statistics", "statistics_short_term") MAX_STATE_ATTRS_BYTES = 16384 +MAX_EVENT_DATA_BYTES = 32768 + PSQL_DIALECT = SupportedDialect.POSTGRESQL ALL_TABLES = [ @@ -117,18 +120,44 @@ METADATA_ID_LAST_UPDATED_INDEX_TS = "ix_states_metadata_id_last_updated_ts" EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" LEGACY_STATES_EVENT_ID_INDEX = "ix_states_event_id" +LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX = "ix_states_entity_id_last_updated_ts" CONTEXT_ID_BIN_MAX_LENGTH = 16 +MYSQL_COLLATE = "utf8mb4_unicode_ci" +MYSQL_DEFAULT_CHARSET = "utf8mb4" +MYSQL_ENGINE = "InnoDB" + _DEFAULT_TABLE_ARGS = { - "mysql_default_charset": "utf8mb4", - "mysql_collate": "utf8mb4_unicode_ci", - "mysql_engine": "InnoDB", - "mariadb_default_charset": "utf8mb4", - "mariadb_collate": "utf8mb4_unicode_ci", - "mariadb_engine": "InnoDB", + "mysql_default_charset": MYSQL_DEFAULT_CHARSET, + "mysql_collate": MYSQL_COLLATE, + "mysql_engine": MYSQL_ENGINE, + "mariadb_default_charset": MYSQL_DEFAULT_CHARSET, + "mariadb_collate": MYSQL_COLLATE, + "mariadb_engine": MYSQL_ENGINE, } +class UnusedDateTime(DateTime): + """An unused column type that behaves like a datetime.""" + + +class Unused(CHAR): + """An unused column type that behaves like a string.""" + + +@compiles(UnusedDateTime, "mysql", "mariadb", "sqlite") # type: ignore[misc,no-untyped-call] +@compiles(Unused, "mysql", "mariadb", "sqlite") # type: ignore[misc,no-untyped-call] +def compile_char_zero(type_: TypeDecorator, compiler: Any, **kw: Any) -> str: + """Compile UnusedDateTime and Unused as CHAR(0) on mysql, mariadb, and sqlite.""" + return "CHAR(0)" # Uses 1 byte on MySQL (no change on sqlite) + + +@compiles(Unused, "postgresql") # type: ignore[misc,no-untyped-call] +def compile_char_one(type_: TypeDecorator, compiler: Any, **kw: Any) -> str: + """Compile Unused as CHAR(1) on postgresql.""" + return "CHAR(1)" # Uses 1 byte + + class FAST_PYSQLITE_DATETIME(sqlite.DATETIME): """Use ciso8601 to parse datetimes instead of sqlalchemy built-in regex.""" @@ -137,6 +166,19 @@ class FAST_PYSQLITE_DATETIME(sqlite.DATETIME): return lambda value: None if value is None else ciso8601.parse_datetime(value) +class NativeLargeBinary(LargeBinary): + """A faster version of LargeBinary for engines that support python bytes natively.""" + + def result_processor(self, dialect, coltype): # type: ignore[no-untyped-def] + """No conversion needed for engines that support native bytes.""" + return None + + +# For MariaDB and MySQL we can use an unsigned integer type since it will fit 2**32 +# for sqlite and postgresql we use a bigint +UINT_32_TYPE = BigInteger().with_variant( + mysql.INTEGER(unsigned=True), "mysql", "mariadb" # type: ignore[no-untyped-call] +) JSON_VARIANT_CAST = Text().with_variant( postgresql.JSON(none_as_null=True), "postgresql" # type: ignore[no-untyped-call] ) @@ -154,6 +196,13 @@ DOUBLE_TYPE = ( .with_variant(oracle.DOUBLE_PRECISION(), "oracle") .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") ) +UNUSED_LEGACY_COLUMN = Unused(0) +UNUSED_LEGACY_DATETIME_COLUMN = UnusedDateTime(timezone=True) +UNUSED_LEGACY_INTEGER_COLUMN = SmallInteger() +DOUBLE_PRECISION_TYPE_SQL = "DOUBLE PRECISION" +CONTEXT_BINARY_TYPE = LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH).with_variant( + NativeLargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), "mysql", "mariadb", "sqlite" +) TIMESTAMP_TYPE = DOUBLE_TYPE @@ -194,41 +243,21 @@ class Events(Base): ) __tablename__ = TABLE_EVENTS event_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - event_type: Mapped[str | None] = mapped_column( - String(MAX_LENGTH_EVENT_EVENT_TYPE) - ) # no longer used - event_data: Mapped[str | None] = mapped_column( - Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") - ) - origin: Mapped[str | None] = mapped_column( - String(MAX_LENGTH_EVENT_ORIGIN) - ) # no longer used for new rows + event_type: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + event_data: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + origin: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) origin_idx: Mapped[int | None] = mapped_column(SmallInteger) - time_fired: Mapped[datetime | None] = mapped_column( - DATETIME_TYPE - ) # no longer used for new rows + time_fired: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) time_fired_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, index=True) - context_id: Mapped[str | None] = mapped_column( # no longer used - String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True - ) - context_user_id: Mapped[str | None] = mapped_column( # no longer used - String(MAX_LENGTH_EVENT_CONTEXT_ID) - ) - context_parent_id: Mapped[str | None] = mapped_column( # no longer used - String(MAX_LENGTH_EVENT_CONTEXT_ID) - ) + context_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + context_user_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + context_parent_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) data_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("event_data.data_id"), index=True ) - context_id_bin: Mapped[bytes | None] = mapped_column( - LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), - ) - context_user_id_bin: Mapped[bytes | None] = mapped_column( - LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), - ) - context_parent_id_bin: Mapped[bytes | None] = mapped_column( - LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) - ) + context_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) + context_user_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) + context_parent_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) event_type_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("event_types.event_type_id") ) @@ -277,7 +306,7 @@ class Events(Base): """Convert to a native HA Event.""" context = Context( id=bytes_to_ulid_or_none(self.context_id_bin), - user_id=bytes_to_uuid_hex_or_none(self.context_user_id), + user_id=bytes_to_uuid_hex_or_none(self.context_user_id_bin), parent_id=bytes_to_ulid_or_none(self.context_parent_id_bin), ) try: @@ -302,7 +331,7 @@ class EventData(Base): __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_EVENT_DATA data_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - hash: Mapped[int | None] = mapped_column(BigInteger, index=True) + hash: Mapped[int | None] = mapped_column(UINT_32_TYPE, index=True) # Note that this is not named attributes to avoid confusion with the states table shared_data: Mapped[str | None] = mapped_column( Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") @@ -322,14 +351,23 @@ class EventData(Base): ) -> bytes: """Create shared_data from an event.""" if dialect == SupportedDialect.POSTGRESQL: - return json_bytes_strip_null(event.data) - return json_bytes(event.data) + bytes_result = json_bytes_strip_null(event.data) + bytes_result = json_bytes(event.data) + if len(bytes_result) > MAX_EVENT_DATA_BYTES: + _LOGGER.warning( + "Event data for %s exceed maximum size of %s bytes. " + "This can cause database performance issues; Event data " + "will not be stored", + event.event_type, + MAX_EVENT_DATA_BYTES, + ) + return b"{}" + return bytes_result @staticmethod - @lru_cache def hash_shared_data_bytes(shared_data_bytes: bytes) -> int: """Return the hash of json encoded shared data.""" - return cast(int, fnv1a_32(shared_data_bytes)) + return fnv1a_32(shared_data_bytes) def to_native(self) -> dict[str, Any]: """Convert to an event data dictionary.""" @@ -379,21 +417,13 @@ class States(Base): ) __tablename__ = TABLE_STATES state_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - entity_id: Mapped[str | None] = mapped_column( - String(MAX_LENGTH_STATE_ENTITY_ID) - ) # no longer used for new rows + entity_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) state: Mapped[str | None] = mapped_column(String(MAX_LENGTH_STATE_STATE)) - attributes: Mapped[str | None] = mapped_column( - Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") - ) # no longer used for new rows - event_id: Mapped[int | None] = mapped_column(Integer) # no longer used for new rows - last_changed: Mapped[datetime | None] = mapped_column( - DATETIME_TYPE - ) # no longer used for new rows + attributes: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + event_id: Mapped[int | None] = mapped_column(UNUSED_LEGACY_INTEGER_COLUMN) + last_changed: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) last_changed_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE) - last_updated: Mapped[datetime | None] = mapped_column( - DATETIME_TYPE - ) # no longer used for new rows + last_updated: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) last_updated_ts: Mapped[float | None] = mapped_column( TIMESTAMP_TYPE, default=time.time, index=True ) @@ -403,29 +433,17 @@ class States(Base): attributes_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("state_attributes.attributes_id"), index=True ) - context_id: Mapped[str | None] = mapped_column( # no longer used - String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True - ) - context_user_id: Mapped[str | None] = mapped_column( # no longer used - String(MAX_LENGTH_EVENT_CONTEXT_ID) - ) - context_parent_id: Mapped[str | None] = mapped_column( # no longer used - String(MAX_LENGTH_EVENT_CONTEXT_ID) - ) + context_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + context_user_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + context_parent_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) origin_idx: Mapped[int | None] = mapped_column( SmallInteger ) # 0 is local, 1 is remote old_state: Mapped[States | None] = relationship("States", remote_side=[state_id]) state_attributes: Mapped[StateAttributes | None] = relationship("StateAttributes") - context_id_bin: Mapped[bytes | None] = mapped_column( - LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), - ) - context_user_id_bin: Mapped[bytes | None] = mapped_column( - LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), - ) - context_parent_id_bin: Mapped[bytes | None] = mapped_column( - LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) - ) + context_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) + context_user_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) + context_parent_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) metadata_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("states_meta.metadata_id") ) @@ -491,7 +509,7 @@ class States(Base): """Convert to an HA state object.""" context = Context( id=bytes_to_ulid_or_none(self.context_id_bin), - user_id=bytes_to_uuid_hex_or_none(self.context_user_id), + user_id=bytes_to_uuid_hex_or_none(self.context_user_id_bin), parent_id=bytes_to_ulid_or_none(self.context_parent_id_bin), ) try: @@ -526,7 +544,7 @@ class StateAttributes(Base): __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_STATE_ATTRIBUTES attributes_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - hash: Mapped[int | None] = mapped_column(BigInteger, index=True) + hash: Mapped[int | None] = mapped_column(UINT_32_TYPE, index=True) # Note that this is not named attributes to avoid confusion with the states table shared_attrs: Mapped[str | None] = mapped_column( Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") @@ -575,10 +593,9 @@ class StateAttributes(Base): return bytes_result @staticmethod - @lru_cache(maxsize=2048) def hash_shared_attrs_bytes(shared_attrs_bytes: bytes) -> int: """Return the hash of json encoded shared attributes.""" - return cast(int, fnv1a_32(shared_attrs_bytes)) + return fnv1a_32(shared_attrs_bytes) def to_native(self) -> dict[str, Any]: """Convert to a state attributes dictionary.""" @@ -616,20 +633,18 @@ class StatisticsBase: """Statistics base class.""" id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - created: Mapped[datetime | None] = mapped_column(DATETIME_TYPE) # No longer used + created: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) created_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, default=time.time) metadata_id: Mapped[int | None] = mapped_column( Integer, ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), ) - start: Mapped[datetime | None] = mapped_column( - DATETIME_TYPE, index=True - ) # No longer used + start: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) start_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, index=True) mean: Mapped[float | None] = mapped_column(DOUBLE_TYPE) min: Mapped[float | None] = mapped_column(DOUBLE_TYPE) max: Mapped[float | None] = mapped_column(DOUBLE_TYPE) - last_reset: Mapped[datetime | None] = mapped_column(DATETIME_TYPE) + last_reset: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) last_reset_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE) state: Mapped[float | None] = mapped_column(DOUBLE_TYPE) sum: Mapped[float | None] = mapped_column(DOUBLE_TYPE) @@ -807,3 +822,11 @@ ENTITY_ID_IN_EVENT: ColumnElement = EVENT_DATA_JSON["entity_id"] OLD_ENTITY_ID_IN_EVENT: ColumnElement = OLD_FORMAT_EVENT_DATA_JSON["entity_id"] DEVICE_ID_IN_EVENT: ColumnElement = EVENT_DATA_JSON["device_id"] OLD_STATE = aliased(States, name="old_state") + +SHARED_ATTR_OR_LEGACY_ATTRIBUTES = case( + (StateAttributes.shared_attrs.is_(None), States.attributes), + else_=StateAttributes.shared_attrs, +).label("attributes") +SHARED_DATA_OR_LEGACY_EVENT_DATA = case( + (EventData.shared_data.is_(None), Events.event_data), else_=EventData.shared_data +).label("event_data") diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 63eed2d145..24d22704a8 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -63,42 +63,53 @@ def merge_include_exclude_filters( def sqlalchemy_filter_from_include_exclude_conf(conf: ConfigType) -> Filters | None: """Build a sql filter from config.""" - filters = Filters() - if exclude := conf.get(CONF_EXCLUDE): - filters.excluded_entities = exclude.get(CONF_ENTITIES, []) - filters.excluded_domains = exclude.get(CONF_DOMAINS, []) - filters.excluded_entity_globs = exclude.get(CONF_ENTITY_GLOBS, []) - if include := conf.get(CONF_INCLUDE): - filters.included_entities = include.get(CONF_ENTITIES, []) - filters.included_domains = include.get(CONF_DOMAINS, []) - filters.included_entity_globs = include.get(CONF_ENTITY_GLOBS, []) - + exclude = conf.get(CONF_EXCLUDE, {}) + include = conf.get(CONF_INCLUDE, {}) + filters = Filters( + excluded_entities=exclude.get(CONF_ENTITIES, []), + excluded_domains=exclude.get(CONF_DOMAINS, []), + excluded_entity_globs=exclude.get(CONF_ENTITY_GLOBS, []), + included_entities=include.get(CONF_ENTITIES, []), + included_domains=include.get(CONF_DOMAINS, []), + included_entity_globs=include.get(CONF_ENTITY_GLOBS, []), + ) return filters if filters.has_config else None class Filters: - """Container for the configured include and exclude filters.""" + """Container for the configured include and exclude filters. - def __init__(self) -> None: + A filter must never change after it is created since it is used in a + cache key. + """ + + def __init__( + self, + excluded_entities: Collection[str] | None = None, + excluded_domains: Collection[str] | None = None, + excluded_entity_globs: Collection[str] | None = None, + included_entities: Collection[str] | None = None, + included_domains: Collection[str] | None = None, + included_entity_globs: Collection[str] | None = None, + ) -> None: """Initialise the include and exclude filters.""" - self.excluded_entities: Collection[str] = [] - self.excluded_domains: Collection[str] = [] - self.excluded_entity_globs: Collection[str] = [] - - self.included_entities: Collection[str] = [] - self.included_domains: Collection[str] = [] - self.included_entity_globs: Collection[str] = [] + self._excluded_entities = excluded_entities or [] + self._excluded_domains = excluded_domains or [] + self._excluded_entity_globs = excluded_entity_globs or [] + self._included_entities = included_entities or [] + self._included_domains = included_domains or [] + self._included_entity_globs = included_entity_globs or [] def __repr__(self) -> str: """Return human readable excludes/includes.""" return ( "" ) @@ -110,34 +121,34 @@ class Filters: @property def _have_exclude(self) -> bool: return bool( - self.excluded_entities - or self.excluded_domains - or self.excluded_entity_globs + self._excluded_entities + or self._excluded_domains + or self._excluded_entity_globs ) @property def _have_include(self) -> bool: return bool( - self.included_entities - or self.included_domains - or self.included_entity_globs + self._included_entities + or self._included_domains + or self._included_entity_globs ) def _generate_filter_for_columns( self, columns: Iterable[Column], encoder: Callable[[Any], Any] - ) -> ColumnElement | None: - """Generate a filter from pre-comuted sets and pattern lists. + ) -> ColumnElement: + """Generate a filter from pre-computed sets and pattern lists. This must match exactly how homeassistant.helpers.entityfilter works. """ - i_domains = _domain_matcher(self.included_domains, columns, encoder) - i_entities = _entity_matcher(self.included_entities, columns, encoder) - i_entity_globs = _globs_to_like(self.included_entity_globs, columns, encoder) + i_domains = _domain_matcher(self._included_domains, columns, encoder) + i_entities = _entity_matcher(self._included_entities, columns, encoder) + i_entity_globs = _globs_to_like(self._included_entity_globs, columns, encoder) includes = [i_domains, i_entities, i_entity_globs] - e_domains = _domain_matcher(self.excluded_domains, columns, encoder) - e_entities = _entity_matcher(self.excluded_entities, columns, encoder) - e_entity_globs = _globs_to_like(self.excluded_entity_globs, columns, encoder) + e_domains = _domain_matcher(self._excluded_domains, columns, encoder) + e_entities = _entity_matcher(self._excluded_entities, columns, encoder) + e_entity_globs = _globs_to_like(self._excluded_entity_globs, columns, encoder) excludes = [e_domains, e_entities, e_entity_globs] have_exclude = self._have_exclude @@ -146,7 +157,9 @@ class Filters: # Case 1 - No filter # - All entities included if not have_include and not have_exclude: - return None + raise RuntimeError( + "No filter configuration provided, check has_config before calling this method." + ) # Case 2 - Only includes # - Entity listed in entities include: include @@ -171,7 +184,7 @@ class Filters: # - Otherwise, entity matches glob exclude: exclude # - Otherwise, entity matches domain include: include # - Otherwise: exclude - if self.included_domains or self.included_entity_globs: + if self._included_domains or self._included_entity_globs: return or_( i_entities, # https://github.com/sqlalchemy/sqlalchemy/issues/9190 @@ -185,7 +198,7 @@ class Filters: # - Otherwise, entity matches glob exclude: exclude # - Otherwise, entity matches domain exclude: exclude # - Otherwise: include - if self.excluded_domains or self.excluded_entity_globs: + if self._excluded_domains or self._excluded_entity_globs: return (not_(or_(*excludes)) | i_entities).self_group() # type: ignore[no-any-return, no-untyped-call] # Case 6 - No Domain and/or glob includes or excludes @@ -193,7 +206,7 @@ class Filters: # - Otherwise: exclude return i_entities - def states_entity_filter(self) -> ColumnElement | None: + def states_entity_filter(self) -> ColumnElement: """Generate the States.entity_id filter query. This is no longer used except by the legacy queries. @@ -206,7 +219,7 @@ class Filters: # The type annotation should be improved so the type ignore can be removed return self._generate_filter_for_columns((States.entity_id,), _encoder) # type: ignore[arg-type] - def states_metadata_entity_filter(self) -> ColumnElement | None: + def states_metadata_entity_filter(self) -> ColumnElement: """Generate the StatesMeta.entity_id filter query.""" def _encoder(data: Any) -> Any: @@ -232,7 +245,7 @@ class Filters: (OLD_ENTITY_ID_IN_EVENT == JSON_NULL) | OLD_ENTITY_ID_IN_EVENT.is_(None) ), # Needs https://github.com/bdraco/home-assistant/commit/bba91945006a46f3a01870008eb048e4f9cbb1ef - self._generate_filter_for_columns( # type: ignore[union-attr] + self._generate_filter_for_columns( (ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT), _encoder # type: ignore[arg-type] ).self_group(), ) diff --git a/homeassistant/components/recorder/history/const.py b/homeassistant/components/recorder/history/const.py index 33717ca78c..61a615a797 100644 --- a/homeassistant/components/recorder/history/const.py +++ b/homeassistant/components/recorder/history/const.py @@ -13,7 +13,6 @@ SIGNIFICANT_DOMAINS = { } SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in SIGNIFICANT_DOMAINS] IGNORE_DOMAINS = {"zone", "scene"} -IGNORE_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in IGNORE_DOMAINS] NEED_ATTRIBUTE_DOMAINS = { "climate", "humidifier", diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index e51b1a2568..74b17d9daa 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -5,7 +5,6 @@ from collections import defaultdict from collections.abc import Callable, Iterable, Iterator, MutableMapping from datetime import datetime from itertools import groupby -import logging from operator import attrgetter import time from typing import Any, cast @@ -13,7 +12,6 @@ from typing import Any, cast from sqlalchemy import Column, Text, and_, func, lambda_stmt, or_, select from sqlalchemy.engine.row import Row from sqlalchemy.orm.properties import MappedColumn -from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal from sqlalchemy.sql.lambdas import StatementLambdaElement @@ -26,17 +24,19 @@ from ... import recorder from ..db_schema import RecorderRuns, StateAttributes, States from ..filters import Filters from ..models import ( - LazyState, process_datetime_to_timestamp, process_timestamp, process_timestamp_to_utc_isoformat, - row_to_compressed_state, ) -from ..models.legacy import LazyStatePreSchema31, row_to_compressed_state_pre_schema_31 +from ..models.legacy import ( + LegacyLazyState, + LegacyLazyStatePreSchema31, + legacy_row_to_compressed_state, + legacy_row_to_compressed_state_pre_schema_31, +) from ..util import execute_stmt_lambda_element, session_scope from .common import _schema_version from .const import ( - IGNORE_DOMAINS_ENTITY_ID_LIKE, LAST_CHANGED_KEY, NEED_ATTRIBUTE_DOMAINS, SIGNIFICANT_DOMAINS, @@ -44,9 +44,6 @@ from .const import ( STATE_KEY, ) -_LOGGER = logging.getLogger(__name__) - - _BASE_STATES = ( States.entity_id, States.state, @@ -229,24 +226,11 @@ def get_significant_states( ) -def _ignore_domains_filter(query: Query) -> Query: - """Add a filter to ignore domains we do not fetch history for.""" - return query.filter( - and_( - *[ - ~States.entity_id.like(entity_domain) - for entity_domain in IGNORE_DOMAINS_ENTITY_ID_LIKE - ] - ) - ) - - def _significant_states_stmt( schema_version: int, start_time: datetime, end_time: datetime | None, - entity_ids: list[str] | None, - filters: Filters | None, + entity_ids: list[str], significant_changes_only: bool, no_attributes: bool, ) -> StatementLambdaElement: @@ -255,8 +239,7 @@ def _significant_states_stmt( schema_version, no_attributes, include_last_changed=not significant_changes_only ) if ( - entity_ids - and len(entity_ids) == 1 + len(entity_ids) == 1 and significant_changes_only and split_entity_id(entity_ids[0])[0] not in SIGNIFICANT_DOMAINS ): @@ -297,19 +280,7 @@ def _significant_states_stmt( ), ) ) - - if entity_ids: - stmt += lambda q: q.filter( - # https://github.com/python/mypy/issues/2608 - States.entity_id.in_(entity_ids) # type:ignore[arg-type] - ) - else: - stmt += _ignore_domains_filter - if filters and filters.has_config: - entity_filter = filters.states_entity_filter() - stmt = stmt.add_criteria( - lambda q: q.filter(entity_filter), track_on=[filters] - ) + stmt += lambda q: q.filter(States.entity_id.in_(entity_ids)) if schema_version >= 31: start_time_ts = start_time.timestamp() @@ -357,25 +328,25 @@ def get_significant_states_with_session( as well as all states from certain domains (for instance thermostat so that we get current temperature in our graphs). """ + if filters is not None: + raise NotImplementedError("Filters are no longer supported") + if not entity_ids: + raise ValueError("entity_ids must be provided") stmt = _significant_states_stmt( _schema_version(hass), start_time, end_time, entity_ids, - filters, significant_changes_only, no_attributes, ) - states = execute_stmt_lambda_element( - session, stmt, None if entity_ids else start_time, end_time - ) + states = execute_stmt_lambda_element(session, stmt, None, end_time) return _sorted_states_to_dict( hass, session, states, start_time, entity_ids, - filters, include_start_time_state, minimal_response, no_attributes, @@ -420,7 +391,7 @@ def _state_changed_during_period_stmt( schema_version: int, start_time: datetime, end_time: datetime | None, - entity_id: str | None, + entity_id: str, no_attributes: bool, descending: bool, limit: int | None, @@ -451,8 +422,7 @@ def _state_changed_during_period_stmt( stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) else: stmt += lambda q: q.filter(States.last_updated < end_time) - if entity_id: - stmt += lambda q: q.filter(States.entity_id == entity_id) + stmt += lambda q: q.filter(States.entity_id == entity_id) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id @@ -485,9 +455,9 @@ def state_changes_during_period( include_start_time_state: bool = True, ) -> MutableMapping[str, list[State]]: """Return states changes during UTC period start_time - end_time.""" - entity_id = entity_id.lower() if entity_id is not None else None - entity_ids = [entity_id] if entity_id is not None else None - + if not entity_id: + raise ValueError("entity_id must be provided") + entity_ids = [entity_id.lower()] with session_scope(hass=hass, read_only=True) as session: stmt = _state_changed_during_period_stmt( _schema_version(hass), @@ -498,9 +468,7 @@ def state_changes_during_period( descending, limit, ) - states = execute_stmt_lambda_element( - session, stmt, None if entity_id else start_time, end_time - ) + states = execute_stmt_lambda_element(session, stmt, None, end_time) return cast( MutableMapping[str, list[State]], _sorted_states_to_dict( @@ -648,92 +616,17 @@ def _get_states_for_entities_stmt( return stmt -def _get_states_for_all_stmt( - schema_version: int, - run_start: datetime, - utc_point_in_time: datetime, - filters: Filters | None, - no_attributes: bool, -) -> StatementLambdaElement: - """Baked query to get states for all entities.""" - stmt, join_attributes = _lambda_stmt_and_join_attributes( - schema_version, no_attributes, include_last_changed=True - ) - # We did not get an include-list of entities, query all states in the inner - # query, then filter out unwanted domains as well as applying the custom filter. - # This filtering can't be done in the inner query because the domain column is - # not indexed and we can't control what's in the custom filter. - if schema_version >= 31: - run_start_ts = process_timestamp(run_start).timestamp() - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) - stmt += lambda q: q.join( - ( - most_recent_states_by_date := ( - select( - States.entity_id.label("max_entity_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(States.last_updated_ts).label("max_last_updated"), - ) - .filter( - (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < utc_point_in_time_ts) - ) - .group_by(States.entity_id) - .subquery() - ) - ), - and_( - States.entity_id == most_recent_states_by_date.c.max_entity_id, - States.last_updated_ts == most_recent_states_by_date.c.max_last_updated, - ), - ) - else: - stmt += lambda q: q.join( - ( - most_recent_states_by_date := ( - select( - States.entity_id.label("max_entity_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(States.last_updated).label("max_last_updated"), - ) - .filter( - (States.last_updated >= run_start) - & (States.last_updated < utc_point_in_time) - ) - .group_by(States.entity_id) - .subquery() - ) - ), - and_( - States.entity_id == most_recent_states_by_date.c.max_entity_id, - States.last_updated == most_recent_states_by_date.c.max_last_updated, - ), - ) - stmt += _ignore_domains_filter - if filters and filters.has_config: - entity_filter = filters.states_entity_filter() - stmt = stmt.add_criteria(lambda q: q.filter(entity_filter), track_on=[filters]) - if join_attributes: - stmt += lambda q: q.outerjoin( - StateAttributes, (States.attributes_id == StateAttributes.attributes_id) - ) - return stmt - - def _get_rows_with_session( hass: HomeAssistant, session: Session, utc_point_in_time: datetime, - entity_ids: list[str] | None = None, + entity_ids: list[str], run: RecorderRuns | None = None, - filters: Filters | None = None, no_attributes: bool = False, ) -> Iterable[Row]: """Return the states at a specific point in time.""" schema_version = _schema_version(hass) - if entity_ids and len(entity_ids) == 1: + if len(entity_ids) == 1: return execute_stmt_lambda_element( session, _get_single_entity_states_stmt( @@ -750,15 +643,9 @@ def _get_rows_with_session( # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. - if entity_ids: - stmt = _get_states_for_entities_stmt( - schema_version, run.start, utc_point_in_time, entity_ids, no_attributes - ) - else: - stmt = _get_states_for_all_stmt( - schema_version, run.start, utc_point_in_time, filters, no_attributes - ) - + stmt = _get_states_for_entities_stmt( + schema_version, run.start, utc_point_in_time, entity_ids, no_attributes + ) return execute_stmt_lambda_element(session, stmt) @@ -804,8 +691,7 @@ def _sorted_states_to_dict( session: Session, states: Iterable[Row], start_time: datetime, - entity_ids: list[str] | None, - filters: Filters | None = None, + entity_ids: list[str], include_start_time_state: bool = True, minimal_response: bool = False, no_attributes: bool = False, @@ -830,29 +716,28 @@ def _sorted_states_to_dict( ] if compressed_state_format: if schema_version >= 31: - state_class = row_to_compressed_state + state_class = legacy_row_to_compressed_state else: - state_class = row_to_compressed_state_pre_schema_31 + state_class = legacy_row_to_compressed_state_pre_schema_31 _process_timestamp = process_datetime_to_timestamp attr_time = COMPRESSED_STATE_LAST_UPDATED attr_state = COMPRESSED_STATE_STATE else: if schema_version >= 31: - state_class = LazyState + state_class = LegacyLazyState else: - state_class = LazyStatePreSchema31 + state_class = LegacyLazyStatePreSchema31 _process_timestamp = process_timestamp_to_utc_isoformat attr_time = LAST_CHANGED_KEY attr_state = STATE_KEY result: dict[str, list[State | dict[str, Any]]] = defaultdict(list) # Set all entity IDs to empty lists in result set to maintain the order - if entity_ids is not None: - for ent_id in entity_ids: - result[ent_id] = [] + for ent_id in entity_ids: + result[ent_id] = [] # Get the states at the start time - timer_start = time.perf_counter() + time.perf_counter() initial_states: dict[str, Row] = {} if include_start_time_state: initial_states = { @@ -862,16 +747,11 @@ def _sorted_states_to_dict( session, start_time, entity_ids, - filters=filters, no_attributes=no_attributes, ) } - if _LOGGER.isEnabledFor(logging.DEBUG): - elapsed = time.perf_counter() - timer_start - _LOGGER.debug("getting %d first datapoints took %fs", len(result), elapsed) - - if entity_ids and len(entity_ids) == 1: + if len(entity_ids) == 1: states_iter: Iterable[tuple[str, Iterator[Row]]] = ( (entity_ids[0], iter(states)), ) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 50e6102703..5322074c20 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -1,101 +1,110 @@ """Provide pre-made queries on top of the recorder component.""" from __future__ import annotations -from collections import defaultdict from collections.abc import Callable, Iterable, Iterator, MutableMapping from datetime import datetime from itertools import groupby from operator import itemgetter from typing import Any, cast -from sqlalchemy import Column, and_, func, lambda_stmt, or_, select +from sqlalchemy import ( + CompoundSelect, + Integer, + Select, + Subquery, + and_, + func, + lambda_stmt, + literal, + select, + union_all, +) +from sqlalchemy.dialects import postgresql from sqlalchemy.engine.row import Row -from sqlalchemy.orm.properties import MappedColumn -from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session -from sqlalchemy.sql.expression import literal -from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE from homeassistant.core import HomeAssistant, State, split_entity_id import homeassistant.util.dt as dt_util from ... import recorder -from ..db_schema import RecorderRuns, StateAttributes, States, StatesMeta +from ..db_schema import SHARED_ATTR_OR_LEGACY_ATTRIBUTES, StateAttributes, States from ..filters import Filters from ..models import ( LazyState, + datetime_to_timestamp_or_none, extract_metadata_ids, process_timestamp, row_to_compressed_state, ) from ..util import execute_stmt_lambda_element, session_scope from .const import ( - IGNORE_DOMAINS_ENTITY_ID_LIKE, LAST_CHANGED_KEY, NEED_ATTRIBUTE_DOMAINS, SIGNIFICANT_DOMAINS, - SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE, STATE_KEY, ) -_BASE_STATES = ( - States.metadata_id, - States.state, - States.last_changed_ts, - States.last_updated_ts, -) -_BASE_STATES_NO_LAST_CHANGED = ( # type: ignore[var-annotated] - States.metadata_id, - States.state, - literal(value=None).label("last_changed_ts"), - States.last_updated_ts, -) -_QUERY_STATE_NO_ATTR = (*_BASE_STATES,) -_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED = (*_BASE_STATES_NO_LAST_CHANGED,) -_QUERY_STATES = ( - *_BASE_STATES, - # Remove States.attributes once all attributes are in StateAttributes.shared_attrs - States.attributes, - StateAttributes.shared_attrs, -) -_QUERY_STATES_NO_LAST_CHANGED = ( - *_BASE_STATES_NO_LAST_CHANGED, - # Remove States.attributes once all attributes are in StateAttributes.shared_attrs - States.attributes, - StateAttributes.shared_attrs, -) _FIELD_MAP = { - cast(MappedColumn, field).name: idx - for idx, field in enumerate(_QUERY_STATE_NO_ATTR) + "metadata_id": 0, + "state": 1, + "last_updated_ts": 2, } -def _lambda_stmt_and_join_attributes( - no_attributes: bool, include_last_changed: bool = True -) -> tuple[StatementLambdaElement, bool]: - """Return the lambda_stmt and if StateAttributes should be joined. +CASTABLE_DOUBLE_TYPE = ( + # MySQL/MariaDB < 10.4+ does not support casting to DOUBLE so we have to use Integer instead but it doesn't + # matter because we don't use the value as its always set to NULL + # + # sqlalchemy.exc.SAWarning: Datatype DOUBLE does not support CAST on MySQL/MariaDb; the CAST will be skipped. + # + Integer().with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") +) - Because these are lambda_stmt the values inside the lambdas need - to be explicitly written out to avoid caching the wrong values. - """ - # If no_attributes was requested we do the query - # without the attributes fields and do not join the - # state_attributes table - if no_attributes: - if include_last_changed: - return ( - lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR)), - False, - ) - return ( - lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED)), - False, - ) +def _stmt_and_join_attributes( + no_attributes: bool, include_last_changed: bool +) -> Select: + """Return the statement and if StateAttributes should be joined.""" + _select = select(States.metadata_id, States.state, States.last_updated_ts) if include_last_changed: - return lambda_stmt(lambda: select(*_QUERY_STATES)), True - return lambda_stmt(lambda: select(*_QUERY_STATES_NO_LAST_CHANGED)), True + _select = _select.add_columns(States.last_changed_ts) + if not no_attributes: + _select = _select.add_columns(SHARED_ATTR_OR_LEGACY_ATTRIBUTES) + return _select + + +def _stmt_and_join_attributes_for_start_state( + no_attributes: bool, include_last_changed: bool +) -> Select: + """Return the statement and if StateAttributes should be joined.""" + _select = select(States.metadata_id, States.state) + _select = _select.add_columns( + literal(value=None).label("last_updated_ts").cast(CASTABLE_DOUBLE_TYPE) + ) + if include_last_changed: + _select = _select.add_columns( + literal(value=None).label("last_changed_ts").cast(CASTABLE_DOUBLE_TYPE) + ) + if not no_attributes: + _select = _select.add_columns(SHARED_ATTR_OR_LEGACY_ATTRIBUTES) + return _select + + +def _select_from_subquery( + subquery: Subquery | CompoundSelect, no_attributes: bool, include_last_changed: bool +) -> Select: + """Return the statement to select from the union.""" + base_select = select( + subquery.c.metadata_id, + subquery.c.state, + subquery.c.last_updated_ts, + ) + if include_last_changed: + base_select = base_select.add_columns(subquery.c.last_changed_ts) + if no_attributes: + return base_select + return base_select.add_columns(subquery.c.attributes) def get_significant_states( @@ -127,92 +136,66 @@ def get_significant_states( ) -def _ignore_domains_filter(query: Query) -> Query: - """Add a filter to ignore domains we do not fetch history for.""" - return query.filter( - and_( - *[ - ~StatesMeta.entity_id.like(entity_domain) - for entity_domain in IGNORE_DOMAINS_ENTITY_ID_LIKE - ] - ) - ) - - def _significant_states_stmt( - start_time: datetime, - end_time: datetime | None, - metadata_ids: list[int] | None, + start_time_ts: float, + end_time_ts: float | None, + single_metadata_id: int | None, + metadata_ids: list[int], metadata_ids_in_significant_domains: list[int], - filters: Filters | None, significant_changes_only: bool, no_attributes: bool, -) -> StatementLambdaElement: + include_start_time_state: bool, + run_start_ts: float | None, +) -> Select | CompoundSelect: """Query the database for significant state changes.""" - stmt, join_attributes = _lambda_stmt_and_join_attributes( - no_attributes, include_last_changed=not significant_changes_only - ) - join_states_meta = False - if metadata_ids and significant_changes_only: + include_last_changed = not significant_changes_only + stmt = _stmt_and_join_attributes(no_attributes, include_last_changed) + if significant_changes_only: # Since we are filtering on entity_id (metadata_id) we can avoid # the join of the states_meta table since we already know which # metadata_ids are in the significant domains. - stmt += lambda q: q.filter( - States.metadata_id.in_(metadata_ids_in_significant_domains) - | (States.last_changed_ts == States.last_updated_ts) - | States.last_changed_ts.is_(None) - ) - elif significant_changes_only: - # This is the case where we are not filtering on entity_id - # so we need to join the states_meta table to filter out - # the domains we do not care about. This query path was - # only used by the old history page to show all entities - # in the UI. The new history page filters on entity_id - # so this query path is not used anymore except for third - # party integrations that use the history API. - stmt += lambda q: q.filter( - or_( - *[ - StatesMeta.entity_id.like(entity_domain) - for entity_domain in SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE - ], - ( - (States.last_changed_ts == States.last_updated_ts) - | States.last_changed_ts.is_(None) - ), + if metadata_ids_in_significant_domains: + stmt = stmt.filter( + States.metadata_id.in_(metadata_ids_in_significant_domains) + | (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) ) - ) - join_states_meta = True - - if metadata_ids: - stmt += lambda q: q.filter( - # https://github.com/python/mypy/issues/2608 - States.metadata_id.in_(metadata_ids) # type:ignore[arg-type] - ) - else: - stmt += _ignore_domains_filter - if filters and filters.has_config: - entity_filter = filters.states_metadata_entity_filter() - stmt = stmt.add_criteria( - lambda q: q.filter(entity_filter), track_on=[filters] + else: + stmt = stmt.filter( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) ) - join_states_meta = True - - start_time_ts = start_time.timestamp() - stmt += lambda q: q.filter(States.last_updated_ts > start_time_ts) - if end_time: - end_time_ts = end_time.timestamp() - stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) - if join_states_meta: - stmt += lambda q: q.outerjoin( - StatesMeta, States.metadata_id == StatesMeta.metadata_id - ) - if join_attributes: - stmt += lambda q: q.outerjoin( + stmt = stmt.filter(States.metadata_id.in_(metadata_ids)).filter( + States.last_updated_ts > start_time_ts + ) + if end_time_ts: + stmt = stmt.filter(States.last_updated_ts < end_time_ts) + if not no_attributes: + stmt = stmt.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - stmt += lambda q: q.order_by(States.metadata_id, States.last_updated_ts) - return stmt + stmt = stmt.order_by(States.metadata_id, States.last_updated_ts) + if not include_start_time_state or not run_start_ts: + return stmt + return _select_from_subquery( + union_all( + _select_from_subquery( + _get_start_time_state_stmt( + run_start_ts, + start_time_ts, + single_metadata_id, + metadata_ids, + no_attributes, + include_last_changed, + ).subquery(), + no_attributes, + include_last_changed, + ), + _select_from_subquery(stmt.subquery(), no_attributes, include_last_changed), + ).subquery(), + no_attributes, + include_last_changed, + ) def get_significant_states_with_session( @@ -239,47 +222,62 @@ def get_significant_states_with_session( as well as all states from certain domains (for instance thermostat so that we get current temperature in our graphs). """ - metadata_ids: list[int] | None = None + if filters is not None: + raise NotImplementedError("Filters are no longer supported") + if not entity_ids: + raise ValueError("entity_ids must be provided") entity_id_to_metadata_id: dict[str, int | None] | None = None metadata_ids_in_significant_domains: list[int] = [] - if entity_ids: - instance = recorder.get_instance(hass) - if not ( - entity_id_to_metadata_id := instance.states_meta_manager.get_many( - entity_ids, session, False - ) - ) or not (metadata_ids := extract_metadata_ids(entity_id_to_metadata_id)): - return {} - if significant_changes_only: - metadata_ids_in_significant_domains = [ - metadata_id - for entity_id, metadata_id in entity_id_to_metadata_id.items() - if metadata_id is not None - and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS - ] - stmt = _significant_states_stmt( - start_time, - end_time, - metadata_ids, - metadata_ids_in_significant_domains, - filters, - significant_changes_only, - no_attributes, - ) - states = execute_stmt_lambda_element( - session, stmt, None if entity_ids else start_time, end_time + instance = recorder.get_instance(hass) + if not ( + entity_id_to_metadata_id := instance.states_meta_manager.get_many( + entity_ids, session, False + ) + ) or not (possible_metadata_ids := extract_metadata_ids(entity_id_to_metadata_id)): + return {} + metadata_ids = possible_metadata_ids + if significant_changes_only: + metadata_ids_in_significant_domains = [ + metadata_id + for entity_id, metadata_id in entity_id_to_metadata_id.items() + if metadata_id is not None + and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS + ] + run_start_ts: float | None = None + if include_start_time_state and not ( + run_start_ts := _get_run_start_ts_for_utc_point_in_time(hass, start_time) + ): + include_start_time_state = False + start_time_ts = dt_util.utc_to_timestamp(start_time) + end_time_ts = datetime_to_timestamp_or_none(end_time) + single_metadata_id = metadata_ids[0] if len(metadata_ids) == 1 else None + stmt = lambda_stmt( + lambda: _significant_states_stmt( + start_time_ts, + end_time_ts, + single_metadata_id, + metadata_ids, + metadata_ids_in_significant_domains, + significant_changes_only, + no_attributes, + include_start_time_state, + run_start_ts, + ), + track_on=[ + bool(single_metadata_id), + bool(metadata_ids_in_significant_domains), + bool(end_time_ts), + significant_changes_only, + no_attributes, + include_start_time_state, + ], ) return _sorted_states_to_dict( - hass, - session, - states, - start_time, + execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False), + start_time_ts if include_start_time_state else None, entity_ids, entity_id_to_metadata_id, - filters, - include_start_time_state, minimal_response, - no_attributes, compressed_state_format, ) @@ -318,40 +316,60 @@ def get_full_significant_states_with_session( def _state_changed_during_period_stmt( - start_time: datetime, - end_time: datetime | None, - metadata_id: int | None, + start_time_ts: float, + end_time_ts: float | None, + single_metadata_id: int, no_attributes: bool, - descending: bool, limit: int | None, -) -> StatementLambdaElement: - stmt, join_attributes = _lambda_stmt_and_join_attributes( - no_attributes, include_last_changed=False - ) - start_time_ts = start_time.timestamp() - stmt += lambda q: q.filter( - ( - (States.last_changed_ts == States.last_updated_ts) - | States.last_changed_ts.is_(None) + include_start_time_state: bool, + run_start_ts: float | None, +) -> Select | CompoundSelect: + stmt = ( + _stmt_and_join_attributes(no_attributes, False) + .filter( + ( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) + ) + & (States.last_updated_ts > start_time_ts) ) - & (States.last_updated_ts > start_time_ts) + .filter(States.metadata_id == single_metadata_id) ) - if end_time: - end_time_ts = end_time.timestamp() - stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) - if metadata_id: - stmt += lambda q: q.filter(States.metadata_id == metadata_id) - if join_attributes: - stmt += lambda q: q.outerjoin( + if end_time_ts: + stmt = stmt.filter(States.last_updated_ts < end_time_ts) + if not no_attributes: + stmt = stmt.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - if descending: - stmt += lambda q: q.order_by(States.metadata_id, States.last_updated_ts.desc()) - else: - stmt += lambda q: q.order_by(States.metadata_id, States.last_updated_ts) if limit: - stmt += lambda q: q.limit(limit) - return stmt + stmt = stmt.limit(limit) + stmt = stmt.order_by( + States.metadata_id, + States.last_updated_ts, + ) + if not include_start_time_state or not run_start_ts: + return stmt + return _select_from_subquery( + union_all( + _select_from_subquery( + _get_single_entity_start_time_stmt( + start_time_ts, + single_metadata_id, + no_attributes, + False, + ).subquery(), + no_attributes, + False, + ), + _select_from_subquery( + stmt.subquery(), + no_attributes, + False, + ), + ).subquery(), + no_attributes, + False, + ) def state_changes_during_period( @@ -365,64 +383,110 @@ def state_changes_during_period( include_start_time_state: bool = True, ) -> MutableMapping[str, list[State]]: """Return states changes during UTC period start_time - end_time.""" - entity_id = entity_id.lower() if entity_id is not None else None - entity_ids = [entity_id] if entity_id is not None else None + if not entity_id: + raise ValueError("entity_id must be provided") + entity_ids = [entity_id.lower()] with session_scope(hass=hass, read_only=True) as session: - metadata_id: int | None = None - entity_id_to_metadata_id = None - if entity_id: - instance = recorder.get_instance(hass) - metadata_id = instance.states_meta_manager.get(entity_id, session, False) - entity_id_to_metadata_id = {entity_id: metadata_id} - stmt = _state_changed_during_period_stmt( - start_time, - end_time, - metadata_id, - no_attributes, - descending, - limit, - ) - states = execute_stmt_lambda_element( - session, stmt, None if entity_id else start_time, end_time + instance = recorder.get_instance(hass) + if not ( + possible_metadata_id := instance.states_meta_manager.get( + entity_id, session, False + ) + ): + return {} + single_metadata_id = possible_metadata_id + entity_id_to_metadata_id: dict[str, int | None] = { + entity_id: single_metadata_id + } + run_start_ts: float | None = None + if include_start_time_state and not ( + run_start_ts := _get_run_start_ts_for_utc_point_in_time(hass, start_time) + ): + include_start_time_state = False + start_time_ts = dt_util.utc_to_timestamp(start_time) + end_time_ts = datetime_to_timestamp_or_none(end_time) + stmt = lambda_stmt( + lambda: _state_changed_during_period_stmt( + start_time_ts, + end_time_ts, + single_metadata_id, + no_attributes, + limit, + include_start_time_state, + run_start_ts, + ), + track_on=[ + bool(end_time_ts), + no_attributes, + bool(limit), + include_start_time_state, + ], ) return cast( MutableMapping[str, list[State]], _sorted_states_to_dict( - hass, - session, - states, - start_time, + execute_stmt_lambda_element( + session, stmt, None, end_time, orm_rows=False + ), + start_time_ts if include_start_time_state else None, entity_ids, entity_id_to_metadata_id, - include_start_time_state=include_start_time_state, + descending=descending, ), ) -def _get_last_state_changes_stmt( - number_of_states: int, metadata_id: int -) -> StatementLambdaElement: - stmt, join_attributes = _lambda_stmt_and_join_attributes( - False, include_last_changed=False - ) - stmt += lambda q: q.where( - States.state_id - == ( - select(States.state_id) - .filter(States.metadata_id == metadata_id) - .order_by(States.last_updated_ts.desc()) - .limit(number_of_states) - .subquery() - ).c.state_id - ) - if join_attributes: - stmt += lambda q: q.outerjoin( +def _get_last_state_changes_single_stmt(metadata_id: int) -> Select: + return ( + _stmt_and_join_attributes(False, False) + .join( + ( + lastest_state_for_metadata_id := ( + select( + States.metadata_id.label("max_metadata_id"), + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter(States.metadata_id == metadata_id) + .group_by(States.metadata_id) + .subquery() + ) + ), + and_( + States.metadata_id == lastest_state_for_metadata_id.c.max_metadata_id, + States.last_updated_ts + == lastest_state_for_metadata_id.c.max_last_updated, + ), + ) + .outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) + .order_by(States.state_id.desc()) + ) - stmt += lambda q: q.order_by(States.state_id.desc()) - return stmt + +def _get_last_state_changes_multiple_stmt( + number_of_states: int, metadata_id: int +) -> Select: + return ( + _stmt_and_join_attributes(False, False) + .where( + States.state_id + == ( + select(States.state_id) + .filter(States.metadata_id == metadata_id) + .order_by(States.last_updated_ts.desc()) + .limit(number_of_states) + .subquery() + ).c.state_id + ) + .outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + .order_by(States.state_id.desc()) + ) def get_last_state_changes( @@ -432,44 +496,55 @@ def get_last_state_changes( entity_id_lower = entity_id.lower() entity_ids = [entity_id_lower] + # Calling this function with number_of_states > 1 can cause instability + # because it has to scan the table to find the last number_of_states states + # because the metadata_id_last_updated_ts index is in ascending order. + with session_scope(hass=hass, read_only=True) as session: instance = recorder.get_instance(hass) if not ( - metadata_id := instance.states_meta_manager.get(entity_id, session, False) + possible_metadata_id := instance.states_meta_manager.get( + entity_id, session, False + ) ): return {} + metadata_id = possible_metadata_id entity_id_to_metadata_id: dict[str, int | None] = {entity_id_lower: metadata_id} - stmt = _get_last_state_changes_stmt(number_of_states, metadata_id) - states = list(execute_stmt_lambda_element(session, stmt)) + if number_of_states == 1: + stmt = lambda_stmt( + lambda: _get_last_state_changes_single_stmt(metadata_id), + ) + else: + stmt = lambda_stmt( + lambda: _get_last_state_changes_multiple_stmt( + number_of_states, metadata_id + ), + ) + states = list(execute_stmt_lambda_element(session, stmt, orm_rows=False)) return cast( MutableMapping[str, list[State]], _sorted_states_to_dict( - hass, - session, reversed(states), - dt_util.utcnow(), + None, entity_ids, entity_id_to_metadata_id, - include_start_time_state=False, ), ) -def _get_states_for_entities_stmt( - run_start: datetime, - utc_point_in_time: datetime, +def _get_start_time_state_for_entities_stmt( + run_start_ts: float, + epoch_time: float, metadata_ids: list[int], no_attributes: bool, -) -> StatementLambdaElement: + include_last_changed: bool, +) -> Select: """Baked query to get states for specific entities.""" - stmt, join_attributes = _lambda_stmt_and_join_attributes( - no_attributes, include_last_changed=True - ) # We got an include-list of entities, accelerate the query by filtering already # in the inner query. - run_start_ts = process_timestamp(run_start).timestamp() - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) - stmt += lambda q: q.join( + stmt = _stmt_and_join_attributes_for_start_state( + no_attributes, include_last_changed + ).join( ( most_recent_states_for_entities_by_date := ( select( @@ -480,7 +555,7 @@ def _get_states_for_entities_stmt( ) .filter( (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < utc_point_in_time_ts) + & (States.last_updated_ts < epoch_time) ) .filter(States.metadata_id.in_(metadata_ids)) .group_by(States.metadata_id) @@ -494,151 +569,88 @@ def _get_states_for_entities_stmt( == most_recent_states_for_entities_by_date.c.max_last_updated, ), ) - if join_attributes: - stmt += lambda q: q.outerjoin( - StateAttributes, (States.attributes_id == StateAttributes.attributes_id) - ) - return stmt + if no_attributes: + return stmt + return stmt.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) -def _get_states_for_all_stmt( - run_start: datetime, - utc_point_in_time: datetime, - filters: Filters | None, +def _get_run_start_ts_for_utc_point_in_time( + hass: HomeAssistant, utc_point_in_time: datetime +) -> float | None: + """Return the start time of a run.""" + run = recorder.get_instance(hass).recorder_runs_manager.get(utc_point_in_time) + if ( + run is not None + and (run_start := process_timestamp(run.start)) < utc_point_in_time + ): + return run_start.timestamp() + # History did not run before utc_point_in_time but we still + return None + + +def _get_start_time_state_stmt( + run_start_ts: float, + epoch_time: float, + single_metadata_id: int | None, + metadata_ids: list[int], no_attributes: bool, -) -> StatementLambdaElement: - """Baked query to get states for all entities.""" - stmt, join_attributes = _lambda_stmt_and_join_attributes( - no_attributes, include_last_changed=True - ) - # We did not get an include-list of entities, query all states in the inner - # query, then filter out unwanted domains as well as applying the custom filter. - # This filtering can't be done in the inner query because the domain column is - # not indexed and we can't control what's in the custom filter. - run_start_ts = process_timestamp(run_start).timestamp() - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) - stmt += lambda q: q.join( - ( - most_recent_states_by_date := ( - select( - States.metadata_id.label("max_metadata_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(States.last_updated_ts).label("max_last_updated"), - ) - .filter( - (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < utc_point_in_time_ts) - ) - .group_by(States.metadata_id) - .subquery() - ) - ), - and_( - States.metadata_id == most_recent_states_by_date.c.max_metadata_id, - States.last_updated_ts == most_recent_states_by_date.c.max_last_updated, - ), - ) - stmt += _ignore_domains_filter - if filters and filters.has_config: - entity_filter = filters.states_metadata_entity_filter() - stmt = stmt.add_criteria(lambda q: q.filter(entity_filter), track_on=[filters]) - if join_attributes: - stmt += lambda q: q.outerjoin( - StateAttributes, (States.attributes_id == StateAttributes.attributes_id) - ) - stmt += lambda q: q.outerjoin( - StatesMeta, States.metadata_id == StatesMeta.metadata_id - ) - return stmt - - -def _get_rows_with_session( - hass: HomeAssistant, - session: Session, - utc_point_in_time: datetime, - entity_ids: list[str] | None = None, - entity_id_to_metadata_id: dict[str, int | None] | None = None, - run: RecorderRuns | None = None, - filters: Filters | None = None, - no_attributes: bool = False, -) -> Iterable[Row]: + include_last_changed: bool, +) -> Select: """Return the states at a specific point in time.""" - if entity_ids and len(entity_ids) == 1: - if not entity_id_to_metadata_id or not ( - metadata_id := entity_id_to_metadata_id.get(entity_ids[0]) - ): - return [] - return execute_stmt_lambda_element( - session, - _get_single_entity_states_stmt( - utc_point_in_time, metadata_id, no_attributes - ), + if single_metadata_id: + # Use an entirely different (and extremely fast) query if we only + # have a single entity id + return _get_single_entity_start_time_stmt( + epoch_time, + single_metadata_id, + no_attributes, + include_last_changed, ) - - if run is None: - run = recorder.get_instance(hass).recorder_runs_manager.get(utc_point_in_time) - - if run is None or process_timestamp(run.start) > utc_point_in_time: - # History did not run before utc_point_in_time - return [] - # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. - if entity_ids: - if not entity_id_to_metadata_id or not ( - metadata_ids := extract_metadata_ids(entity_id_to_metadata_id) - ): - return [] - stmt = _get_states_for_entities_stmt( - run.start, utc_point_in_time, metadata_ids, no_attributes - ) - else: - stmt = _get_states_for_all_stmt( - run.start, utc_point_in_time, filters, no_attributes - ) - - return execute_stmt_lambda_element(session, stmt) + return _get_start_time_state_for_entities_stmt( + run_start_ts, + epoch_time, + metadata_ids, + no_attributes, + include_last_changed, + ) -def _get_single_entity_states_stmt( - utc_point_in_time: datetime, +def _get_single_entity_start_time_stmt( + epoch_time: float, metadata_id: int, - no_attributes: bool = False, -) -> StatementLambdaElement: + no_attributes: bool, + include_last_changed: bool, +) -> Select: # Use an entirely different (and extremely fast) query if we only # have a single entity id - stmt, join_attributes = _lambda_stmt_and_join_attributes( - no_attributes, include_last_changed=True - ) - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) - stmt += ( - lambda q: q.filter( - States.last_updated_ts < utc_point_in_time_ts, + stmt = ( + _stmt_and_join_attributes_for_start_state(no_attributes, include_last_changed) + .filter( + States.last_updated_ts < epoch_time, States.metadata_id == metadata_id, ) .order_by(States.last_updated_ts.desc()) .limit(1) ) - if join_attributes: - stmt += lambda q: q.outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ) - return stmt + if no_attributes: + return stmt + return stmt.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) def _sorted_states_to_dict( - hass: HomeAssistant, - session: Session, states: Iterable[Row], - start_time: datetime, - entity_ids: list[str] | None, - entity_id_to_metadata_id: dict[str, int | None] | None, - filters: Filters | None = None, - include_start_time_state: bool = True, + start_time_ts: float | None, + entity_ids: list[str], + entity_id_to_metadata_id: dict[str, int | None], minimal_response: bool = False, - no_attributes: bool = False, compressed_state_format: bool = False, + descending: bool = False, ) -> MutableMapping[str, list[State | dict[str, Any]]]: """Convert SQL results into JSON friendly data structure. @@ -653,7 +665,8 @@ def _sorted_states_to_dict( """ field_map = _FIELD_MAP state_class: Callable[ - [Row, dict[str, dict[str, Any]], datetime | None], State | dict[str, Any] + [Row, dict[str, dict[str, Any]], float | None, str, str, float | None], + State | dict[str, Any], ] if compressed_state_format: state_class = row_to_compressed_state @@ -664,73 +677,51 @@ def _sorted_states_to_dict( attr_time = LAST_CHANGED_KEY attr_state = STATE_KEY - result: dict[str, list[State | dict[str, Any]]] = defaultdict(list) - metadata_id_to_entity_id: dict[int, str] = {} - metadata_id_idx = field_map["metadata_id"] - # Set all entity IDs to empty lists in result set to maintain the order - if entity_ids is not None: - for ent_id in entity_ids: - result[ent_id] = [] - - if entity_id_to_metadata_id: - metadata_id_to_entity_id = { - v: k for k, v in entity_id_to_metadata_id.items() if v is not None - } - else: - metadata_id_to_entity_id = recorder.get_instance( - hass - ).states_meta_manager.get_metadata_id_to_entity_id(session) - + result: dict[str, list[State | dict[str, Any]]] = { + entity_id: [] for entity_id in entity_ids + } + metadata_id_to_entity_id: dict[int, str] = {} + metadata_id_to_entity_id = { + v: k for k, v in entity_id_to_metadata_id.items() if v is not None + } # Get the states at the start time - initial_states: dict[int, Row] = {} - if include_start_time_state: - initial_states = { - row[metadata_id_idx]: row - for row in _get_rows_with_session( - hass, - session, - start_time, - entity_ids, - entity_id_to_metadata_id, - filters=filters, - no_attributes=no_attributes, - ) - } - - if entity_ids and len(entity_ids) == 1: - if not entity_id_to_metadata_id or not ( - metadata_id := entity_id_to_metadata_id.get(entity_ids[0]) - ): - return {} + if len(entity_ids) == 1: + metadata_id = entity_id_to_metadata_id[entity_ids[0]] + assert metadata_id is not None # should not be possible if we got here states_iter: Iterable[tuple[int, Iterator[Row]]] = ( (metadata_id, iter(states)), ) else: - key_func = itemgetter(metadata_id_idx) + key_func = itemgetter(field_map["metadata_id"]) states_iter = groupby(states, key_func) + state_idx = field_map["state"] + last_updated_ts_idx = field_map["last_updated_ts"] + # Append all changes to it for metadata_id, group in states_iter: + entity_id = metadata_id_to_entity_id[metadata_id] attr_cache: dict[str, dict[str, Any]] = {} - prev_state: Column | str | None = None - if not (entity_id := metadata_id_to_entity_id.get(metadata_id)): - continue ent_results = result[entity_id] - if row := initial_states.pop(metadata_id, None): - prev_state = row.state - ent_results.append(state_class(row, attr_cache, start_time, entity_id=entity_id)) # type: ignore[call-arg] - if ( not minimal_response or split_entity_id(entity_id)[0] in NEED_ATTRIBUTE_DOMAINS ): ent_results.extend( - state_class(db_state, attr_cache, None, entity_id=entity_id) # type: ignore[call-arg] + state_class( + db_state, + attr_cache, + start_time_ts, + entity_id, + db_state[state_idx], + db_state[last_updated_ts_idx], + ) for db_state in group ) continue + prev_state: str | None = None # With minimal response we only provide a native # State for the first and last response. All the states # in-between only provide the "state" and the @@ -738,14 +729,18 @@ def _sorted_states_to_dict( if not ent_results: if (first_state := next(group, None)) is None: continue - prev_state = first_state.state + prev_state = first_state[state_idx] ent_results.append( - state_class(first_state, attr_cache, None, entity_id=entity_id) # type: ignore[call-arg] + state_class( + first_state, + attr_cache, + start_time_ts, + entity_id, + prev_state, # type: ignore[arg-type] + first_state[last_updated_ts_idx], + ) ) - state_idx = field_map["state"] - last_updated_ts_idx = field_map["last_updated_ts"] - # # minimal_response only makes sense with last_updated == last_updated # @@ -776,13 +771,9 @@ def _sorted_states_to_dict( if (state := row[state_idx]) != prev_state ) - # If there are no states beyond the initial state, - # the state a was never popped from initial_states - for metadata_id, row in initial_states.items(): - if entity_id := metadata_id_to_entity_id.get(metadata_id): - result[entity_id].append( - state_class(row, {}, start_time, entity_id=entity_id) # type: ignore[call-arg] - ) + if descending: + for ent_results in result.values(): + ent_results.reverse() # Filter out the empty lists if some states had 0 results. return {key: val for key, val in result.items() if val} diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 4f87c19ca7..5ca56de513 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -6,5 +6,9 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["sqlalchemy==2.0.6", "fnvhash==0.1.0"] + "requirements": [ + "sqlalchemy==2.0.10", + "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 6fc2138d91..1d2b56fb43 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -6,6 +6,7 @@ import contextlib from dataclasses import dataclass, replace as dataclass_replace from datetime import timedelta import logging +from time import time from typing import TYPE_CHECKING, cast from uuid import UUID @@ -26,8 +27,16 @@ from sqlalchemy.sql.expression import true from homeassistant.core import HomeAssistant from homeassistant.util.enum import try_parse_enum -from homeassistant.util.ulid import ulid_to_bytes +from homeassistant.util.ulid import ulid_at_time, ulid_to_bytes +from .auto_repairs.events.schema import ( + correct_db_schema as events_correct_db_schema, + validate_db_schema as events_validate_db_schema, +) +from .auto_repairs.states.schema import ( + correct_db_schema as states_correct_db_schema, + validate_db_schema as states_validate_db_schema, +) from .auto_repairs.statistics.duplicates import ( delete_statistics_duplicates, delete_statistics_meta_duplicates, @@ -39,7 +48,11 @@ from .auto_repairs.statistics.schema import ( from .const import SupportedDialect from .db_schema import ( CONTEXT_ID_BIN_MAX_LENGTH, + DOUBLE_PRECISION_TYPE_SQL, + LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX, LEGACY_STATES_EVENT_ID_INDEX, + MYSQL_COLLATE, + MYSQL_DEFAULT_CHARSET, SCHEMA_VERSION, STATISTICS_TABLES, TABLE_STATES, @@ -80,7 +93,6 @@ if TYPE_CHECKING: from . import Recorder LIVE_MIGRATION_MIN_SCHEMA_VERSION = 0 -_EMPTY_CONTEXT_ID = b"\x00" * 16 _EMPTY_ENTITY_ID = "missing.entity_id" _EMPTY_EVENT_TYPE = "missing_event_type" @@ -96,13 +108,13 @@ class _ColumnTypesForDialect: _MYSQL_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER(20)", - timestamp_type="DOUBLE PRECISION", + timestamp_type=DOUBLE_PRECISION_TYPE_SQL, context_bin_type=f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})", ) _POSTGRESQL_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER", - timestamp_type="DOUBLE PRECISION", + timestamp_type=DOUBLE_PRECISION_TYPE_SQL, context_bin_type="BYTEA", ) @@ -132,7 +144,11 @@ def raise_if_exception_missing_str(ex: Exception, match_substrs: Iterable[str]) def _get_schema_version(session: Session) -> int | None: """Get the schema version.""" - res = session.query(SchemaChanges).order_by(SchemaChanges.change_id.desc()).first() + res = ( + session.query(SchemaChanges.schema_version) + .order_by(SchemaChanges.change_id.desc()) + .first() + ) return getattr(res, "schema_version", None) @@ -151,7 +167,7 @@ class SchemaValidationStatus: """Store schema validation status.""" current_version: int - statistics_schema_errors: set[str] + schema_errors: set[str] valid: bool @@ -178,13 +194,24 @@ def validate_db_schema( if is_current := _schema_is_current(current_version): # We can only check for further errors if the schema is current, because # columns may otherwise not exist etc. - schema_errors |= statistics_validate_db_schema(hass, instance, session_maker) + schema_errors = _find_schema_errors(hass, instance, session_maker) valid = is_current and not schema_errors return SchemaValidationStatus(current_version, schema_errors, valid) +def _find_schema_errors( + hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] +) -> set[str]: + """Find schema errors.""" + schema_errors: set[str] = set() + schema_errors |= statistics_validate_db_schema(instance) + schema_errors |= states_validate_db_schema(instance) + schema_errors |= events_validate_db_schema(instance) + return schema_errors + + def live_migration(schema_status: SchemaValidationStatus) -> bool: """Check if live migration is possible.""" return schema_status.current_version >= LIVE_MIGRATION_MIN_SCHEMA_VERSION @@ -226,12 +253,14 @@ def migrate_schema( # so its clear that the upgrade is done _LOGGER.warning("Upgrade to version %s done", new_version) - if schema_errors := schema_status.statistics_schema_errors: + if schema_errors := schema_status.schema_errors: _LOGGER.warning( "Database is about to correct DB schema errors: %s", ", ".join(sorted(schema_errors)), ) - statistics_correct_db_schema(instance, engine, session_maker, schema_errors) + statistics_correct_db_schema(instance, schema_errors) + states_correct_db_schema(instance, schema_errors) + events_correct_db_schema(instance, schema_errors) if current_version != SCHEMA_VERSION: instance.queue_task(PostSchemaMigrationTask(current_version, SCHEMA_VERSION)) @@ -279,6 +308,19 @@ def _create_index( _LOGGER.debug("Finished creating %s", index_name) +def _execute_or_collect_error( + session_maker: Callable[[], Session], query: str, errors: list[str] +) -> bool: + """Execute a query or collect an error.""" + with session_scope(session=session_maker()) as session: + try: + session.connection().execute(text(query)) + return True + except SQLAlchemyError as err: + errors.append(str(err)) + return False + + def _drop_index( session_maker: Callable[[], Session], table_name: str, @@ -304,74 +346,45 @@ def _drop_index( index_name, table_name, ) - success = False + index_to_drop: str | None = None + with session_scope(session=session_maker()) as session: + index_to_drop = get_index_by_name(session, table_name, index_name) - # Engines like DB2/Oracle - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - connection = session.connection() - connection.execute(text(f"DROP INDEX {index_name}")) - success = True - - # Engines like SQLite, SQL Server - if not success: - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - connection = session.connection() - connection.execute( - text( - "DROP INDEX {table}.{index}".format( - index=index_name, table=table_name - ) - ) - ) - success = True - - if not success: - # Engines like MySQL, MS Access - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - connection = session.connection() - connection.execute( - text( - "DROP INDEX {index} ON {table}".format( - index=index_name, table=table_name - ) - ) - ) - success = True - - if not success: - # Engines like postgresql may have a prefix - # ex idx_16532_ix_events_event_type_time_fired - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - if index_to_drop := get_index_by_name(session, table_name, index_name): - connection.execute(text(f"DROP INDEX {index_to_drop}")) - success = True - - if success: + if index_to_drop is None: _LOGGER.debug( - "Finished dropping index %s from table %s", index_name, table_name + "The index %s on table %s no longer exists", index_name, table_name ) return - if quiet: - return + errors: list[str] = [] + for query in ( + # Engines like DB2/Oracle + f"DROP INDEX {index_name}", + # Engines like SQLite, SQL Server + f"DROP INDEX {table_name}.{index_name}", + # Engines like MySQL, MS Access + f"DROP INDEX {index_name} ON {table_name}", + # Engines like postgresql may have a prefix + # ex idx_16532_ix_events_event_type_time_fired + f"DROP INDEX {index_to_drop}", + ): + if _execute_or_collect_error(session_maker, query, errors): + _LOGGER.debug( + "Finished dropping index %s from table %s", index_name, table_name + ) + return - _LOGGER.warning( - ( - "Failed to drop index `%s` from table `%s`. Schema " - "Migration will continue; this is not a " - "critical operation" - ), - index_name, - table_name, - ) + if not quiet: + _LOGGER.warning( + ( + "Failed to drop index `%s` from table `%s`. Schema " + "Migration will continue; this is not a " + "critical operation: %s" + ), + index_name, + table_name, + errors, + ) def _add_columns( @@ -732,38 +745,15 @@ def _apply_update( # noqa: C901 engine, "statistics", [ - "mean DOUBLE PRECISION", - "min DOUBLE PRECISION", - "max DOUBLE PRECISION", - "state DOUBLE PRECISION", - "sum DOUBLE PRECISION", + f"{column} {DOUBLE_PRECISION_TYPE_SQL}" + for column in ("max", "mean", "min", "state", "sum") ], ) elif new_version == 21: # Try to change the character set of the statistic_meta table if engine.dialect.name == SupportedDialect.MYSQL: for table in ("events", "states", "statistics_meta"): - _LOGGER.warning( - ( - "Updating character set and collation of table %s to utf8mb4." - " Note: this can take several minutes on large databases and" - " slow computers. Please be patient!" - ), - table, - ) - with contextlib.suppress(SQLAlchemyError), session_scope( - session=session_maker() - ) as session: - connection = session.connection() - connection.execute( - # Using LOCK=EXCLUSIVE to prevent - # the database from corrupting - # https://github.com/home-assistant/core/issues/56104 - text( - f"ALTER TABLE {table} CONVERT TO CHARACTER SET utf8mb4" - " COLLATE utf8mb4_unicode_ci, LOCK=EXCLUSIVE" - ) - ) + _correct_table_character_set_and_collation(table, session_maker) elif new_version == 22: # Recreate the all statistics tables for Oracle DB with Identity columns # @@ -924,7 +914,7 @@ def _apply_update( # noqa: C901 _create_index(session_maker, "events", "ix_events_event_type_time_fired_ts") _create_index(session_maker, "states", "ix_states_entity_id_last_updated_ts") _create_index(session_maker, "states", "ix_states_last_updated_ts") - _migrate_columns_to_timestamp(session_maker, engine) + _migrate_columns_to_timestamp(instance, session_maker, engine) elif new_version == 32: # Migration is done in two steps to ensure we can start using # the new columns before we wipe the old ones. @@ -977,7 +967,7 @@ def _apply_update( # noqa: C901 "ix_statistics_short_term_statistic_id_start_ts", ) try: - _migrate_statistics_columns_to_timestamp(session_maker, engine) + _migrate_statistics_columns_to_timestamp(instance, session_maker, engine) except IntegrityError as ex: _LOGGER.error( "Statistics table contains duplicate entries: %s; " @@ -990,7 +980,7 @@ def _apply_update( # noqa: C901 # and try again with session_scope(session=session_maker()) as session: delete_statistics_duplicates(instance, hass, session) - _migrate_statistics_columns_to_timestamp(session_maker, engine) + _migrate_statistics_columns_to_timestamp(instance, session_maker, engine) # Log at error level to ensure the user sees this message in the log # since we logged the error above. _LOGGER.error( @@ -1090,6 +1080,33 @@ def _apply_update( # noqa: C901 raise ValueError(f"No schema migration defined for version {new_version}") +def _correct_table_character_set_and_collation( + table: str, + session_maker: Callable[[], Session], +) -> None: + """Correct issues detected by validate_db_schema.""" + # Attempt to convert the table to utf8mb4 + _LOGGER.warning( + "Updating character set and collation of table %s to utf8mb4. " + "Note: this can take several minutes on large databases and slow " + "computers. Please be patient!", + table, + ) + with contextlib.suppress(SQLAlchemyError), session_scope( + session=session_maker() + ) as session: + connection = session.connection() + connection.execute( + # Using LOCK=EXCLUSIVE to prevent the database from corrupting + # https://github.com/home-assistant/core/issues/56104 + text( + f"ALTER TABLE {table} CONVERT TO CHARACTER SET " + f"{MYSQL_DEFAULT_CHARSET} " + f"COLLATE {MYSQL_COLLATE}, LOCK=EXCLUSIVE" + ) + ) + + def post_schema_migration( instance: Recorder, old_version: int, @@ -1179,8 +1196,9 @@ def _wipe_old_string_time_columns( session.commit() +@database_job_retry_wrapper("Migrate columns to timestamp", 3) def _migrate_columns_to_timestamp( - session_maker: Callable[[], Session], engine: Engine + instance: Recorder, session_maker: Callable[[], Session], engine: Engine ) -> None: """Migrate columns to use timestamp.""" # Migrate all data in Events.time_fired to Events.time_fired_ts @@ -1246,7 +1264,7 @@ def _migrate_columns_to_timestamp( text( "UPDATE events SET " "time_fired_ts= " - "(case when time_fired is NULL then 0 else EXTRACT(EPOCH FROM time_fired) end) " + "(case when time_fired is NULL then 0 else EXTRACT(EPOCH FROM time_fired::timestamptz) end) " "WHERE event_id IN ( " "SELECT event_id FROM events where time_fired_ts is NULL LIMIT 250000 " " );" @@ -1258,8 +1276,8 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE states set last_updated_ts=" - "(case when last_updated is NULL then 0 else EXTRACT(EPOCH FROM last_updated) end), " - "last_changed_ts=EXTRACT(EPOCH FROM last_changed) " + "(case when last_updated is NULL then 0 else EXTRACT(EPOCH FROM last_updated::timestamptz) end), " + "last_changed_ts=EXTRACT(EPOCH FROM last_changed::timestamptz) " "where state_id IN ( " "SELECT state_id FROM states where last_updated_ts is NULL LIMIT 250000 " " );" @@ -1267,8 +1285,9 @@ def _migrate_columns_to_timestamp( ) +@database_job_retry_wrapper("Migrate statistics columns to timestamp", 3) def _migrate_statistics_columns_to_timestamp( - session_maker: Callable[[], Session], engine: Engine + instance: Recorder, session_maker: Callable[[], Session], engine: Engine ) -> None: """Migrate statistics columns to use timestamp.""" # Migrate all data in statistics.start to statistics.start_ts @@ -1325,12 +1344,12 @@ def _migrate_statistics_columns_to_timestamp( result = session.connection().execute( text( f"UPDATE {table} set start_ts=" # nosec - "(case when start is NULL then 0 else EXTRACT(EPOCH FROM start) end), " - "created_ts=EXTRACT(EPOCH FROM created), " - "last_reset_ts=EXTRACT(EPOCH FROM last_reset) " - "where id IN ( " - f"SELECT id FROM {table} where start_ts is NULL LIMIT 100000 " - " );" + "(case when start is NULL then 0 else EXTRACT(EPOCH FROM start::timestamptz) end), " + "created_ts=EXTRACT(EPOCH FROM created::timestamptz), " + "last_reset_ts=EXTRACT(EPOCH FROM last_reset::timestamptz) " + "where id IN (" + f"SELECT id FROM {table} where start_ts is NULL LIMIT 100000" + ");" ) ) @@ -1339,13 +1358,23 @@ def _context_id_to_bytes(context_id: str | None) -> bytes | None: """Convert a context_id to bytes.""" if context_id is None: return None - if len(context_id) == 32: + with contextlib.suppress(ValueError): + # There may be garbage in the context_id column + # from custom integrations that are not UUIDs or + # ULIDs that filled the column to the max length + # so we need to catch the ValueError and return + # None if it happens + if len(context_id) == 26: + return ulid_to_bytes(context_id) return UUID(context_id).bytes - if len(context_id) == 26: - return ulid_to_bytes(context_id) return None +def _generate_ulid_bytes_at_time(timestamp: float | None) -> bytes: + """Generate a ulid with a specific timestamp.""" + return ulid_to_bytes(ulid_at_time(timestamp or time())) + + @retryable_database_job("migrate states context_ids to binary format") def migrate_states_context_ids(instance: Recorder) -> bool: """Migrate states context_ids to use binary format.""" @@ -1360,13 +1389,14 @@ def migrate_states_context_ids(instance: Recorder) -> bool: { "state_id": state_id, "context_id": None, - "context_id_bin": _to_bytes(context_id) or _EMPTY_CONTEXT_ID, + "context_id_bin": _to_bytes(context_id) + or _generate_ulid_bytes_at_time(last_updated_ts), "context_user_id": None, "context_user_id_bin": _to_bytes(context_user_id), "context_parent_id": None, "context_parent_id_bin": _to_bytes(context_parent_id), } - for state_id, context_id, context_user_id, context_parent_id in states + for state_id, last_updated_ts, context_id, context_user_id, context_parent_id in states ], ) # If there is more work to do return False @@ -1394,13 +1424,14 @@ def migrate_events_context_ids(instance: Recorder) -> bool: { "event_id": event_id, "context_id": None, - "context_id_bin": _to_bytes(context_id) or _EMPTY_CONTEXT_ID, + "context_id_bin": _to_bytes(context_id) + or _generate_ulid_bytes_at_time(time_fired_ts), "context_user_id": None, "context_user_id_bin": _to_bytes(context_user_id), "context_parent_id": None, "context_parent_id_bin": _to_bytes(context_parent_id), } - for event_id, context_id, context_user_id, context_parent_id in events + for event_id, time_fired_ts, context_id, context_user_id, context_parent_id in events ], ) # If there is more work to do return False @@ -1423,12 +1454,15 @@ def migrate_event_type_ids(instance: Recorder) -> bool: with session_scope(session=session_maker()) as session: if events := session.execute(find_event_type_to_migrate()).all(): event_types = {event_type for _, event_type in events} + if None in event_types: + # event_type should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + event_types.remove(None) + event_types.add(_EMPTY_EVENT_TYPE) + event_type_to_id = event_type_manager.get_many(event_types, session) if missing_event_types := { - # We should never see see None for the event_Type in the events table - # but we need to be defensive so we don't fail the migration - # because of a bad event - _EMPTY_EVENT_TYPE if event_type is None else event_type + event_type for event_type, event_id in event_type_to_id.items() if event_id is None }: @@ -1447,6 +1481,7 @@ def migrate_event_type_ids(instance: Recorder) -> bool: event_type_to_id[ db_event_type.event_type ] = db_event_type.event_type_id + event_type_manager.clear_non_existent(db_event_type.event_type) session.execute( update(Events), @@ -1454,7 +1489,9 @@ def migrate_event_type_ids(instance: Recorder) -> bool: { "event_id": event_id, "event_type": None, - "event_type_id": event_type_to_id[event_type], + "event_type_id": event_type_to_id[ + _EMPTY_EVENT_TYPE if event_type is None else event_type + ], } for event_id, event_type in events ], @@ -1486,14 +1523,17 @@ def migrate_entity_ids(instance: Recorder) -> bool: with session_scope(session=instance.get_session()) as session: if states := session.execute(find_entity_ids_to_migrate()).all(): entity_ids = {entity_id for _, entity_id in states} + if None in entity_ids: + # entity_id should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + entity_ids.remove(None) + entity_ids.add(_EMPTY_ENTITY_ID) + entity_id_to_metadata_id = states_meta_manager.get_many( entity_ids, session, True ) if missing_entity_ids := { - # We should never see _EMPTY_ENTITY_ID in the states table - # but we need to be defensive so we don't fail the migration - # because of a bad state - _EMPTY_ENTITY_ID if entity_id is None else entity_id + entity_id for entity_id, metadata_id in entity_id_to_metadata_id.items() if metadata_id is None }: @@ -1521,7 +1561,9 @@ def migrate_entity_ids(instance: Recorder) -> bool: # the history queries still need to work while the # migration is in progress and we will do this in # post_migrate_entity_ids - "metadata_id": entity_id_to_metadata_id[entity_id], + "metadata_id": entity_id_to_metadata_id[ + _EMPTY_ENTITY_ID if entity_id is None else entity_id + ], } for state_id, entity_id in states ], @@ -1552,7 +1594,7 @@ def post_migrate_entity_ids(instance: Recorder) -> bool: if is_done: # Drop the old indexes since they are no longer needed - _drop_index(session_maker, "states", "ix_states_entity_id_last_updated_ts") + _drop_index(session_maker, "states", LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX) _LOGGER.debug("Cleanup legacy entity_ids done=%s", is_done) return is_done diff --git a/homeassistant/components/recorder/models/__init__.py b/homeassistant/components/recorder/models/__init__.py index 91dd80c4aa..1a204e767e 100644 --- a/homeassistant/components/recorder/models/__init__.py +++ b/homeassistant/components/recorder/models/__init__.py @@ -8,6 +8,7 @@ from .context import ( uuid_hex_to_bytes_or_none, ) from .database import DatabaseEngine, DatabaseOptimizer, UnsupportedDialect +from .event import extract_event_type_ids from .state import LazyState, extract_metadata_ids, row_to_compressed_state from .statistics import ( CalendarStatisticPeriod, @@ -43,6 +44,7 @@ __all__ = [ "bytes_to_ulid_or_none", "bytes_to_uuid_hex_or_none", "datetime_to_timestamp_or_none", + "extract_event_type_ids", "extract_metadata_ids", "process_datetime_to_timestamp", "process_timestamp", diff --git a/homeassistant/components/recorder/models/context.py b/homeassistant/components/recorder/models/context.py index dbd9383bde..f722c51983 100644 --- a/homeassistant/components/recorder/models/context.py +++ b/homeassistant/components/recorder/models/context.py @@ -3,23 +3,36 @@ from __future__ import annotations from contextlib import suppress from functools import lru_cache +import logging from uuid import UUID from homeassistant.util.ulid import bytes_to_ulid, ulid_to_bytes +_LOGGER = logging.getLogger(__name__) + def ulid_to_bytes_or_none(ulid: str | None) -> bytes | None: """Convert an ulid to bytes.""" if ulid is None: return None - return ulid_to_bytes(ulid) + try: + return ulid_to_bytes(ulid) + except ValueError as ex: + _LOGGER.error("Error converting ulid %s to bytes: %s", ulid, ex, exc_info=True) + return None def bytes_to_ulid_or_none(_bytes: bytes | None) -> str | None: """Convert bytes to a ulid.""" if _bytes is None: return None - return bytes_to_ulid(_bytes) + try: + return bytes_to_ulid(_bytes) + except ValueError as ex: + _LOGGER.error( + "Error converting bytes %s to ulid: %s", _bytes, ex, exc_info=True + ) + return None @lru_cache(maxsize=16) diff --git a/homeassistant/components/recorder/models/event.py b/homeassistant/components/recorder/models/event.py new file mode 100644 index 0000000000..1d644b62f4 --- /dev/null +++ b/homeassistant/components/recorder/models/event.py @@ -0,0 +1,13 @@ +"""Models events in for Recorder.""" +from __future__ import annotations + + +def extract_event_type_ids( + event_type_to_event_type_id: dict[str, int | None], +) -> list[int]: + """Extract event_type ids from event_type_to_event_type_id.""" + return [ + event_type_id + for event_type_id in event_type_to_event_type_id.values() + if event_type_id is not None + ] diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py index c26e517772..398ad773ba 100644 --- a/homeassistant/components/recorder/models/legacy.py +++ b/homeassistant/components/recorder/models/legacy.py @@ -13,18 +13,17 @@ from homeassistant.const import ( COMPRESSED_STATE_STATE, ) from homeassistant.core import Context, State +import homeassistant.util.dt as dt_util -from .state_attributes import decode_attributes_from_row +from .state_attributes import decode_attributes_from_source from .time import ( process_datetime_to_timestamp, process_timestamp, process_timestamp_to_utc_isoformat, ) -# pylint: disable=invalid-name - -class LazyStatePreSchema31(State): +class LegacyLazyStatePreSchema31(State): """A lazy version of core State before schema 31.""" __slots__ = [ @@ -56,7 +55,9 @@ class LazyStatePreSchema31(State): def attributes(self) -> dict[str, Any]: """State attributes.""" if self._attributes is None: - self._attributes = decode_attributes_from_row(self._row, self.attr_cache) + self._attributes = decode_attributes_from_row_legacy( + self._row, self.attr_cache + ) return self._attributes @attributes.setter @@ -138,7 +139,7 @@ class LazyStatePreSchema31(State): } -def row_to_compressed_state_pre_schema_31( +def legacy_row_to_compressed_state_pre_schema_31( row: Row, attr_cache: dict[str, dict[str, Any]], start_time: datetime | None, @@ -146,7 +147,7 @@ def row_to_compressed_state_pre_schema_31( """Convert a database row to a compressed state before schema 31.""" comp_state = { COMPRESSED_STATE_STATE: row.state, - COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row(row, attr_cache), + COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row_legacy(row, attr_cache), } if start_time: comp_state[COMPRESSED_STATE_LAST_UPDATED] = start_time.timestamp() @@ -162,3 +163,137 @@ def row_to_compressed_state_pre_schema_31( row_changed_changed ) return comp_state + + +class LegacyLazyState(State): + """A lazy version of core State after schema 31.""" + + __slots__ = [ + "_row", + "_attributes", + "_last_changed_ts", + "_last_updated_ts", + "_context", + "attr_cache", + ] + + def __init__( # pylint: disable=super-init-not-called + self, + row: Row, + attr_cache: dict[str, dict[str, Any]], + start_time: datetime | None, + entity_id: str | None = None, + ) -> None: + """Init the lazy state.""" + self._row = row + self.entity_id = entity_id or self._row.entity_id + self.state = self._row.state or "" + self._attributes: dict[str, Any] | None = None + self._last_updated_ts: float | None = self._row.last_updated_ts or ( + dt_util.utc_to_timestamp(start_time) if start_time else None + ) + self._last_changed_ts: float | None = ( + self._row.last_changed_ts or self._last_updated_ts + ) + self._context: Context | None = None + self.attr_cache = attr_cache + + @property # type: ignore[override] + def attributes(self) -> dict[str, Any]: + """State attributes.""" + if self._attributes is None: + self._attributes = decode_attributes_from_row_legacy( + self._row, self.attr_cache + ) + return self._attributes + + @attributes.setter + def attributes(self, value: dict[str, Any]) -> None: + """Set attributes.""" + self._attributes = value + + @property + def context(self) -> Context: + """State context.""" + if self._context is None: + self._context = Context(id=None) + return self._context + + @context.setter + def context(self, value: Context) -> None: + """Set context.""" + self._context = value + + @property + def last_changed(self) -> datetime: + """Last changed datetime.""" + assert self._last_changed_ts is not None + return dt_util.utc_from_timestamp(self._last_changed_ts) + + @last_changed.setter + def last_changed(self, value: datetime) -> None: + """Set last changed datetime.""" + self._last_changed_ts = process_timestamp(value).timestamp() + + @property + def last_updated(self) -> datetime: + """Last updated datetime.""" + assert self._last_updated_ts is not None + return dt_util.utc_from_timestamp(self._last_updated_ts) + + @last_updated.setter + def last_updated(self, value: datetime) -> None: + """Set last updated datetime.""" + self._last_updated_ts = process_timestamp(value).timestamp() + + def as_dict(self) -> dict[str, Any]: # type: ignore[override] + """Return a dict representation of the LazyState. + + Async friendly. + To be used for JSON serialization. + """ + last_updated_isoformat = self.last_updated.isoformat() + if self._last_changed_ts == self._last_updated_ts: + last_changed_isoformat = last_updated_isoformat + else: + last_changed_isoformat = self.last_changed.isoformat() + return { + "entity_id": self.entity_id, + "state": self.state, + "attributes": self._attributes or self.attributes, + "last_changed": last_changed_isoformat, + "last_updated": last_updated_isoformat, + } + + +def legacy_row_to_compressed_state( + row: Row, + attr_cache: dict[str, dict[str, Any]], + start_time: datetime | None, + entity_id: str | None = None, +) -> dict[str, Any]: + """Convert a database row to a compressed state schema 31 and later.""" + comp_state = { + COMPRESSED_STATE_STATE: row.state, + COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row_legacy(row, attr_cache), + } + if start_time: + comp_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp(start_time) + else: + row_last_updated_ts: float = row.last_updated_ts + comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts + if ( + row_last_changed_ts := row.last_changed_ts + ) and row_last_updated_ts != row_last_changed_ts: + comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_last_changed_ts + return comp_state + + +def decode_attributes_from_row_legacy( + row: Row, attr_cache: dict[str, dict[str, Any]] +) -> dict[str, Any]: + """Decode attributes from a database row.""" + return decode_attributes_from_source( + getattr(row, "shared_attrs", None) or getattr(row, "attributes", None), + attr_cache, + ) diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 5594f5f6d4..523ffdf185 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -16,11 +16,9 @@ from homeassistant.const import ( from homeassistant.core import Context, State import homeassistant.util.dt as dt_util -from .state_attributes import decode_attributes_from_row +from .state_attributes import decode_attributes_from_source from .time import process_timestamp -# pylint: disable=invalid-name - _LOGGER = logging.getLogger(__name__) @@ -51,20 +49,18 @@ class LazyState(State): self, row: Row, attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, - entity_id: str | None = None, + start_time_ts: float | None, + entity_id: str, + state: str, + last_updated_ts: float | None, ) -> None: """Init the lazy state.""" self._row = row - self.entity_id = entity_id or self._row.entity_id - self.state = self._row.state or "" + self.entity_id = entity_id + self.state = state or "" self._attributes: dict[str, Any] | None = None - self._last_updated_ts: float | None = self._row.last_updated_ts or ( - dt_util.utc_to_timestamp(start_time) if start_time else None - ) - self._last_changed_ts: float | None = ( - self._row.last_changed_ts or self._last_updated_ts - ) + self._last_updated_ts: float | None = last_updated_ts or start_time_ts + self._last_changed_ts: float | None = None self._context: Context | None = None self.attr_cache = attr_cache @@ -72,7 +68,9 @@ class LazyState(State): def attributes(self) -> dict[str, Any]: """State attributes.""" if self._attributes is None: - self._attributes = decode_attributes_from_row(self._row, self.attr_cache) + self._attributes = decode_attributes_from_source( + getattr(self._row, "attributes", None), self.attr_cache + ) return self._attributes @attributes.setter @@ -95,7 +93,10 @@ class LazyState(State): @property def last_changed(self) -> datetime: """Last changed datetime.""" - assert self._last_changed_ts is not None + if self._last_changed_ts is None: + self._last_changed_ts = ( + getattr(self._row, "last_changed_ts", None) or self._last_updated_ts + ) return dt_util.utc_from_timestamp(self._last_changed_ts) @last_changed.setter @@ -138,21 +139,24 @@ class LazyState(State): def row_to_compressed_state( row: Row, attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, - entity_id: str | None = None, + start_time_ts: float | None, + entity_id: str, + state: str, + last_updated_ts: float | None, ) -> dict[str, Any]: - """Convert a database row to a compressed state schema 31 and later.""" - comp_state = { - COMPRESSED_STATE_STATE: row.state, - COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row(row, attr_cache), + """Convert a database row to a compressed state schema 41 and later.""" + comp_state: dict[str, Any] = { + COMPRESSED_STATE_STATE: state, + COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_source( + getattr(row, "attributes", None), attr_cache + ), } - if start_time: - comp_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp(start_time) - else: - row_last_updated_ts: float = row.last_updated_ts - comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts - if ( - row_changed_changed_ts := row.last_changed_ts - ) and row_last_updated_ts != row_changed_changed_ts: - comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_changed_changed_ts + row_last_updated_ts: float = last_updated_ts or start_time_ts # type: ignore[assignment] + comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts + if ( + (row_last_changed_ts := getattr(row, "last_changed_ts", None)) + and row_last_changed_ts + and row_last_updated_ts != row_last_changed_ts + ): + comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_last_changed_ts return comp_state diff --git a/homeassistant/components/recorder/models/state_attributes.py b/homeassistant/components/recorder/models/state_attributes.py index 3ed109afa0..c9cc110e1e 100644 --- a/homeassistant/components/recorder/models/state_attributes.py +++ b/homeassistant/components/recorder/models/state_attributes.py @@ -5,21 +5,16 @@ from __future__ import annotations import logging from typing import Any -from sqlalchemy.engine.row import Row - from homeassistant.util.json import json_loads_object EMPTY_JSON_OBJECT = "{}" _LOGGER = logging.getLogger(__name__) -def decode_attributes_from_row( - row: Row, attr_cache: dict[str, dict[str, Any]] +def decode_attributes_from_source( + source: Any, attr_cache: dict[str, dict[str, Any]] ) -> dict[str, Any]: - """Decode attributes from a database row.""" - source: str | None = getattr(row, "shared_attrs", None) or getattr( - row, "attributes", None - ) + """Decode attributes from a row source.""" if not source or source == EMPTY_JSON_OBJECT: return {} if (attributes := attr_cache.get(source)) is not None: diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index 02ba7545f8..09b113f03e 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -52,7 +52,7 @@ class RecorderPool(SingletonThreadPool, NullPool): # type: ignore[misc] thread_name == "Recorder" or thread_name.startswith(DB_WORKER_PREFIX) ) - def _do_return_conn(self, record: ConnectionPoolEntry) -> Any: + def _do_return_conn(self, record: ConnectionPoolEntry) -> None: if self.recorder_or_dbworker: return super()._do_return_conn(record) record.close() @@ -72,8 +72,7 @@ class RecorderPool(SingletonThreadPool, NullPool): # type: ignore[misc] if self.recorder_or_dbworker: super().dispose() - # Any can be switched out for ConnectionPoolEntry in the next version of sqlalchemy - def _do_get(self) -> Any: + def _do_get(self) -> ConnectionPoolEntry: if self.recorder_or_dbworker: return super()._do_get() check_loop( @@ -83,7 +82,7 @@ class RecorderPool(SingletonThreadPool, NullPool): # type: ignore[misc] ) return self._do_get_db_connection_protected() - def _do_get_db_connection_protected(self) -> Any: + def _do_get_db_connection_protected(self) -> ConnectionPoolEntry: report( ( "accesses the database without the database executor; " @@ -106,7 +105,7 @@ class MutexPool(StaticPool): _reference_counter = 0 pool_lock: threading.RLock - def _do_return_conn(self, record: ConnectionPoolEntry) -> Any: + def _do_return_conn(self, record: ConnectionPoolEntry) -> None: if DEBUG_MUTEX_POOL_TRACE: trace = traceback.extract_stack() trace_msg = "\n" + "".join(traceback.format_list(trace[:-1])) @@ -124,7 +123,7 @@ class MutexPool(StaticPool): ) MutexPool.pool_lock.release() - def _do_get(self) -> Any: + def _do_get(self) -> ConnectionPoolEntry: if DEBUG_MUTEX_POOL_TRACE: trace = traceback.extract_stack() trace_msg = "".join(traceback.format_list(trace[:-1])) diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index f983224e21..f8a1b769d8 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -690,6 +690,7 @@ def find_events_context_ids_to_migrate() -> StatementLambdaElement: return lambda_stmt( lambda: select( Events.event_id, + Events.time_fired_ts, Events.context_id, Events.context_user_id, Events.context_parent_id, @@ -730,7 +731,8 @@ def batch_cleanup_entity_ids() -> StatementLambdaElement: lambda: update(States) .where( States.state_id.in_( - select(States.state_id).join( + select(States.state_id) + .join( states_with_entity_ids := select( States.state_id.label("state_id_with_entity_id") ) @@ -739,6 +741,8 @@ def batch_cleanup_entity_ids() -> StatementLambdaElement: .subquery(), States.state_id == states_with_entity_ids.c.state_id_with_entity_id, ) + .alias("states_with_entity_ids") + .select() ) ) .values(entity_id=None) @@ -785,6 +789,7 @@ def find_states_context_ids_to_migrate() -> StatementLambdaElement: return lambda_stmt( lambda: select( States.state_id, + States.last_updated_ts, States.context_id, States.context_user_id, States.context_parent_id, diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 82fbf7798f..06dd20defb 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -47,6 +47,9 @@ from .const import ( DOMAIN, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, + INTEGRATION_PLATFORM_COMPILE_STATISTICS, + INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, + INTEGRATION_PLATFORM_VALIDATE_STATISTICS, SupportedDialect, ) from .db_schema import ( @@ -477,6 +480,11 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) - return True +def _get_first_id_stmt(start: datetime) -> StatementLambdaElement: + """Return a statement that returns the first run_id at start.""" + return lambda_stmt(lambda: select(StatisticsRuns.run_id).filter_by(start=start)) + + def _compile_statistics( instance: Recorder, session: Session, start: datetime, fire_events: bool ) -> set[str]: @@ -493,7 +501,7 @@ def _compile_statistics( modified_statistic_ids: set[str] = set() # Return if we already have 5-minute statistics for the requested period - if session.query(StatisticsRuns).filter_by(start=start).first(): + if execute_stmt_lambda_element(session, _get_first_id_stmt(start)): _LOGGER.debug("Statistics already compiled for %s-%s", start, end) return modified_statistic_ids @@ -502,9 +510,13 @@ def _compile_statistics( current_metadata: dict[str, tuple[int, StatisticMetaData]] = {} # Collect statistics from all platforms implementing support for domain, platform in instance.hass.data[DOMAIN].recorder_platforms.items(): - if not hasattr(platform, "compile_statistics"): + if not ( + platform_compile_statistics := getattr( + platform, INTEGRATION_PLATFORM_COMPILE_STATISTICS, None + ) + ): continue - compiled: PlatformCompiledStatistics = platform.compile_statistics( + compiled: PlatformCompiledStatistics = platform_compile_statistics( instance.hass, start, end ) _LOGGER.debug( @@ -783,9 +795,13 @@ def list_statistic_ids( # # Query all integrations with a registered recorder platform for platform in hass.data[DOMAIN].recorder_platforms.values(): - if not hasattr(platform, "list_statistic_ids"): + if not ( + platform_list_statistic_ids := getattr( + platform, INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, None + ) + ): continue - platform_statistic_ids = platform.list_statistic_ids( + platform_statistic_ids = platform_list_statistic_ids( hass, statistic_ids=statistic_ids, statistic_type=statistic_type ) @@ -841,10 +857,13 @@ def _reduce_statistics( } if _want_mean: row["mean"] = mean(mean_values) if mean_values else None + mean_values.clear() if _want_min: row["min"] = min(min_values) if min_values else None + min_values.clear() if _want_max: row["max"] = max(max_values) if max_values else None + max_values.clear() if _want_last_reset: row["last_reset"] = prev_stat.get("last_reset") if _want_state: @@ -852,10 +871,6 @@ def _reduce_statistics( if _want_sum: row["sum"] = prev_stat["sum"] result[statistic_id].append(row) - - max_values = [] - mean_values = [] - min_values = [] if _want_max and (_max := statistic.get("max")) is not None: max_values.append(_max) if _want_mean and (_mean := statistic.get("mean")) is not None: @@ -1018,7 +1033,6 @@ def _reduce_statistics_per_month( def _generate_statistics_during_period_stmt( - columns: Select, start_time: datetime, end_time: datetime | None, metadata_ids: list[int] | None, @@ -1030,7 +1044,8 @@ def _generate_statistics_during_period_stmt( This prepares a lambda_stmt query, so we don't insert the parameters yet. """ start_time_ts = start_time.timestamp() - stmt = lambda_stmt(lambda: columns.filter(table.start_ts >= start_time_ts)) + stmt = _generate_select_columns_for_types_stmt(table, types) + stmt += lambda q: q.filter(table.start_ts >= start_time_ts) if end_time is not None: end_time_ts = end_time.timestamp() stmt += lambda q: q.filter(table.start_ts < end_time_ts) @@ -1476,6 +1491,33 @@ def statistic_during_period( return {key: convert(value) if convert else value for key, value in result.items()} +_type_column_mapping = { + "last_reset": "last_reset_ts", + "max": "max", + "mean": "mean", + "min": "min", + "state": "state", + "sum": "sum", +} + + +def _generate_select_columns_for_types_stmt( + table: type[StatisticsBase], + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], +) -> StatementLambdaElement: + columns = select(table.metadata_id, table.start_ts) + track_on: list[str | None] = [ + table.__tablename__, # type: ignore[attr-defined] + ] + for key, column in _type_column_mapping.items(): + if key in types: + columns = columns.add_columns(getattr(table, column)) + track_on.append(column) + else: + track_on.append(None) + return lambda_stmt(lambda: columns, track_on=track_on) + + def _statistics_during_period_with_session( hass: HomeAssistant, session: Session, @@ -1510,40 +1552,15 @@ def _statistics_during_period_with_session( table: type[Statistics | StatisticsShortTerm] = ( Statistics if period != "5minute" else StatisticsShortTerm ) - columns = select(table.metadata_id, table.start_ts) # type: ignore[call-overload] - if "last_reset" in types: - columns = columns.add_columns(table.last_reset_ts) - if "max" in types: - columns = columns.add_columns(table.max) - if "mean" in types: - columns = columns.add_columns(table.mean) - if "min" in types: - columns = columns.add_columns(table.min) - if "state" in types: - columns = columns.add_columns(table.state) - if "sum" in types: - columns = columns.add_columns(table.sum) stmt = _generate_statistics_during_period_stmt( - columns, start_time, end_time, metadata_ids, table, types + start_time, end_time, metadata_ids, table, types + ) + stats = cast( + Sequence[Row], execute_stmt_lambda_element(session, stmt, orm_rows=False) ) - stats = cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) if not stats: return {} - # Return statistics combined with metadata - if period not in ("day", "week", "month"): - return _sorted_statistics_to_dict( - hass, - session, - stats, - statistic_ids, - metadata, - True, - table, - start_time, - units, - types, - ) result = _sorted_statistics_to_dict( hass, @@ -1558,6 +1575,10 @@ def _statistics_during_period_with_session( types, ) + # Return statistics combined with metadata + if period not in ("day", "week", "month"): + return result + if period == "day": return _reduce_statistics_per_day(result, types) @@ -1645,7 +1666,9 @@ def _get_last_statistics( stmt = _get_last_statistics_stmt(metadata_id, number_of_stats) else: stmt = _get_last_statistics_short_term_stmt(metadata_id, number_of_stats) - stats = cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) + stats = cast( + Sequence[Row], execute_stmt_lambda_element(session, stmt, orm_rows=False) + ) if not stats: return {} @@ -1736,7 +1759,9 @@ def get_latest_short_term_statistics( if statistic_id in metadata ] stmt = _latest_short_term_statistics_stmt(metadata_ids) - stats = cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) + stats = cast( + Sequence[Row], execute_stmt_lambda_element(session, stmt, orm_rows=False) + ) if not stats: return {} @@ -1756,34 +1781,34 @@ def get_latest_short_term_statistics( def _generate_statistics_at_time_stmt( - columns: Select, table: type[StatisticsBase], metadata_ids: set[int], start_time_ts: float, + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> StatementLambdaElement: """Create the statement for finding the statistics for a given time.""" - return lambda_stmt( - lambda: columns.join( - ( - most_recent_statistic_ids := ( - select( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(table.start_ts).label("max_start_ts"), - table.metadata_id.label("max_metadata_id"), - ) - .filter(table.start_ts < start_time_ts) - .filter(table.metadata_id.in_(metadata_ids)) - .group_by(table.metadata_id) - .subquery() + stmt = _generate_select_columns_for_types_stmt(table, types) + stmt += lambda q: q.join( + ( + most_recent_statistic_ids := ( + select( + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(table.start_ts).label("max_start_ts"), + table.metadata_id.label("max_metadata_id"), ) - ), - and_( - table.start_ts == most_recent_statistic_ids.c.max_start_ts, - table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, - ), - ) + .filter(table.start_ts < start_time_ts) + .filter(table.metadata_id.in_(metadata_ids)) + .group_by(table.metadata_id) + .subquery() + ) + ), + and_( + table.start_ts == most_recent_statistic_ids.c.max_start_ts, + table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, + ), ) + return stmt def _statistics_at_time( @@ -1794,27 +1819,39 @@ def _statistics_at_time( types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> Sequence[Row] | None: """Return last known statistics, earlier than start_time, for the metadata_ids.""" - columns = select(table.metadata_id, table.start_ts) - if "last_reset" in types: - columns = columns.add_columns(table.last_reset_ts) - if "max" in types: - columns = columns.add_columns(table.max) - if "mean" in types: - columns = columns.add_columns(table.mean) - if "min" in types: - columns = columns.add_columns(table.min) - if "state" in types: - columns = columns.add_columns(table.state) - if "sum" in types: - columns = columns.add_columns(table.sum) start_time_ts = start_time.timestamp() - stmt = _generate_statistics_at_time_stmt( - columns, table, metadata_ids, start_time_ts - ) + stmt = _generate_statistics_at_time_stmt(table, metadata_ids, start_time_ts, types) return cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) -def _sorted_statistics_to_dict( +def _fast_build_sum_list( + stats_list: list[Row], + table_duration_seconds: float, + convert: Callable | None, + start_ts_idx: int, + sum_idx: int, +) -> list[StatisticsRow]: + """Build a list of sum statistics.""" + if convert: + return [ + { + "start": (start_ts := db_state[start_ts_idx]), + "end": start_ts + table_duration_seconds, + "sum": convert(db_state[sum_idx]), + } + for db_state in stats_list + ] + return [ + { + "start": (start_ts := db_state[start_ts_idx]), + "end": start_ts + table_duration_seconds, + "sum": db_state[sum_idx], + } + for db_state in stats_list + ] + + +def _sorted_statistics_to_dict( # noqa: C901 hass: HomeAssistant, session: Session, stats: Sequence[Row[Any]], @@ -1873,6 +1910,7 @@ def _sorted_statistics_to_dict( last_reset_ts_idx = field_map["last_reset_ts"] if "last_reset" in types else None state_idx = field_map["state"] if "state" in types else None sum_idx = field_map["sum"] if "sum" in types else None + sum_only = len(types) == 1 and sum_idx is not None # Append all statistic entries, and optionally do unit conversion table_duration_seconds = table.duration.total_seconds() for meta_id, stats_list in stats_by_meta_id.items(): @@ -1885,6 +1923,23 @@ def _sorted_statistics_to_dict( convert = _get_statistic_to_display_unit_converter(unit, state_unit, units) else: convert = None + + if sum_only: + # This function is extremely flexible and can handle all types of + # statistics, but in practice we only ever use a few combinations. + # + # For energy, we only need sum statistics, so we can optimize + # this path to avoid the overhead of the more generic function. + assert sum_idx is not None + result[statistic_id] = _fast_build_sum_list( + stats_list, + table_duration_seconds, + convert, + start_ts_idx, + sum_idx, + ) + continue + ent_results_append = result[statistic_id].append # # The below loop is a red hot path for energy, and every @@ -1931,9 +1986,10 @@ def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]] """Validate statistics.""" platform_validation: dict[str, list[ValidationIssue]] = {} for platform in hass.data[DOMAIN].recorder_platforms.values(): - if not hasattr(platform, "validate_statistics"): - continue - platform_validation.update(platform.validate_statistics(hass)) + if platform_validate_statistics := getattr( + platform, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, None + ): + platform_validation.update(platform_validate_statistics(hass)) return platform_validation diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py index 4c661e3dc2..4e08719e57 100644 --- a/homeassistant/components/recorder/table_managers/event_data.py +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -97,7 +97,7 @@ class EventDataManager(BaseLRUTableManager[EventData]): with session.no_autoflush: for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): for data_id, shared_data in execute_stmt_lambda_element( - session, get_shared_event_datas(hashs_chunk) + session, get_shared_event_datas(hashs_chunk), orm_rows=False ): results[shared_data] = self._id_map[shared_data] = cast( int, data_id diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index 5b77e9116c..d5541c547d 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Iterable from typing import TYPE_CHECKING, cast +from lru import LRU # pylint: disable=no-name-in-module from sqlalchemy.orm.session import Session from homeassistant.core import Event @@ -12,6 +13,7 @@ from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventTypes from ..queries import find_event_type_ids +from ..tasks import RefreshEventTypesTask from ..util import chunked, execute_stmt_lambda_element if TYPE_CHECKING: @@ -27,6 +29,7 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): def __init__(self, recorder: Recorder) -> None: """Initialize the event type manager.""" super().__init__(recorder, CACHE_SIZE) + self._non_existent_event_types: LRU = LRU(CACHE_SIZE) def load(self, events: list[Event], session: Session) -> None: """Load the event_type to event_type_ids mapping into memory. @@ -37,9 +40,12 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): self.get_many( {event.event_type for event in events if event.event_type is not None}, session, + True, ) - def get(self, event_type: str, session: Session) -> int | None: + def get( + self, event_type: str, session: Session, from_recorder: bool = False + ) -> int | None: """Resolve event_type to the event_type_id. This call is not thread-safe and must be called from the @@ -48,7 +54,7 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): return self.get_many((event_type,), session)[event_type] def get_many( - self, event_types: Iterable[str], session: Session + self, event_types: Iterable[str], session: Session, from_recorder: bool = False ) -> dict[str, int | None]: """Resolve event_types to event_type_ids. @@ -57,9 +63,14 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): """ results: dict[str, int | None] = {} missing: list[str] = [] + non_existent: list[str] = [] + for event_type in event_types: if (event_type_id := self._id_map.get(event_type)) is None: - missing.append(event_type) + if event_type in self._non_existent_event_types: + results[event_type] = None + else: + missing.append(event_type) results[event_type] = event_type_id @@ -69,12 +80,26 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): with session.no_autoflush: for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): for event_type_id, event_type in execute_stmt_lambda_element( - session, find_event_type_ids(missing_chunk) + session, find_event_type_ids(missing_chunk), orm_rows=False ): results[event_type] = self._id_map[event_type] = cast( int, event_type_id ) + if non_existent := [ + event_type for event_type in missing if results[event_type] is None + ]: + if from_recorder: + # We are already in the recorder thread so we can update the + # non-existent event types directly. + for event_type in non_existent: + self._non_existent_event_types[event_type] = None + else: + # Queue a task to refresh the event types since its not + # thread-safe to do it here since we are not in the recorder + # thread. + self.recorder.queue_task(RefreshEventTypesTask(non_existent)) + return results def add_pending(self, db_event_type: EventTypes) -> None: @@ -95,8 +120,17 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): """ for event_type, db_event_types in self._pending.items(): self._id_map[event_type] = db_event_types.event_type_id + self.clear_non_existent(event_type) self._pending.clear() + def clear_non_existent(self, event_type: str) -> None: + """Clear a non-existent event type from the cache. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._non_existent_event_types.pop(event_type, None) + def evict_purged(self, event_types: Iterable[str]) -> None: """Evict purged event_types from the cache when they are no longer used. diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py index 51c626bd36..442277be96 100644 --- a/homeassistant/components/recorder/table_managers/state_attributes.py +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -114,7 +114,7 @@ class StateAttributesManager(BaseLRUTableManager[StateAttributes]): with session.no_autoflush: for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): for attributes_id, shared_attrs in execute_stmt_lambda_element( - session, get_shared_attributes(hashs_chunk) + session, get_shared_attributes(hashs_chunk), orm_rows=False ): results[shared_attrs] = self._id_map[shared_attrs] = cast( int, attributes_id diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index 639e0acaa3..bc4a8cfd2d 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -67,7 +67,7 @@ class StatesMetaManager(BaseLRUTableManager[StatesMeta]): cast( Sequence[tuple[int, str]], execute_stmt_lambda_element( - session, find_all_states_metadata_ids() + session, find_all_states_metadata_ids(), orm_rows=False ), ) ) diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py index ba47b3600d..75af59d7c7 100644 --- a/homeassistant/components/recorder/table_managers/statistics_meta.py +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -109,6 +109,7 @@ class StatisticsMetaManager: _generate_get_metadata_stmt( statistic_ids, statistic_type, statistic_source ), + orm_rows=False, ): statistics_meta = cast(StatisticsMeta, row) id_meta = _statistics_meta_to_id_statistics_metadata(statistics_meta) diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 7b8fa4867b..07be6202a0 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -14,10 +14,10 @@ from homeassistant.core import Event from homeassistant.helpers.typing import UndefinedType from . import entity_registry, purge, statistics -from .const import DOMAIN, EXCLUDE_ATTRIBUTES +from .const import DOMAIN from .db_schema import Statistics, StatisticsShortTerm from .models import StatisticData, StatisticMetaData -from .util import periodic_db_cleanups +from .util import periodic_db_cleanups, session_scope _LOGGER = logging.getLogger(__name__) @@ -26,7 +26,8 @@ if TYPE_CHECKING: from .core import Recorder -class RecorderTask(abc.ABC): +@dataclass(slots=True) +class RecorderTask: """ABC for recorder tasks.""" commit_before = True @@ -36,7 +37,7 @@ class RecorderTask(abc.ABC): """Handle the task.""" -@dataclass +@dataclass(slots=True) class ChangeStatisticsUnitTask(RecorderTask): """Object to store statistics_id and unit to convert unit of statistics.""" @@ -54,7 +55,7 @@ class ChangeStatisticsUnitTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class ClearStatisticsTask(RecorderTask): """Object to store statistics_ids which for which to remove statistics.""" @@ -65,7 +66,7 @@ class ClearStatisticsTask(RecorderTask): statistics.clear_statistics(instance, self.statistic_ids) -@dataclass +@dataclass(slots=True) class UpdateStatisticsMetadataTask(RecorderTask): """Object to store statistics_id and unit for update of statistics metadata.""" @@ -83,7 +84,7 @@ class UpdateStatisticsMetadataTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class UpdateStatesMetadataTask(RecorderTask): """Task to update states metadata.""" @@ -99,7 +100,7 @@ class UpdateStatesMetadataTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class PurgeTask(RecorderTask): """Object to store information about purge task.""" @@ -125,7 +126,7 @@ class PurgeTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class PurgeEntitiesTask(RecorderTask): """Object to store entity information about purge task.""" @@ -140,7 +141,7 @@ class PurgeEntitiesTask(RecorderTask): instance.queue_task(PurgeEntitiesTask(self.entity_filter, self.purge_before)) -@dataclass +@dataclass(slots=True) class PerodicCleanupTask(RecorderTask): """An object to insert into the recorder to trigger cleanup tasks. @@ -152,7 +153,7 @@ class PerodicCleanupTask(RecorderTask): periodic_db_cleanups(instance) -@dataclass +@dataclass(slots=True) class StatisticsTask(RecorderTask): """An object to insert into the recorder queue to run a statistics task.""" @@ -167,7 +168,7 @@ class StatisticsTask(RecorderTask): instance.queue_task(StatisticsTask(self.start, self.fire_events)) -@dataclass +@dataclass(slots=True) class CompileMissingStatisticsTask(RecorderTask): """An object to insert into the recorder queue to run a compile missing statistics.""" @@ -179,7 +180,7 @@ class CompileMissingStatisticsTask(RecorderTask): instance.queue_task(CompileMissingStatisticsTask()) -@dataclass +@dataclass(slots=True) class ImportStatisticsTask(RecorderTask): """An object to insert into the recorder queue to run an import statistics task.""" @@ -199,7 +200,7 @@ class ImportStatisticsTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class AdjustStatisticsTask(RecorderTask): """An object to insert into the recorder queue to run an adjust statistics task.""" @@ -229,7 +230,7 @@ class AdjustStatisticsTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class WaitTask(RecorderTask): """An object to insert into the recorder queue. @@ -243,7 +244,7 @@ class WaitTask(RecorderTask): instance._queue_watch.set() # pylint: disable=[protected-access] -@dataclass +@dataclass(slots=True) class DatabaseLockTask(RecorderTask): """An object to insert into the recorder queue to prevent writes to the database.""" @@ -256,7 +257,7 @@ class DatabaseLockTask(RecorderTask): instance._lock_database(self) # pylint: disable=[protected-access] -@dataclass +@dataclass(slots=True) class StopTask(RecorderTask): """An object to insert into the recorder queue to stop the event handler.""" @@ -267,7 +268,7 @@ class StopTask(RecorderTask): instance.stop_requested = True -@dataclass +@dataclass(slots=True) class EventTask(RecorderTask): """An event to be processed.""" @@ -280,7 +281,7 @@ class EventTask(RecorderTask): instance._process_one_event(self.event) -@dataclass +@dataclass(slots=True) class KeepAliveTask(RecorderTask): """A keep alive to be sent.""" @@ -292,7 +293,7 @@ class KeepAliveTask(RecorderTask): instance._send_keep_alive() -@dataclass +@dataclass(slots=True) class CommitTask(RecorderTask): """Commit the event session.""" @@ -304,7 +305,7 @@ class CommitTask(RecorderTask): instance._commit_event_session_or_retry() -@dataclass +@dataclass(slots=True) class AddRecorderPlatformTask(RecorderTask): """Add a recorder platform.""" @@ -317,14 +318,11 @@ class AddRecorderPlatformTask(RecorderTask): hass = instance.hass domain = self.domain platform = self.platform - platforms: dict[str, Any] = hass.data[DOMAIN].recorder_platforms platforms[domain] = platform - if hasattr(self.platform, "exclude_attributes"): - hass.data[EXCLUDE_ATTRIBUTES][domain] = platform.exclude_attributes(hass) -@dataclass +@dataclass(slots=True) class SynchronizeTask(RecorderTask): """Ensure all pending data has been committed.""" @@ -338,7 +336,7 @@ class SynchronizeTask(RecorderTask): instance.hass.loop.call_soon_threadsafe(self.event.set) -@dataclass +@dataclass(slots=True) class PostSchemaMigrationTask(RecorderTask): """Post migration task to update schema.""" @@ -352,7 +350,7 @@ class PostSchemaMigrationTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class StatisticsTimestampMigrationCleanupTask(RecorderTask): """An object to insert into the recorder queue to run a statistics migration cleanup task.""" @@ -363,7 +361,7 @@ class StatisticsTimestampMigrationCleanupTask(RecorderTask): instance.queue_task(StatisticsTimestampMigrationCleanupTask()) -@dataclass +@dataclass(slots=True) class AdjustLRUSizeTask(RecorderTask): """An object to insert into the recorder queue to adjust the LRU size.""" @@ -374,7 +372,7 @@ class AdjustLRUSizeTask(RecorderTask): instance._adjust_lru_size() # pylint: disable=[protected-access] -@dataclass +@dataclass(slots=True) class StatesContextIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate states context ids.""" @@ -389,7 +387,7 @@ class StatesContextIDMigrationTask(RecorderTask): instance.queue_task(StatesContextIDMigrationTask()) -@dataclass +@dataclass(slots=True) class EventsContextIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate events context ids.""" @@ -404,7 +402,7 @@ class EventsContextIDMigrationTask(RecorderTask): instance.queue_task(EventsContextIDMigrationTask()) -@dataclass +@dataclass(slots=True) class EventTypeIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate event type ids.""" @@ -420,7 +418,7 @@ class EventTypeIDMigrationTask(RecorderTask): instance.queue_task(EventTypeIDMigrationTask()) -@dataclass +@dataclass(slots=True) class EntityIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate entity_ids to StatesMeta.""" @@ -443,7 +441,7 @@ class EntityIDMigrationTask(RecorderTask): instance.queue_task(EntityIDPostMigrationTask()) -@dataclass +@dataclass(slots=True) class EntityIDPostMigrationTask(RecorderTask): """An object to insert into the recorder queue to cleanup after entity_ids migration.""" @@ -456,7 +454,7 @@ class EntityIDPostMigrationTask(RecorderTask): instance.queue_task(EntityIDPostMigrationTask()) -@dataclass +@dataclass(slots=True) class EventIdMigrationTask(RecorderTask): """An object to insert into the recorder queue to cleanup legacy event_ids in the states table. @@ -468,3 +466,17 @@ class EventIdMigrationTask(RecorderTask): def run(self, instance: Recorder) -> None: """Clean up the legacy event_id index on states.""" instance._cleanup_legacy_states_event_ids() # pylint: disable=[protected-access] + + +@dataclass(slots=True) +class RefreshEventTypesTask(RecorderTask): + """An object to insert into the recorder queue to refresh event types.""" + + event_types: list[str] + + def run(self, instance: Recorder) -> None: + """Refresh event types.""" + with session_scope(session=instance.get_session(), read_only=True) as session: + instance.event_type_manager.get_many( + self.event_types, session, from_recorder=True + ) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 4ec0a0c450..1c50fd0a77 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -20,6 +20,7 @@ from awesomeversion import ( import ciso8601 from sqlalchemy import inspect, text from sqlalchemy.engine import Result, Row +from sqlalchemy.engine.interfaces import DBAPIConnection from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session @@ -198,6 +199,7 @@ def execute_stmt_lambda_element( start_time: datetime | None = None, end_time: datetime | None = None, yield_per: int = DEFAULT_YIELD_STATES_ROWS, + orm_rows: bool = True, ) -> Sequence[Row] | Result: """Execute a StatementLambdaElement. @@ -210,10 +212,13 @@ def execute_stmt_lambda_element( specific entities) since they are usually faster with .all(). """ - executed = session.execute(stmt) use_all = not start_time or ((end_time or dt_util.utcnow()) - start_time).days <= 1 for tryno in range(RETRIES): try: + if orm_rows: + executed = session.execute(stmt) + else: + executed = session.connection().execute(stmt) if use_all: return executed.all() return executed.yield_per(yield_per) @@ -344,14 +349,14 @@ def move_away_broken_database(dbfile: str) -> None: os.rename(path, f"{path}{corrupt_postfix}") -def execute_on_connection(dbapi_connection: Any, statement: str) -> None: +def execute_on_connection(dbapi_connection: DBAPIConnection, statement: str) -> None: """Execute a single statement with a dbapi connection.""" cursor = dbapi_connection.cursor() cursor.execute(statement) cursor.close() -def query_on_connection(dbapi_connection: Any, statement: str) -> Any: +def query_on_connection(dbapi_connection: DBAPIConnection, statement: str) -> Any: """Execute a single statement with a dbapi connection and return the result.""" cursor = dbapi_connection.cursor() cursor.execute(statement) @@ -457,7 +462,7 @@ def _async_create_mariadb_range_index_regression_issue( def setup_connection_for_dialect( instance: Recorder, dialect_name: str, - dbapi_connection: Any, + dbapi_connection: DBAPIConnection, first_connection: bool, ) -> DatabaseEngine | None: """Execute statements needed for dialect connection.""" @@ -465,10 +470,10 @@ def setup_connection_for_dialect( slow_range_in_select = False if dialect_name == SupportedDialect.SQLITE: if first_connection: - old_isolation = dbapi_connection.isolation_level - dbapi_connection.isolation_level = None + old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined] + dbapi_connection.isolation_level = None # type: ignore[attr-defined] execute_on_connection(dbapi_connection, "PRAGMA journal_mode=WAL") - dbapi_connection.isolation_level = old_isolation + dbapi_connection.isolation_level = old_isolation # type: ignore[attr-defined] # WAL mode only needs to be setup once # instead of every time we open the sqlite connection # as its persistent and isn't free to call every time. @@ -672,6 +677,7 @@ def periodic_db_cleanups(instance: Recorder) -> None: _LOGGER.debug("WAL checkpoint") with instance.engine.connect() as connection: connection.execute(text("PRAGMA wal_checkpoint(TRUNCATE);")) + connection.execute(text("PRAGMA OPTIMIZE;")) @contextmanager diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index df42c519fe..c52df1b25e 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -30,7 +30,6 @@ from homeassistant.util.unit_conversion import ( VolumeConverter, ) -from .const import MAX_QUEUE_BACKLOG from .models import StatisticPeriod from .statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, @@ -504,7 +503,7 @@ def ws_info( recorder_info = { "backlog": backlog, - "max_backlog": MAX_QUEUE_BACKLOG, + "max_backlog": instance.max_backlog, "migration_in_progress": migration_in_progress, "migration_is_live": migration_is_live, "recording": recording, diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 9db7c6ff10..135205aa95 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -42,7 +42,7 @@ CONF_DIRECTION = "direction" CONF_DEPARTURE_TYPE = "departure_type" DEFAULT_NAME = "Next departure" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) @@ -98,6 +98,7 @@ class RejseplanenTransportSensor(SensorEntity): """Implementation of Rejseplanen transport sensor.""" _attr_attribution = "Data provided by rejseplanen.dk" + _attr_icon = "mdi:bus" def __init__(self, data, stop_id, route, direction, name): """Initialize the sensor.""" @@ -143,11 +144,6 @@ class RejseplanenTransportSensor(SensorEntity): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from rejseplanen.dk and update the states.""" self.data.update() diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index f309a8f188..83d86745d9 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN -from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription +from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub @@ -85,43 +85,43 @@ BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple( key="plugged_in", coordinator="battery", device_class=BinarySensorDeviceClass.PLUG, - name="Plugged in", on_key="plugStatus", on_value=PlugState.PLUGGED.value, + translation_key="plugged_in", ), RenaultBinarySensorEntityDescription( key="charging", coordinator="battery", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - name="Charging", on_key="chargingStatus", on_value=ChargeState.CHARGE_IN_PROGRESS.value, + translation_key="charging", ), RenaultBinarySensorEntityDescription( key="hvac_status", coordinator="hvac_status", icon_fn=lambda e: "mdi:fan" if e.is_on else "mdi:fan-off", - name="HVAC", on_key="hvacStatus", on_value="on", + translation_key="hvac_status", ), RenaultBinarySensorEntityDescription( key="lock_status", coordinator="lock_status", # lock: on means open (unlocked), off means closed (locked) device_class=BinarySensorDeviceClass.LOCK, - name="Lock", on_key="lockStatus", on_value="unlocked", + translation_key="lock_status", ), RenaultBinarySensorEntityDescription( key="hatch_status", coordinator="lock_status", # On means open, Off means closed device_class=BinarySensorDeviceClass.DOOR, - name="Hatch", on_key="hatchStatus", on_value="open", + translation_key="hatch_status", ), ] + [ @@ -130,9 +130,9 @@ BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple( coordinator="lock_status", # On means open, Off means closed device_class=BinarySensorDeviceClass.DOOR, - name=f"{door.capitalize()} door", on_key=f"doorStatus{door.replace(' ','')}", on_value="open", + translation_key=f"{door.lower().replace(' ','_')}_door_status", ) for door in ("Rear Left", "Rear Right", "Driver", "Passenger") ], diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py index 67dfe8fc97..5f916a2d14 100644 --- a/homeassistant/components/renault/button.py +++ b/homeassistant/components/renault/button.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .renault_entities import RenaultEntity +from .entity import RenaultEntity from .renault_hub import RenaultHub @@ -62,20 +62,20 @@ BUTTON_TYPES: tuple[RenaultButtonEntityDescription, ...] = ( async_press=lambda x: x.vehicle.set_ac_start(21, None), key="start_air_conditioner", icon="mdi:air-conditioner", - name="Start air conditioner", + translation_key="start_air_conditioner", ), RenaultButtonEntityDescription( async_press=lambda x: x.vehicle.set_charge_start(), key="start_charge", icon="mdi:ev-station", - name="Start charge", requires_electricity=True, + translation_key="start_charge", ), RenaultButtonEntityDescription( async_press=lambda x: x.vehicle.set_charge_stop(), key="stop_charge", icon="mdi:ev-station", - name="Stop charge", requires_electricity=True, + translation_key="stop_charge", ), ) diff --git a/homeassistant/components/renault/renault_coordinator.py b/homeassistant/components/renault/coordinator.py similarity index 100% rename from homeassistant/components/renault/renault_coordinator.py rename to homeassistant/components/renault/coordinator.py diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py index 87ca3c9eb5..a27c59cecf 100644 --- a/homeassistant/components/renault/device_tracker.py +++ b/homeassistant/components/renault/device_tracker.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription +from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub @@ -55,6 +55,6 @@ DEVICE_TRACKER_TYPES: tuple[RenaultDataEntityDescription, ...] = ( key="location", coordinator="location", icon="mdi:car", - name="Location", + translation_key="location", ), ) diff --git a/homeassistant/components/renault/renault_entities.py b/homeassistant/components/renault/entity.py similarity index 96% rename from homeassistant/components/renault/renault_entities.py rename to homeassistant/components/renault/entity.py index 188d429016..aa83c93595 100644 --- a/homeassistant/components/renault/renault_entities.py +++ b/homeassistant/components/renault/entity.py @@ -8,7 +8,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .renault_coordinator import RenaultDataUpdateCoordinator, T +from .coordinator import RenaultDataUpdateCoordinator, T from .renault_vehicle import RenaultVehicleProxy diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 9fade49b4b..5f2670fb17 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "platinum", - "requirements": ["renault-api==0.1.12"] + "requirements": ["renault-api==0.1.13"] } diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 9580ea2b7d..30e251dd30 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo from .const import DOMAIN -from .renault_coordinator import RenaultDataUpdateCoordinator +from .coordinator import RenaultDataUpdateCoordinator LOGGER = logging.getLogger(__name__) _T = TypeVar("_T") diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index 8fef7d9aee..1ec891a51e 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN -from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription +from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub @@ -92,7 +92,6 @@ SENSOR_TYPES: tuple[RenaultSelectEntityDescription, ...] = ( data_key="chargeMode", translation_key="charge_mode", icon_lambda=_get_charge_mode_icon, - name="Charge mode", options=["always", "always_charging", "schedule_mode"], ), ) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index d75dc55aa2..90ad70521d 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -37,8 +37,8 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import as_utc, parse_datetime from .const import DOMAIN -from .renault_coordinator import T -from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription +from .coordinator import T +from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub from .renault_vehicle import RenaultVehicleProxy @@ -163,9 +163,9 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="batteryLevel", device_class=SensorDeviceClass.BATTERY, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], - name="Battery level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + translation_key="battery_level", ), RenaultSensorEntityDescription( key="charge_state", @@ -175,7 +175,6 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( device_class=SensorDeviceClass.ENUM, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], icon_lambda=_get_charge_state_icon, - name="Charge state", options=[ "not_in_charge", "waiting_for_a_planned_charge", @@ -194,9 +193,9 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="chargingRemainingTime", entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], icon="mdi:timer", - name="Charging remaining time", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, + translation_key="charging_remaining_time", ), RenaultSensorEntityDescription( # For vehicles that DO NOT report charging power in watts, this seems to @@ -208,9 +207,9 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="chargingInstantaneousPower", device_class=SensorDeviceClass.POWER, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], - name="Admissible charging power", native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, + translation_key="admissible_charging_power", ), RenaultSensorEntityDescription( # For vehicles that DO report charging power in watts, this is the power @@ -221,10 +220,10 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="chargingInstantaneousPower", device_class=SensorDeviceClass.POWER, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], - name="Charging power", native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, value_lambda=_get_charging_power, + translation_key="charging_power", ), RenaultSensorEntityDescription( key="plug_state", @@ -234,7 +233,6 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( device_class=SensorDeviceClass.ENUM, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], icon_lambda=_get_plug_state_icon, - name="Plug state", options=["unplugged", "plugged", "plug_error", "plug_unknown"], value_lambda=_get_plug_state_formatted, ), @@ -245,9 +243,9 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( device_class=SensorDeviceClass.DISTANCE, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], icon="mdi:ev-station", - name="Battery autonomy", native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, + translation_key="battery_autonomy", ), RenaultSensorEntityDescription( key="battery_available_energy", @@ -255,9 +253,9 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="batteryAvailableEnergy", entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], device_class=SensorDeviceClass.ENERGY, - name="Battery available energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, + translation_key="battery_available_energy", ), RenaultSensorEntityDescription( key="battery_temperature", @@ -265,9 +263,9 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="batteryTemperature", device_class=SensorDeviceClass.TEMPERATURE, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], - name="Battery temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, + translation_key="battery_temperature", ), RenaultSensorEntityDescription( key="battery_last_activity", @@ -276,8 +274,8 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="timestamp", entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], entity_registry_enabled_default=False, - name="Battery last activity", value_lambda=_get_utc_value, + translation_key="battery_last_activity", ), RenaultSensorEntityDescription( key="mileage", @@ -286,10 +284,10 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( device_class=SensorDeviceClass.DISTANCE, entity_class=RenaultSensor[KamereonVehicleCockpitData], icon="mdi:sign-direction", - name="Mileage", native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.TOTAL_INCREASING, value_lambda=_get_rounded_value, + translation_key="mileage", ), RenaultSensorEntityDescription( key="fuel_autonomy", @@ -298,11 +296,11 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( device_class=SensorDeviceClass.DISTANCE, entity_class=RenaultSensor[KamereonVehicleCockpitData], icon="mdi:gas-station", - name="Fuel autonomy", native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, requires_fuel=True, value_lambda=_get_rounded_value, + translation_key="fuel_autonomy", ), RenaultSensorEntityDescription( key="fuel_quantity", @@ -311,11 +309,11 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( device_class=SensorDeviceClass.VOLUME, entity_class=RenaultSensor[KamereonVehicleCockpitData], icon="mdi:fuel", - name="Fuel quantity", native_unit_of_measurement=UnitOfVolume.LITERS, state_class=SensorStateClass.TOTAL, requires_fuel=True, value_lambda=_get_rounded_value, + translation_key="fuel_quantity", ), RenaultSensorEntityDescription( key="outside_temperature", @@ -323,17 +321,17 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( device_class=SensorDeviceClass.TEMPERATURE, data_key="externalTemperature", entity_class=RenaultSensor[KamereonVehicleHvacStatusData], - name="Outside temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, + translation_key="outside_temperature", ), RenaultSensorEntityDescription( key="hvac_soc_threshold", coordinator="hvac_status", data_key="socThreshold", entity_class=RenaultSensor[KamereonVehicleHvacStatusData], - name="HVAC SoC threshold", native_unit_of_measurement=PERCENTAGE, + translation_key="hvac_soc_threshold", ), RenaultSensorEntityDescription( key="hvac_last_activity", @@ -342,7 +340,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="lastUpdateTime", entity_class=RenaultSensor[KamereonVehicleHvacStatusData], entity_registry_enabled_default=False, - name="HVAC last activity", + translation_key="hvac_last_activity", value_lambda=_get_utc_value, ), RenaultSensorEntityDescription( @@ -352,7 +350,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="lastUpdateTime", entity_class=RenaultSensor[KamereonVehicleLocationData], entity_registry_enabled_default=False, - name="Location last activity", + translation_key="location_last_activity", value_lambda=_get_utc_value, ), RenaultSensorEntityDescription( @@ -360,7 +358,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( coordinator="res_state", data_key="details", entity_class=RenaultSensor[KamereonVehicleResStateData], - name="Remote engine start", + translation_key="res_state", ), RenaultSensorEntityDescription( key="res_state_code", @@ -368,6 +366,6 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( data_key="code", entity_class=RenaultSensor[KamereonVehicleResStateData], entity_registry_enabled_default=False, - name="Remote engine start code", + translation_key="res_state_code", ), ) diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index b28f1727c2..066b49abcc 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -33,8 +33,54 @@ } }, "entity": { + "binary_sensor": { + "charging": { + "name": "[%key:component::binary_sensor::entity_component::battery_charging::name%]" + }, + "hatch_status": { + "name": "Hatch" + }, + "driver_door_status": { + "name": "Driver door" + }, + "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" + }, + "rear_right_door_status": { + "name": "Rear right door" + } + }, + "button": { + "start_air_conditioner": { + "name": "Start air conditioner" + }, + "start_charge": { + "name": "Start charge" + }, + "stop_charge": { + "name": "Stop charge" + } + }, + "device_tracker": { + "location": { + "name": "Location" + } + }, "select": { "charge_mode": { + "name": "Charge mode", "state": { "always": "Instant", "always_charging": "Instant", @@ -43,15 +89,26 @@ } }, "sensor": { - "plug_state": { - "state": { - "unplugged": "Unplugged", - "plugged": "Plugged in", - "plug_error": "Plug error", - "plug_unknown": "Plug unknown" - } + "admissible_charging_power": { + "name": "Admissible charging power" + }, + "battery_autonomy": { + "name": "Battery autonomy" + }, + "battery_available_energy": { + "name": "Battery available energy" + }, + "battery_last_activity": { + "name": "Last battery activity" + }, + "battery_level": { + "name": "Battery level" + }, + "battery_temperature": { + "name": "Battery temperature" }, "charge_state": { + "name": "Charge state", "state": { "not_in_charge": "Not charging", "waiting_for_a_planned_charge": "Waiting for planned charge", @@ -62,6 +119,48 @@ "charge_error": "Not charging or plugged in", "unavailable": "Unavailable" } + }, + "charging_power": { + "name": "Charging power" + }, + "charging_remaining_time": { + "name": "Charging remaining time" + }, + "fuel_autonomy": { + "name": "Fuel autonomy" + }, + "fuel_quantity": { + "name": "Fuel quantity" + }, + "hvac_last_activity": { + "name": "Last HVAC activity" + }, + "hvac_soc_threshold": { + "name": "HVAC SoC threshold" + }, + "location_last_activity": { + "name": "Last location activity" + }, + "mileage": { + "name": "Mileage" + }, + "outside_temperature": { + "name": "Outside temperature" + }, + "plug_state": { + "name": "Plug state", + "state": { + "unplugged": "Unplugged", + "plugged": "Plugged in", + "plug_error": "Plug error", + "plug_unknown": "Plug unknown" + } + }, + "res_state": { + "name": "Remote engine start" + }, + "res_state_code": { + "name": "Remote engine start code" } } } diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 1a7649f367..850aa11017 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -87,7 +87,7 @@ BINARY_SENSORS = ( icon="mdi:bell-ring-outline", icon_off="mdi:doorbell", value=lambda api, ch: api.visitor_detected(ch), - supported=lambda api, ch: api.is_doorbell_enabled(ch), + supported=lambda api, ch: api.is_doorbell(ch), ), ) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 15f3dfa613..df5bf968ae 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -108,7 +108,10 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the initial step.""" errors = {} - placeholders = {"error": ""} + placeholders = { + "error": "", + "troubleshooting_link": "https://www.home-assistant.io/integrations/reolink/#troubleshooting", + } if user_input is not None: if CONF_HOST not in user_input: @@ -175,7 +178,7 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema = data_schema.extend( { vol.Optional(CONF_PORT): cv.positive_int, - vol.Optional(CONF_USE_HTTPS): bool, + vol.Required(CONF_USE_HTTPS, default=False): bool, } ) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 9ba4809e90..8dad658d7d 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -8,6 +8,7 @@ from typing import Any import aiohttp from aiohttp.web import Request +import async_timeout from reolink_aio.api import Host from reolink_aio.exceptions import ReolinkError, SubscriptionError @@ -23,6 +24,7 @@ from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotAdmin DEFAULT_TIMEOUT = 60 +FIRST_ONVIF_TIMEOUT = 15 SUBSCRIPTION_RENEW_THRESHOLD = 300 _LOGGER = logging.getLogger(__name__) @@ -54,7 +56,9 @@ class ReolinkHost: ) self.webhook_id: str | None = None - self._webhook_url: str | None = None + self._base_url: str = "" + self._webhook_url: str = "" + self._webhook_reachable: asyncio.Event = asyncio.Event() self._lost_subscription: bool = False @property @@ -80,9 +84,15 @@ class ReolinkHost: f"'{self._api.user_level}', only admin users can change camera settings" ) + enable_rtsp = None enable_onvif = None enable_rtmp = None - enable_rtsp = None + + if not self._api.rtsp_enabled: + _LOGGER.debug( + "RTSP is disabled on %s, trying to enable it", self._api.nvr_name + ) + enable_rtsp = True if not self._api.onvif_enabled: _LOGGER.debug( @@ -95,11 +105,6 @@ class ReolinkHost: "RTMP is disabled on %s, trying to enable it", self._api.nvr_name ) enable_rtmp = True - elif not self._api.rtsp_enabled and self._api.protocol == "rtsp": - _LOGGER.debug( - "RTSP is disabled on %s, trying to enable it", self._api.nvr_name - ) - enable_rtsp = True if enable_onvif or enable_rtmp or enable_rtsp: try: @@ -110,13 +115,14 @@ class ReolinkHost: ) except ReolinkError: ports = "" + if enable_rtsp: + ports += "RTSP " + if enable_onvif: ports += "ONVIF " if enable_rtmp: ports += "RTMP " - elif enable_rtsp: - ports += "RTSP " ir.async_create_issue( self._hass, @@ -138,6 +144,34 @@ class ReolinkHost: 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: + _LOGGER.debug( + "Did not receive initial ONVIF state on webhook '%s' after %i seconds", + self._webhook_url, + FIRST_ONVIF_TIMEOUT, + ) + ir.async_create_issue( + self._hass, + DOMAIN, + "webhook_url", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="webhook_url", + translation_placeholders={ + "name": self._api.nvr_name, + "base_url": self._base_url, + "network_link": "https://my.home-assistant.io/redirect/network/", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") + if self._api.sw_version_update_required: ir.async_create_issue( self._hass, @@ -287,10 +321,10 @@ class ReolinkHost: ) try: - base_url = get_url(self._hass, prefer_external=False) + self._base_url = get_url(self._hass, prefer_external=False) except NoURLAvailableError: try: - base_url = get_url(self._hass, prefer_external=True) + self._base_url = get_url(self._hass, prefer_external=True) except NoURLAvailableError as err: self.unregister_webhook() raise ReolinkWebhookException( @@ -299,9 +333,9 @@ class ReolinkHost: ) from err webhook_path = webhook.async_generate_path(event_id) - self._webhook_url = f"{base_url}{webhook_path}" + self._webhook_url = f"{self._base_url}{webhook_path}" - if base_url.startswith("https"): + if self._base_url.startswith("https"): ir.async_create_issue( self._hass, DOMAIN, @@ -310,7 +344,7 @@ class ReolinkHost: severity=ir.IssueSeverity.WARNING, translation_key="https_webhook", translation_placeholders={ - "base_url": base_url, + "base_url": self._base_url, "network_link": "https://my.home-assistant.io/redirect/network/", }, ) @@ -327,32 +361,77 @@ class ReolinkHost: async def handle_webhook( self, hass: HomeAssistant, webhook_id: str, request: Request - ): - """Shield the incoming webhook callback from cancellation.""" - await asyncio.shield(self.handle_webhook_shielded(hass, webhook_id, request)) - - async def handle_webhook_shielded( - self, hass: HomeAssistant, webhook_id: str, request: Request - ): - """Handle incoming webhook from Reolink for inbound messages and calls.""" - + ) -> None: + """Read the incoming webhook from Reolink for inbound messages and schedule processing.""" _LOGGER.debug("Webhook '%s' called", webhook_id) - - if not request.body_exists: - _LOGGER.debug("Webhook '%s' triggered without payload", webhook_id) - return - - data = await request.text() - if not data: + data: bytes | None = None + try: + data = await request.read() + if not data: + _LOGGER.debug( + "Webhook '%s' triggered with unknown payload: %s", webhook_id, data + ) + except ConnectionResetError: _LOGGER.debug( - "Webhook '%s' triggered with unknown payload: %s", webhook_id, data + "Webhook '%s' called, but lost connection before reading message " + "(ConnectionResetError), issuing poll", + webhook_id, + ) + return + except aiohttp.ClientResponseError: + _LOGGER.debug( + "Webhook '%s' called, but could not read the message, issuing poll", + webhook_id, + ) + return + except asyncio.CancelledError: + _LOGGER.debug( + "Webhook '%s' called, but lost connection before reading message " + "(CancelledError), issuing poll", + webhook_id, + ) + raise + finally: + # We want handle_webhook to return as soon as possible + # so we process the data in the background, this also shields from cancellation + hass.async_create_background_task( + self._process_webhook_data(hass, webhook_id, data), + "Process Reolink webhook", + ) + + async def _process_webhook_data( + self, hass: HomeAssistant, webhook_id: str, data: bytes | None + ) -> None: + """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() + ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") + + try: + if not data: + if not await self._api.get_motion_state_all_ch(): + _LOGGER.error( + "Could not poll motion state after losing connection during receiving ONVIF event" + ) + return + async_dispatcher_send(hass, f"{webhook_id}_all", {}) + return + + message = data.decode("utf-8") + channels = await self._api.ONVIF_event_callback(message) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception( + "Error processing ONVIF event for Reolink %s: %s", + self._api.nvr_name, + ex, ) return - channels = await self._api.ONVIF_event_callback(data) - if channels is None: async_dispatcher_send(hass, f"{webhook_id}_all", {}) - else: - for channel in channels: - async_dispatcher_send(hass, f"{webhook_id}_{channel}", {}) + return + + for channel in channels: + async_dispatcher_send(hass, f"{webhook_id}_{channel}", {}) diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index c4923c0088..0f80215d50 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -57,6 +57,7 @@ LIGHT_ENTITIES = ( key="ir_lights", name="Infra red lights in night mode", icon="mdi:led-off", + entity_category=EntityCategory.CONFIG, supported_fn=lambda api, ch: api.supported(ch, "ir_lights"), is_on_fn=lambda api, ch: api.ir_enabled(ch), turn_on_off_fn=lambda api, ch, value: api.set_ir_lights(ch, value), @@ -66,7 +67,7 @@ LIGHT_ENTITIES = ( name="Status LED", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, - supported_fn=lambda api, ch: api.supported(ch, "status_led"), + supported_fn=lambda api, ch: api.supported(ch, "power_led"), is_on_fn=lambda api, ch: api.status_led_enabled(ch), turn_on_off_fn=lambda api, ch, value: api.set_status_led(ch, value), ), diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 7050ed61d5..cad89ac48c 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.6"] + "requirements": ["reolink-aio==0.5.13"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 4a221e2ca9..bb19974114 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -188,6 +188,60 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.quick_reply_time(ch), method=lambda api, ch, value: api.set_quick_reply(ch, time=int(value)), ), + ReolinkNumberEntityDescription( + key="auto_track_limit_left", + name="Auto track limit left", + icon="mdi:angle-acute", + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=-1, + native_max_value=2700, + supported=lambda api, ch: api.supported(ch, "auto_track_limit"), + value=lambda api, ch: api.auto_track_limit_left(ch), + method=lambda api, ch, value: api.set_auto_track_limit(ch, left=int(value)), + ), + ReolinkNumberEntityDescription( + key="auto_track_limit_right", + name="Auto track limit right", + icon="mdi:angle-acute", + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=-1, + native_max_value=2700, + supported=lambda api, ch: api.supported(ch, "auto_track_limit"), + value=lambda api, ch: api.auto_track_limit_right(ch), + method=lambda api, ch, value: api.set_auto_track_limit(ch, right=int(value)), + ), + ReolinkNumberEntityDescription( + key="auto_track_disappear_time", + name="Auto track disappear time", + icon="mdi:target-account", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=60, + supported=lambda api, ch: api.supported(ch, "auto_track_disappear_time"), + value=lambda api, ch: api.auto_track_disappear_time(ch), + method=lambda api, ch, value: api.set_auto_tracking( + ch, disappear_time=int(value) + ), + ), + ReolinkNumberEntityDescription( + key="auto_track_stop_time", + name="Auto track stop time", + icon="mdi:target-account", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=60, + supported=lambda api, ch: api.supported(ch, "auto_track_stop_time"), + value=lambda api, ch: api.auto_track_stop_time(ch), + method=lambda api, ch, value: api.set_auto_tracking(ch, stop_time=int(value)), + ), ) diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index e18961c97d..6303bc5813 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -5,7 +5,13 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from reolink_aio.api import DayNightEnum, Host, SpotlightModeEnum +from reolink_aio.api import ( + DayNightEnum, + Host, + SpotlightModeEnum, + StatusLedEnum, + TrackMethodEnum, +) from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -79,6 +85,28 @@ SELECT_ENTITIES = ( ch, file_id=[k for k, v in api.quick_reply_dict(ch).items() if v == mess][0] ), ), + ReolinkSelectEntityDescription( + key="auto_track_method", + name="Auto track method", + icon="mdi:target-account", + translation_key="auto_track_method", + entity_category=EntityCategory.CONFIG, + get_options=[method.name for method in TrackMethodEnum], + supported=lambda api, ch: api.supported(ch, "auto_track_method"), + value=lambda api, ch: TrackMethodEnum(api.auto_track_method(ch)).name, + method=lambda api, ch, name: api.set_auto_tracking(ch, method=name), + ), + ReolinkSelectEntityDescription( + key="status_led", + name="Status LED", + icon="mdi:lightning-bolt-circle", + translation_key="status_led", + entity_category=EntityCategory.CONFIG, + get_options=[state.name for state in StatusLedEnum], + supported=lambda api, ch: api.supported(ch, "doorbell_led"), + value=lambda api, ch: StatusLedEnum(api.doorbell_led(ch)).name, + method=lambda api, ch, name: api.set_status_led(ch, StatusLedEnum[name].value), + ), ) @@ -133,3 +161,4 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_description.method(self._host.api, self._channel, option) + self.async_write_ha_state() diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 06b588a119..d02dbb8ab4 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -3,7 +3,7 @@ "flow_title": "{hostname} ({ip_address})", "step": { "user": { - "description": "{error}", + "description": "See the [troubleshooting steps]({troubleshooting_link}) if you encounter problems. {error}", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", @@ -19,7 +19,7 @@ }, "error": { "api_error": "API error occurred", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_connect": "Failed to connect, check the IP address of the camera", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "not_admin": "User needs to be admin, user ''{username}'' has authorisation level ''{userlevel}''", "unknown": "[%key:common::config_flow::error::unknown%]" @@ -41,7 +41,11 @@ "issues": { "https_webhook": { "title": "Reolink webhook URL uses HTTPS (SSL)", - "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`" + "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device" + }, + "webhook_url": { + "title": "Reolink webhook URL unreachable", + "description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera can not reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL." }, "enable_port": { "title": "Reolink port not enabled", @@ -72,6 +76,20 @@ "state": { "off": "Off" } + }, + "auto_track_method": { + "state": { + "digital": "Digital", + "digitalfirst": "Digital first", + "pantiltfirst": "Pan/tilt first" + } + }, + "status_led": { + "state": { + "stayoff": "Stay off", + "auto": "Auto", + "alwaysonatnight": "Auto & always on at night" + } } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index a7ed9b6a98..1a4deda17e 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -98,6 +98,15 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.ptz_guard_enabled(ch), method=lambda api, ch, value: api.set_ptz_guard(ch, enable=value), ), + ReolinkSwitchEntityDescription( + key="doorbell_button_sound", + name="Doorbell button sound", + icon="mdi:volume-high", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "doorbell_button_sound"), + value=lambda api, ch: api.doorbell_button_sound(ch), + method=lambda api, ch, value: api.set_volume(ch, doorbell_button_sound=value), + ), ) NVR_SWITCH_ENTITIES = ( diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 37c483505b..b249b7536b 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -41,7 +41,17 @@ from homeassistant.helpers.reload import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import COORDINATOR, DOMAIN, PLATFORM_IDX, REST, REST_DATA, REST_IDX +from .const import ( + CONF_ENCODING, + CONF_SSL_CIPHER_LIST, + COORDINATOR, + DEFAULT_SSL_CIPHER_LIST, + DOMAIN, + PLATFORM_IDX, + REST, + REST_DATA, + REST_IDX, +) from .data import RestData from .schema import CONFIG_SCHEMA, RESOURCE_SCHEMA # noqa: F401 @@ -177,12 +187,13 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res method: str = config[CONF_METHOD] payload: str | None = config.get(CONF_PAYLOAD) verify_ssl: bool = config[CONF_VERIFY_SSL] + ssl_cipher_list: str = config.get(CONF_SSL_CIPHER_LIST, DEFAULT_SSL_CIPHER_LIST) username: str | None = config.get(CONF_USERNAME) password: str | None = config.get(CONF_PASSWORD) headers: dict[str, str] | None = config.get(CONF_HEADERS) params: dict[str, str] | None = config.get(CONF_PARAMS) timeout: int = config[CONF_TIMEOUT] - + encoding: str = config[CONF_ENCODING] if resource_template is not None: resource_template.hass = hass resource = resource_template.async_render(parse_result=False) @@ -201,5 +212,15 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res auth = (username, password) return RestData( - hass, method, resource, auth, headers, params, payload, verify_ssl, timeout + hass, + method, + resource, + encoding, + auth, + headers, + params, + payload, + verify_ssl, + ssl_cipher_list, + timeout, ) diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index 320413a10a..60d9a2d850 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -1,6 +1,9 @@ """Support for RESTful binary sensors.""" from __future__ import annotations +import logging +import ssl + import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -31,6 +34,8 @@ from .data import RestData from .entity import RestEntity from .schema import BINARY_SENSOR_SCHEMA, RESOURCE_SCHEMA +_LOGGER = logging.getLogger(__name__) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({**RESOURCE_SCHEMA, **BINARY_SENSOR_SCHEMA}) PLATFORM_SCHEMA = vol.All( @@ -59,6 +64,13 @@ async def async_setup_platform( if rest.data is None: if rest.last_exception: + if isinstance(rest.last_exception, ssl.SSLError): + _LOGGER.error( + "Error connecting %s failed with %s", + conf[CONF_RESOURCE], + rest.last_exception, + ) + return raise PlatformNotReady from rest.last_exception raise PlatformNotReady diff --git a/homeassistant/components/rest/const.py b/homeassistant/components/rest/const.py index 5fd32d8fba..0bf0ea9743 100644 --- a/homeassistant/components/rest/const.py +++ b/homeassistant/components/rest/const.py @@ -1,10 +1,16 @@ """The rest component constants.""" +from homeassistant.util.ssl import SSLCipherList + DOMAIN = "rest" DEFAULT_METHOD = "GET" DEFAULT_VERIFY_SSL = True +DEFAULT_SSL_CIPHER_LIST = SSLCipherList.PYTHON_DEFAULT DEFAULT_FORCE_UPDATE = False +DEFAULT_ENCODING = "UTF-8" +CONF_ENCODING = "encoding" +CONF_SSL_CIPHER_LIST = "ssl_cipher_list" DEFAULT_BINARY_SENSOR_NAME = "REST Binary Sensor" DEFAULT_SENSOR_NAME = "REST Sensor" diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index c1990b2833..8f1dd93739 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -2,12 +2,14 @@ from __future__ import annotations import logging +import ssl import httpx from homeassistant.core import HomeAssistant from homeassistant.helpers import template -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.util.ssl import SSLCipherList DEFAULT_TIMEOUT = 10 @@ -22,23 +24,27 @@ class RestData: hass: HomeAssistant, method: str, resource: str, + encoding: str, auth: httpx.DigestAuth | tuple[str, str] | None, headers: dict[str, str] | None, params: dict[str, str] | None, data: str | None, verify_ssl: bool, + ssl_cipher_list: str, timeout: int = DEFAULT_TIMEOUT, ) -> None: """Initialize the data object.""" self._hass = hass self._method = method self._resource = resource + self._encoding = encoding self._auth = auth self._headers = headers self._params = params self._request_data = data self._timeout = timeout self._verify_ssl = verify_ssl + self._ssl_cipher_list = SSLCipherList(ssl_cipher_list) self._async_client: httpx.AsyncClient | None = None self.data: str | None = None self.last_exception: Exception | None = None @@ -51,8 +57,11 @@ class RestData: 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: - self._async_client = get_async_client( - self._hass, verify_ssl=self._verify_ssl + self._async_client = create_async_httpx_client( + self._hass, + verify_ssl=self._verify_ssl, + default_encoding=self._encoding, + ssl_cipher_list=self._ssl_cipher_list, ) rendered_headers = template.render_complex(self._headers, parse_result=False) @@ -86,3 +95,11 @@ class RestData: self.last_exception = ex self.data = None self.headers = None + except ssl.SSLError as ex: + if log_errors: + _LOGGER.error( + "Error connecting to %s failed with %s", self._resource, ex + ) + self.last_exception = ex + self.data = None + self.headers = None diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json index c8796c7161..b6ec7eb8ec 100644 --- a/homeassistant/components/rest/manifest.json +++ b/homeassistant/components/rest/manifest.json @@ -1,7 +1,7 @@ { "domain": "rest", "name": "RESTful", - "codeowners": [], + "codeowners": ["@epenet"], "documentation": "https://www.home-assistant.io/integrations/rest", "iot_class": "local_polling", "requirements": ["jsonpath==0.82", "xmltodict==0.13.0"] diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index cfd8f8a385..c5abe42d7f 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -31,12 +31,17 @@ from homeassistant.helpers.template_entity import ( TEMPLATE_ENTITY_BASE_SCHEMA, TEMPLATE_SENSOR_BASE_SCHEMA, ) +from homeassistant.util.ssl import SSLCipherList from .const import ( + CONF_ENCODING, CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, + CONF_SSL_CIPHER_LIST, + DEFAULT_ENCODING, DEFAULT_FORCE_UPDATE, DEFAULT_METHOD, + DEFAULT_SSL_CIPHER_LIST, DEFAULT_VERIFY_SSL, DOMAIN, METHODS, @@ -56,7 +61,12 @@ RESOURCE_SCHEMA = { vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(CONF_PAYLOAD): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional( + CONF_SSL_CIPHER_LIST, + default=DEFAULT_SSL_CIPHER_LIST, + ): vol.In([e.value for e in SSLCipherList]), vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, } SENSOR_SCHEMA = { diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 07fef4c4ea..ead5a5893f 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +import ssl from xml.parsers.expat import ExpatError from jsonpath import jsonpath @@ -67,6 +68,13 @@ async def async_setup_platform( if rest.data is None: if rest.last_exception: + if isinstance(rest.last_exception, ssl.SSLError): + _LOGGER.error( + "Error connecting %s failed with %s", + conf[CONF_RESOURCE], + rest.last_exception, + ) + return raise PlatformNotReady from rest.last_exception raise PlatformNotReady diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index cda35d1f91..9e016db037 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -97,8 +98,8 @@ async def async_setup_platform( "Missing resource or schema in configuration. " "Add http:// or https:// to your URL" ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("No route to resource/endpoint: %s", resource) + except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc class RestSwitch(TemplateEntity, SwitchEntity): diff --git a/homeassistant/components/ridwell/__init__.py b/homeassistant/components/ridwell/__init__.py index 116528f4ca..1b0a83f1c0 100644 --- a/homeassistant/components/ridwell/__init__.py +++ b/homeassistant/components/ridwell/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import entity_registry as er from .const import DOMAIN, LOGGER, SENSOR_TYPE_NEXT_PICKUP from .coordinator import RidwellDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/ridwell/calendar.py b/homeassistant/components/ridwell/calendar.py new file mode 100644 index 0000000000..57919ed1fe --- /dev/null +++ b/homeassistant/components/ridwell/calendar.py @@ -0,0 +1,78 @@ +"""Support for Ridwell calendars.""" +from __future__ import annotations + +import datetime + +from aioridwell.model import RidwellAccount, RidwellPickupEvent + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import RidwellDataUpdateCoordinator +from .entity import RidwellEntity + + +@callback +def async_get_calendar_event_from_pickup_event( + pickup_event: RidwellPickupEvent, +) -> CalendarEvent: + """Get a HASS CalendarEvent from an aioridwell PickupEvent.""" + pickup_type_string = ", ".join( + [ + f"{pickup.name} (quantity: {pickup.quantity})" + for pickup in pickup_event.pickups + ] + ) + return CalendarEvent( + summary=f"Ridwell Pickup ({pickup_event.state.value})", + description=f"Pickup types: {pickup_type_string}", + start=pickup_event.pickup_date, + end=pickup_event.pickup_date + datetime.timedelta(days=1), + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Ridwell calendars based on a config entry.""" + coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + RidwellCalendar(coordinator, account) + for account in coordinator.accounts.values() + ) + + +class RidwellCalendar(RidwellEntity, CalendarEntity): + """Define a Ridwell calendar.""" + + _attr_icon = "mdi:delete-empty" + + def __init__( + self, coordinator: RidwellDataUpdateCoordinator, account: RidwellAccount + ) -> None: + """Initialize the Ridwell entity.""" + super().__init__(coordinator, account) + + self._attr_unique_id = self._account.account_id + self._event: CalendarEvent | None = None + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return async_get_calendar_event_from_pickup_event(self.next_pickup_event) + + async def async_get_events( + self, + hass: HomeAssistant, + start_date: datetime.datetime, + end_date: datetime.datetime, + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range.""" + return [ + async_get_calendar_event_from_pickup_event(event) + for event in self.coordinator.data[self._account.account_id] + ] diff --git a/homeassistant/components/ridwell/coordinator.py b/homeassistant/components/ridwell/coordinator.py index a3b83c70aa..9561cd26e4 100644 --- a/homeassistant/components/ridwell/coordinator.py +++ b/homeassistant/components/ridwell/coordinator.py @@ -22,14 +22,14 @@ UPDATE_INTERVAL = timedelta(hours=1) class RidwellDataUpdateCoordinator( - DataUpdateCoordinator[dict[str, RidwellPickupEvent]] + DataUpdateCoordinator[dict[str, list[RidwellPickupEvent]]] ): """Class to manage fetching data from single endpoint.""" config_entry: ConfigEntry def __init__(self, hass: HomeAssistant, *, name: str) -> None: - """Initialize global data updater.""" + """Initialize.""" # These will be filled in by async_initialize; we give them these defaults to # avoid arduous typing checks down the line: self.accounts: dict[str, RidwellAccount] = {} @@ -38,13 +38,13 @@ class RidwellDataUpdateCoordinator( super().__init__(hass, LOGGER, name=name, update_interval=UPDATE_INTERVAL) - async def _async_update_data(self) -> dict[str, RidwellPickupEvent]: + async def _async_update_data(self) -> dict[str, list[RidwellPickupEvent]]: """Fetch the latest data from the source.""" data = {} async def async_get_pickups(account: RidwellAccount) -> None: """Get the latest pickups for an account.""" - data[account.account_id] = await account.async_get_next_pickup_event() + data[account.account_id] = await account.async_get_pickup_events() tasks = [async_get_pickups(account) for account in self.accounts.values()] results = await asyncio.gather(*tasks, return_exceptions=True) diff --git a/homeassistant/components/ridwell/diagnostics.py b/homeassistant/components/ridwell/diagnostics.py index 772efb87ac..f48861cee1 100644 --- a/homeassistant/components/ridwell/diagnostics.py +++ b/homeassistant/components/ridwell/diagnostics.py @@ -32,7 +32,11 @@ async def async_get_config_entry_diagnostics( return async_redact_data( { "entry": entry.as_dict(), - "data": [dataclasses.asdict(event) for event in coordinator.data.values()], + "data": [ + dataclasses.asdict(event) + for events in coordinator.data.values() + for event in events + ], }, TO_REDACT, ) diff --git a/homeassistant/components/ridwell/entity.py b/homeassistant/components/ridwell/entity.py index 29dd68e2a8..9c7ceee7f5 100644 --- a/homeassistant/components/ridwell/entity.py +++ b/homeassistant/components/ridwell/entity.py @@ -1,8 +1,12 @@ """Define a base Ridwell entity.""" +from __future__ import annotations + +from datetime import date + from aioridwell.model import RidwellAccount, RidwellPickupEvent from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -18,7 +22,6 @@ class RidwellEntity(CoordinatorEntity[RidwellDataUpdateCoordinator]): self, coordinator: RidwellDataUpdateCoordinator, account: RidwellAccount, - description: EntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) @@ -31,10 +34,12 @@ class RidwellEntity(CoordinatorEntity[RidwellDataUpdateCoordinator]): manufacturer="Ridwell", name="Ridwell", ) - self._attr_unique_id = f"{account.account_id}_{description.key}" - self.entity_description = description @property def next_pickup_event(self) -> RidwellPickupEvent: """Get the next pickup event.""" - return self.coordinator.data[self._account.account_id] + return next( + event + for event in self.coordinator.data[self._account.account_id] + if event.pickup_date >= date.today() + ) diff --git a/homeassistant/components/ridwell/sensor.py b/homeassistant/components/ridwell/sensor.py index 05cee54ba9..1eba555e95 100644 --- a/homeassistant/components/ridwell/sensor.py +++ b/homeassistant/components/ridwell/sensor.py @@ -27,7 +27,7 @@ ATTR_QUANTITY = "quantity" SENSOR_DESCRIPTION = SensorEntityDescription( key=SENSOR_TYPE_NEXT_PICKUP, - name="Ridwell pickup", + name="Next Ridwell pickup", device_class=SensorDeviceClass.DATE, ) @@ -54,9 +54,10 @@ class RidwellSensor(RidwellEntity, SensorEntity): description: SensorEntityDescription, ) -> None: """Initialize.""" - super().__init__(coordinator, account, description) + super().__init__(coordinator, account) - self._attr_name = f"{description.name} ({account.address['street1']})" + self._attr_unique_id = f"{account.account_id}_{description.key}" + self.entity_description = description @property def extra_state_attributes(self) -> Mapping[str, Any]: diff --git a/homeassistant/components/ridwell/switch.py b/homeassistant/components/ridwell/switch.py index f16bbaebab..7a948f8b88 100644 --- a/homeassistant/components/ridwell/switch.py +++ b/homeassistant/components/ridwell/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from aioridwell.errors import RidwellError -from aioridwell.model import EventState +from aioridwell.model import EventState, RidwellAccount from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -38,7 +38,19 @@ async def async_setup_entry( class RidwellSwitch(RidwellEntity, SwitchEntity): - """Define a Ridwell button.""" + """Define a Ridwell switch.""" + + def __init__( + self, + coordinator: RidwellDataUpdateCoordinator, + account: RidwellAccount, + description: SwitchEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator, account) + + self._attr_unique_id = f"{account.account_id}_{description.key}" + self.entity_description = description @property def is_on(self) -> bool: diff --git a/homeassistant/components/risco/strings.json b/homeassistant/components/risco/strings.json index 1cc2fe7317..ed3d832cf0 100644 --- a/homeassistant/components/risco/strings.json +++ b/homeassistant/components/risco/strings.json @@ -37,8 +37,8 @@ "title": "Configure options", "data": { "scan_interval": "How often to poll Risco (in seconds)", - "code_arm_required": "Require [%key:common::config_flow::data::pin%] to arm", - "code_disarm_required": "Require [%key:common::config_flow::data::pin%] to disarm" + "code_arm_required": "Require PIN to arm", + "code_disarm_required": "Require PIN to disarm" } }, "risco_to_ha": { diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py index a165fc0b2f..18fd30754e 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -24,7 +24,7 @@ PLATFORMS = [ _LOGGER = logging.getLogger(__name__) -UPDATE_INTERVAL = timedelta(seconds=30) +UPDATE_INTERVAL = timedelta(minutes=2) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py new file mode 100644 index 0000000000..497d30b41c --- /dev/null +++ b/homeassistant/components/roborock/__init__.py @@ -0,0 +1,77 @@ +"""The Roborock component.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging + +from roborock.api import RoborockApiClient +from roborock.cloud_api import RoborockMqttClient +from roborock.containers import HomeDataDevice, RoborockDeviceInfo, UserData + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS +from .coordinator import RoborockDataUpdateCoordinator + +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up roborock from a config entry.""" + _LOGGER.debug("Integration async setup entry: %s", entry.as_dict()) + + user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) + api_client = RoborockApiClient(entry.data[CONF_USERNAME], entry.data[CONF_BASE_URL]) + _LOGGER.debug("Getting home data") + home_data = await api_client.get_home_data(user_data) + _LOGGER.debug("Got home data %s", home_data) + devices: list[HomeDataDevice] = home_data.devices + home_data.received_devices + # Create a mqtt_client, which is needed to get the networking information of the device for local connection and in the future, get the map. + mqtt_client = RoborockMqttClient( + user_data, {device.duid: RoborockDeviceInfo(device) for device in devices} + ) + network_results = await asyncio.gather( + *(mqtt_client.get_networking(device.duid) for device in devices) + ) + network_info = { + device.duid: result + for device, result in zip(devices, network_results) + if result is not None + } + await mqtt_client.async_disconnect() + if not network_info: + raise ConfigEntryNotReady( + "Could not get network information about your devices" + ) + + product_info = {product.id: product for product in home_data.products} + coordinator = RoborockDataUpdateCoordinator( + hass, + devices, + network_info, + product_info, + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + await hass.data[DOMAIN][entry.entry_id].release() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py new file mode 100644 index 0000000000..d0c2147c1e --- /dev/null +++ b/homeassistant/components/roborock/config_flow.py @@ -0,0 +1,99 @@ +"""Config flow for Roborock.""" +from __future__ import annotations + +import logging +from typing import Any + +from roborock.api import RoborockApiClient +from roborock.containers import UserData +from roborock.exceptions import RoborockException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_BASE_URL, CONF_ENTRY_CODE, CONF_USER_DATA, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Roborock.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._username: str | None = None + self._client: RoborockApiClient | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + username = user_input[CONF_USERNAME] + await self.async_set_unique_id(username.lower()) + self._abort_if_unique_id_configured() + self._username = username + _LOGGER.debug("Requesting code for Roborock account") + self._client = RoborockApiClient(username) + try: + await self._client.request_code() + except RoborockException as ex: + _LOGGER.exception(ex) + errors["base"] = "invalid_email" + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception(ex) + errors["base"] = "unknown" + else: + return await self.async_step_code() + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_USERNAME): str}), + errors=errors, + ) + + async def async_step_code( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + assert self._client + assert self._username + if user_input is not None: + code = user_input[CONF_ENTRY_CODE] + _LOGGER.debug("Logging into Roborock account using email provided code") + try: + login_data = await self._client.code_login(code) + except RoborockException as ex: + _LOGGER.exception(ex) + errors["base"] = "invalid_code" + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception(ex) + errors["base"] = "unknown" + else: + return self._create_entry(self._client, self._username, login_data) + + return self.async_show_form( + step_id="code", + data_schema=vol.Schema({vol.Required(CONF_ENTRY_CODE): str}), + errors=errors, + ) + + def _create_entry( + self, client: RoborockApiClient, username: str, user_data: UserData + ) -> FlowResult: + """Finished config flow and create entry.""" + return self.async_create_entry( + title=username, + data={ + CONF_USERNAME: username, + CONF_USER_DATA: user_data.as_dict(), + CONF_BASE_URL: client.base_url, + }, + ) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py new file mode 100644 index 0000000000..61a9a70dd2 --- /dev/null +++ b/homeassistant/components/roborock/const.py @@ -0,0 +1,9 @@ +"""Constants for Roborock.""" +from homeassistant.const import Platform + +DOMAIN = "roborock" +CONF_ENTRY_CODE = "code" +CONF_BASE_URL = "base_url" +CONF_USER_DATA = "user_data" + +PLATFORMS = [Platform.VACUUM, Platform.SELECT] diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py new file mode 100644 index 0000000000..997c0a6acb --- /dev/null +++ b/homeassistant/components/roborock/coordinator.py @@ -0,0 +1,88 @@ +"""Roborock Coordinator.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging + +from roborock.containers import ( + HomeDataDevice, + HomeDataProduct, + NetworkInfo, + RoborockLocalDeviceInfo, +) +from roborock.exceptions import RoborockException +from roborock.local_api import RoborockLocalClient +from roborock.typing import RoborockDeviceProp + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN +from .models import RoborockHassDeviceInfo + +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +class RoborockDataUpdateCoordinator( + DataUpdateCoordinator[dict[str, RoborockDeviceProp]] +): + """Class to manage fetching data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + devices: list[HomeDataDevice], + devices_networking: dict[str, NetworkInfo], + product_info: dict[str, HomeDataProduct], + ) -> None: + """Initialize.""" + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + local_devices_info: dict[str, RoborockLocalDeviceInfo] = {} + hass_devices_info: dict[str, RoborockHassDeviceInfo] = {} + for device in devices: + if not (networking := devices_networking.get(device.duid)): + _LOGGER.warning("Device %s is offline and cannot be setup", device.duid) + continue + hass_devices_info[device.duid] = RoborockHassDeviceInfo( + device, + networking, + product_info[device.product_id], + RoborockDeviceProp(), + ) + local_devices_info[device.duid] = RoborockLocalDeviceInfo( + device, networking + ) + self.api = RoborockLocalClient(local_devices_info) + self.devices_info = hass_devices_info + + async def release(self) -> None: + """Disconnect from API.""" + await self.api.async_disconnect() + + async def _update_device_prop(self, device_info: RoborockHassDeviceInfo) -> None: + """Update device properties.""" + device_prop = await self.api.get_prop(device_info.device.duid) + if device_prop: + if device_info.props: + device_info.props.update(device_prop) + else: + device_info.props = device_prop + + async def _async_update_data(self) -> dict[str, RoborockDeviceProp]: + """Update data via library.""" + try: + await asyncio.gather( + *( + self._update_device_prop(device_info) + for device_info in self.devices_info.values() + ) + ) + except RoborockException as ex: + raise UpdateFailed(ex) from ex + return { + device_id: device_info.props + for device_id, device_info in self.devices_info.items() + } diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py new file mode 100644 index 0000000000..e544147e9b --- /dev/null +++ b/homeassistant/components/roborock/device.py @@ -0,0 +1,66 @@ +"""Support for Roborock device base class.""" + +from typing import Any + +from roborock.containers import Status +from roborock.typing import RoborockCommand + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import RoborockDataUpdateCoordinator +from .const import DOMAIN +from .models import RoborockHassDeviceInfo + + +class RoborockCoordinatedEntity(CoordinatorEntity[RoborockDataUpdateCoordinator]): + """Representation of a base a coordinated Roborock Entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + unique_id: str, + device_info: RoborockHassDeviceInfo, + coordinator: RoborockDataUpdateCoordinator, + ) -> None: + """Initialize the coordinated Roborock Device.""" + super().__init__(coordinator) + self._attr_unique_id = unique_id + self._device_name = device_info.device.name + self._device_id = device_info.device.duid + self._device_model = device_info.product.model + self._fw_version = device_info.device.fv + + @property + def _device_status(self) -> Status: + """Return the status of the device.""" + data = self.coordinator.data + if data: + device_data = data.get(self._device_id) + if device_data: + status = device_data.status + if status: + return status + return Status({}) + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + name=self._device_name, + identifiers={(DOMAIN, self._device_id)}, + manufacturer="Roborock", + model=self._device_model, + sw_version=self._fw_version, + ) + + async def send( + self, command: RoborockCommand, params: dict[str, Any] | list[Any] | None = None + ) -> dict: + """Send a command to a vacuum cleaner.""" + response = await self.coordinator.api.send_command( + self._device_id, command, params + ) + await self.coordinator.async_request_refresh() + return response diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 00f90271cf..20dee34db0 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -1,6 +1,10 @@ { "domain": "roborock", "name": "Roborock", - "integration_type": "virtual", - "supported_by": "xiaomi_miio" + "codeowners": ["@humbertogontijo", "@Lash-L"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/roborock", + "iot_class": "local_polling", + "loggers": ["roborock"], + "requirements": ["python-roborock==0.6.5"] } diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py new file mode 100644 index 0000000000..ae0adb4ad7 --- /dev/null +++ b/homeassistant/components/roborock/models.py @@ -0,0 +1,15 @@ +"""Roborock Models.""" +from dataclasses import dataclass + +from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo +from roborock.typing import RoborockDeviceProp + + +@dataclass +class RoborockHassDeviceInfo: + """A model to describe roborock devices.""" + + device: HomeDataDevice + network_info: NetworkInfo + product: HomeDataProduct + props: RoborockDeviceProp diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py new file mode 100644 index 0000000000..646c490485 --- /dev/null +++ b/homeassistant/components/roborock/select.py @@ -0,0 +1,116 @@ +"""Support for Roborock select.""" +from collections.abc import Callable +from dataclasses import dataclass + +from roborock.code_mappings import RoborockMopIntensityCode, RoborockMopModeCode +from roborock.containers import Status +from roborock.exceptions import RoborockException +from roborock.typing import RoborockCommand + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockCoordinatedEntity +from .models import RoborockHassDeviceInfo + + +@dataclass +class RoborockSelectDescriptionMixin: + """Define an entity description mixin for select entities.""" + + api_command: RoborockCommand + value_fn: Callable[[Status], str] + options_lambda: Callable[[str], list[int]] + + +@dataclass +class RoborockSelectDescription( + SelectEntityDescription, RoborockSelectDescriptionMixin +): + """Class to describe an Roborock select entity.""" + + +SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ + RoborockSelectDescription( + key="water_box_mode", + translation_key="mop_intensity", + options=RoborockMopIntensityCode.values(), + api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, + value_fn=lambda data: data.water_box_mode, + options_lambda=lambda data: [ + k for k, v in RoborockMopIntensityCode.items() if v == data + ], + ), + RoborockSelectDescription( + key="mop_mode", + translation_key="mop_mode", + options=RoborockMopModeCode.values(), + api_command=RoborockCommand.SET_MOP_MODE, + value_fn=lambda data: data.mop_mode, + options_lambda=lambda data: [ + k for k, v in RoborockMopModeCode.items() if v == data + ], + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roborock select platform.""" + + coordinator: RoborockDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + RoborockSelectEntity( + f"{description.key}_{slugify(device_id)}", + device_info, + coordinator, + description, + ) + for device_id, device_info in coordinator.devices_info.items() + for description in SELECT_DESCRIPTIONS + ) + + +class RoborockSelectEntity(RoborockCoordinatedEntity, SelectEntity): + """A class to let you set options on a Roborock vacuum where the potential options are fixed.""" + + entity_description: RoborockSelectDescription + + def __init__( + self, + unique_id: str, + device_info: RoborockHassDeviceInfo, + coordinator: RoborockDataUpdateCoordinator, + entity_description: RoborockSelectDescription, + ) -> None: + """Create a select entity.""" + self.entity_description = entity_description + super().__init__(unique_id, device_info, coordinator) + + async def async_select_option(self, option: str) -> None: + """Set the mop intensity.""" + try: + await self.send( + self.entity_description.api_command, + self.entity_description.options_lambda(option), + ) + except RoborockException as err: + raise HomeAssistantError( + f"Error while setting {self.entity_description.key} to {option}" + ) from err + + @property + def current_option(self) -> str | None: + """Get the current status of the select entity from device_status.""" + return self.entity_description.value_fn(self._device_status) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json new file mode 100644 index 0000000000..7e755a0c41 --- /dev/null +++ b/homeassistant/components/roborock/strings.json @@ -0,0 +1,49 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter your Roborock email address.", + "data": { + "username": "Email" + } + }, + "code": { + "description": "Type the verification code sent to your email", + "data": { + "code": "Verification code" + } + } + }, + "error": { + "invalid_code": "The code you entered was incorrect, please check it and try again.", + "invalid_email": "There is no account associated with the email you entered, please try again.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "select": { + "mop_mode": { + "name": "Mop mode", + "state": { + "standard": "Standard", + "deep": "Deep", + "deep_plus": "Deep+", + "custom": "Custom" + } + }, + "mop_intensity": { + "name": "Mop intensity", + "state": { + "off": "Off", + "mild": "Mild", + "moderate": "Moderate", + "intense": "Intense", + "custom": "Custom" + } + } + } + } +} diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py new file mode 100644 index 0000000000..4306afb25e --- /dev/null +++ b/homeassistant/components/roborock/vacuum.py @@ -0,0 +1,173 @@ +"""Support for Roborock vacuum class.""" +from typing import Any + +from roborock.code_mappings import RoborockFanPowerCode, RoborockStateCode +from roborock.typing import RoborockCommand + +from homeassistant.components.vacuum import ( + STATE_CLEANING, + STATE_DOCKED, + STATE_ERROR, + STATE_IDLE, + STATE_PAUSED, + STATE_RETURNING, + StateVacuumEntity, + VacuumEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockCoordinatedEntity +from .models import RoborockHassDeviceInfo + +STATE_CODE_TO_STATE = { + RoborockStateCode["1"]: STATE_IDLE, # "Starting" + RoborockStateCode["2"]: STATE_IDLE, # "Charger disconnected" + RoborockStateCode["3"]: STATE_IDLE, # "Idle" + RoborockStateCode["4"]: STATE_CLEANING, # "Remote control active" + RoborockStateCode["5"]: STATE_CLEANING, # "Cleaning" + RoborockStateCode["6"]: STATE_RETURNING, # "Returning home" + RoborockStateCode["7"]: STATE_CLEANING, # "Manual mode" + RoborockStateCode["8"]: STATE_DOCKED, # "Charging" + RoborockStateCode["9"]: STATE_ERROR, # "Charging problem" + RoborockStateCode["10"]: STATE_PAUSED, # "Paused" + RoborockStateCode["11"]: STATE_CLEANING, # "Spot cleaning" + RoborockStateCode["12"]: STATE_ERROR, # "Error" + RoborockStateCode["13"]: STATE_IDLE, # "Shutting down" + RoborockStateCode["14"]: STATE_DOCKED, # "Updating" + RoborockStateCode["15"]: STATE_RETURNING, # "Docking" + RoborockStateCode["16"]: STATE_CLEANING, # "Going to target" + RoborockStateCode["17"]: STATE_CLEANING, # "Zoned cleaning" + RoborockStateCode["18"]: STATE_CLEANING, # "Segment cleaning" + RoborockStateCode["22"]: STATE_DOCKED, # "Emptying the bin" on s7+ + RoborockStateCode["23"]: STATE_DOCKED, # "Washing the mop" on s7maxV + RoborockStateCode["26"]: STATE_RETURNING, # "Going to wash the mop" on s7maxV + RoborockStateCode["100"]: STATE_DOCKED, # "Charging complete" + RoborockStateCode["101"]: STATE_ERROR, # "Device offline" +} + + +ATTR_STATUS = "status" +ATTR_ERROR = "error" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Roborock sensor.""" + coordinator: RoborockDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + RoborockVacuum(slugify(device_id), device_info, coordinator) + for device_id, device_info in coordinator.devices_info.items() + ) + + +class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): + """General Representation of a Roborock vacuum.""" + + _attr_icon = "mdi:robot-vacuum" + _attr_supported_features = ( + VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STOP + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.STATUS + | VacuumEntityFeature.SEND_COMMAND + | VacuumEntityFeature.LOCATE + | VacuumEntityFeature.CLEAN_SPOT + | VacuumEntityFeature.STATE + | VacuumEntityFeature.START + ) + _attr_fan_speed_list = RoborockFanPowerCode.values() + + def __init__( + self, + unique_id: str, + device: RoborockHassDeviceInfo, + coordinator: RoborockDataUpdateCoordinator, + ) -> None: + """Initialize a vacuum.""" + StateVacuumEntity.__init__(self) + RoborockCoordinatedEntity.__init__(self, unique_id, device, coordinator) + + @property + def state(self) -> str | None: + """Return the status of the vacuum cleaner.""" + return STATE_CODE_TO_STATE.get(self._device_status.state) + + @property + def status(self) -> str | None: + """Return the status of the vacuum cleaner.""" + return self._device_status.status + + @property + def battery_level(self) -> int | None: + """Return the battery level of the vacuum cleaner.""" + return self._device_status.battery + + @property + def fan_speed(self) -> str | None: + """Return the fan speed of the vacuum cleaner.""" + return self._device_status.fan_power + + @property + def error(self) -> str | None: + """Get the error str if an error code exists.""" + return self._device_status.error + + async def async_start(self) -> None: + """Start the vacuum.""" + await self.send(RoborockCommand.APP_START) + + async def async_pause(self) -> None: + """Pause the vacuum.""" + await self.send(RoborockCommand.APP_PAUSE) + + async def async_stop(self, **kwargs: Any) -> None: + """Stop the vacuum.""" + await self.send(RoborockCommand.APP_STOP) + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Send vacuum back to base.""" + await self.send(RoborockCommand.APP_CHARGE) + + async def async_clean_spot(self, **kwargs: Any) -> None: + """Spot clean.""" + await self.send(RoborockCommand.APP_SPOT) + + async def async_locate(self, **kwargs: Any) -> None: + """Locate vacuum.""" + await self.send(RoborockCommand.FIND_ME) + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set vacuum fan speed.""" + await self.send( + RoborockCommand.SET_CUSTOM_MODE, + [k for k, v in RoborockFanPowerCode.items() if v == fan_speed], + ) + await self.coordinator.async_request_refresh() + + async def async_start_pause(self): + """Start, pause or resume the cleaning task.""" + if self.state == STATE_CLEANING: + await self.async_pause() + else: + await self.async_start() + + async def async_send_command( + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> None: + """Send a command to a vacuum cleaner.""" + await self.send(command, params) diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index b0191f605d..877e58233d 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -265,7 +265,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: @@ -278,7 +278,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 08815cae9f..7b437a4f8c 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -24,5 +24,5 @@ "documentation": "https://www.home-assistant.io/integrations/roomba", "iot_class": "local_push", "loggers": ["paho_mqtt", "roombapy"], - "requirements": ["roombapy==1.6.6"] + "requirements": ["roombapy==1.6.8"] } diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 307765da5c..3bcafe4ba9 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -498,7 +498,9 @@ class RoonDevice(MediaPlayerEntity): ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await self.hass.async_add_executor_job( diff --git a/homeassistant/components/ruuvi_gateway/manifest.json b/homeassistant/components/ruuvi_gateway/manifest.json index cf1c9e02af..a928489397 100644 --- a/homeassistant/components/ruuvi_gateway/manifest.json +++ b/homeassistant/components/ruuvi_gateway/manifest.json @@ -11,5 +11,5 @@ ], "documentation": "https://www.home-assistant.io/integrations/ruuvi_gateway", "iot_class": "local_polling", - "requirements": ["aioruuvigateway==0.0.2"] + "requirements": ["aioruuvigateway==0.1.0"] } diff --git a/homeassistant/components/rympro/manifest.json b/homeassistant/components/rympro/manifest.json index 613a1c3361..e14ac9af71 100644 --- a/homeassistant/components/rympro/manifest.json +++ b/homeassistant/components/rympro/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rympro", "iot_class": "cloud_polling", - "requirements": ["pyrympro==0.0.4"] + "requirements": ["pyrympro==0.0.7"] } diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 0d90157f76..55d0fbdfbd 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -165,10 +165,9 @@ class DebouncedEntryReloader: LOGGER.debug("Calling debouncer to get a reload after cooldown") await self._debounced_reload.async_call() - @callback - def async_cancel(self) -> None: + async def async_shutdown(self) -> None: """Cancel any pending reload.""" - self._debounced_reload.async_cancel() + await self._debounced_reload.async_shutdown() async def _async_reload_entry(self) -> None: """Reload entry.""" @@ -228,7 +227,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # will be a race where the config flow will see the entry # as not loaded and may reload it debounced_reloader = DebouncedEntryReloader(hass, entry) - entry.async_on_unload(debounced_reloader.async_cancel) + entry.async_on_unload(debounced_reloader.async_shutdown) entry.async_on_unload(entry.add_update_listener(debounced_reloader.async_call)) hass.data[DOMAIN][entry.entry_id] = bridge @@ -340,7 +339,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> en_reg.async_clear_config_entry(config_entry.entry_id) version = config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry) LOGGER.debug("Migration to version %s successful", version) return True diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index fb00d58c7a..2e5fcc2771 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, time, timedelta import itertools -import logging from typing import Any, Literal import voluptuous as vol @@ -21,9 +20,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers.collection import ( CollectionEntity, + DictStorageCollection, + DictStorageCollectionWebsocket, IDManager, - StorageCollection, - StorageCollectionWebsocket, + SerializedStorageCollection, YamlCollection, sync_entity_lifecycle, ) @@ -173,7 +173,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: version=STORAGE_VERSION, minor_version=STORAGE_VERSION_MINOR, ), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) sync_entity_lifecycle(hass, DOMAIN, DOMAIN, component, storage_collection, Schedule) @@ -183,7 +182,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - StorageCollectionWebsocket( + DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, @@ -210,7 +209,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class ScheduleStorageCollection(StorageCollection): +class ScheduleStorageCollection(DictStorageCollection): """Schedules stored in storage.""" SCHEMA = vol.Schema(BASE_SCHEMA | STORAGE_SCHEDULE_SCHEMA) @@ -226,12 +225,12 @@ class ScheduleStorageCollection(StorageCollection): name: str = info[CONF_NAME] return name - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" self.SCHEMA(update_data) - return data | update_data + return item | update_data - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> SerializedStorageCollection | None: """Load the data.""" if data := await super()._async_load_data(): data["items"] = [STORAGE_SCHEMA(item) for item in data["items"]] diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index f8da366887..4c22e5ecea 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -6,6 +6,18 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "next_event": { + "name": "Next event" + } } } } diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 419dd04f60..3ca13e56b2 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -60,7 +60,15 @@ from homeassistant.helpers.selector import ( ) from . import COMBINED_SCHEMA -from .const import CONF_INDEX, CONF_SELECT, DEFAULT_NAME, DEFAULT_VERIFY_SSL, DOMAIN +from .const import ( + CONF_ENCODING, + CONF_INDEX, + CONF_SELECT, + DEFAULT_ENCODING, + DEFAULT_NAME, + DEFAULT_VERIFY_SSL, + DOMAIN, +) RESOURCE_SETUP = { vol.Required(CONF_RESOURCE): TextSelector( @@ -84,8 +92,11 @@ RESOURCE_SETUP = { vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector( NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) ), + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), } +NONE_SENTINEL = "none" + SENSOR_SETUP = { vol.Required(CONF_SELECT): TextSelector(), vol.Optional(CONF_INDEX, default=0): NumberSelector( @@ -93,28 +104,45 @@ SENSOR_SETUP = { ), vol.Optional(CONF_ATTRIBUTE): TextSelector(), vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + vol.Required(CONF_DEVICE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorDeviceClass], + options=[NONE_SENTINEL] + + sorted( + [ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ] + ), mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class", ) ), - vol.Optional(CONF_STATE_CLASS): SelectSelector( + vol.Required(CONF_STATE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], + options=[NONE_SENTINEL] + sorted([cls.value for cls in SensorStateClass]), mode=SelectSelectorMode.DROPDOWN, + translation_key="state_class", ) ), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in UnitOfTemperature], + options=[NONE_SENTINEL] + sorted([cls.value for cls in UnitOfTemperature]), custom_value=True, mode=SelectSelectorMode.DROPDOWN, + translation_key="unit_of_measurement", ) ), } +def _strip_sentinel(options: dict[str, Any]) -> None: + """Convert sentinel to None.""" + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if options[key] == NONE_SENTINEL: + options.pop(key) + + async def validate_rest_setup( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: @@ -141,6 +169,7 @@ async def validate_sensor_setup( # Standard behavior is to merge the result with the options. # In this case, we want to add a sub-item so we update the options directly. sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, []) + _strip_sentinel(user_input) sensors.append(user_input) return {} @@ -172,7 +201,11 @@ async def get_edit_sensor_suggested_values( ) -> dict[str, Any]: """Return suggested values for sensor editing.""" idx: int = handler.flow_state["_idx"] - return cast(dict[str, Any], handler.options[SENSOR_DOMAIN][idx]) + suggested_values: dict[str, Any] = dict(handler.options[SENSOR_DOMAIN][idx]) + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if not suggested_values.get(key): + suggested_values[key] = NONE_SENTINEL + return suggested_values async def validate_sensor_edit( @@ -185,6 +218,7 @@ async def validate_sensor_edit( # In this case, we want to add a sub-item so we update the options directly. idx: int = handler.flow_state["_idx"] handler.options[SENSOR_DOMAIN][idx].update(user_input) + _strip_sentinel(handler.options[SENSOR_DOMAIN][idx]) return {} diff --git a/homeassistant/components/scrape/const.py b/homeassistant/components/scrape/const.py index fc433ebb6f..cd64199fa2 100644 --- a/homeassistant/components/scrape/const.py +++ b/homeassistant/components/scrape/const.py @@ -6,11 +6,13 @@ from datetime import timedelta from homeassistant.const import Platform DOMAIN = "scrape" +DEFAULT_ENCODING = "UTF-8" DEFAULT_NAME = "Web scrape" DEFAULT_VERIFY_SSL = True DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) PLATFORMS = [Platform.SENSOR] +CONF_ENCODING = "encoding" CONF_SELECT = "select" CONF_INDEX = "index" diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 22184a17b8..5ddd6c48e4 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -1,44 +1,23 @@ """Support for getting data from websites with scraping.""" from __future__ import annotations -from datetime import timedelta import logging -from typing import Any +from typing import Any, cast import voluptuous as vol -from homeassistant.components.rest import RESOURCE_SCHEMA, create_rest_data_from_config -from homeassistant.components.sensor import ( - CONF_STATE_CLASS, - DEVICE_CLASSES_SCHEMA, - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - STATE_CLASSES_SCHEMA, - SensorDeviceClass, -) +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ATTRIBUTE, - CONF_AUTHENTICATION, - CONF_DEVICE_CLASS, - CONF_HEADERS, CONF_NAME, - CONF_PASSWORD, - CONF_RESOURCE, - CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, - CONF_UNIT_OF_MEASUREMENT, - CONF_USERNAME, CONF_VALUE_TEMPLATE, - CONF_VERIFY_SSL, - HTTP_BASIC_AUTHENTICATION, - HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ( TEMPLATE_SENSOR_BASE_SCHEMA, @@ -47,43 +26,11 @@ from homeassistant.helpers.template_entity import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - CONF_INDEX, - CONF_SELECT, - DEFAULT_NAME, - DEFAULT_SCAN_INTERVAL, - DEFAULT_VERIFY_SSL, - DOMAIN, -) +from .const import CONF_INDEX, CONF_SELECT, DOMAIN from .coordinator import ScrapeCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - { - # Linked to the loading of the page (can be linked to RestData) - vol.Optional(CONF_AUTHENTICATION): vol.In( - [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] - ), - vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), - vol.Optional(CONF_PASSWORD): cv.string, - vol.Required(CONF_RESOURCE): cv.string, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, - # Linked to the parsing of the page (specific to scrape) - vol.Optional(CONF_ATTRIBUTE): cv.string, - vol.Optional(CONF_INDEX, default=0): cv.positive_int, - vol.Required(CONF_SELECT): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - # Linked to the sensor definition (can be linked to TemplateSensor) - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -) - async def async_setup_platform( hass: HomeAssistant, @@ -92,33 +39,9 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Web scrape sensor.""" - coordinator: ScrapeCoordinator - sensors_config: list[ConfigType] - if discovery_info is None: - async_create_issue( - hass, - DOMAIN, - "moved_yaml", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="moved_yaml", - ) - resource_config = vol.Schema(RESOURCE_SCHEMA, extra=vol.REMOVE_EXTRA)(config) - rest = create_rest_data_from_config(hass, resource_config) - - scan_interval: timedelta = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - coordinator = ScrapeCoordinator(hass, rest, scan_interval) - - sensors_config = [ - vol.Schema(TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.ALLOW_EXTRA)( - config - ) - ] - - else: - coordinator = discovery_info["coordinator"] - sensors_config = discovery_info["configs"] + discovery_info = cast(DiscoveryInfoType, discovery_info) + coordinator: ScrapeCoordinator = discovery_info["coordinator"] + sensors_config: list[ConfigType] = discovery_info["configs"] await coordinator.async_refresh() if coordinator.data is None: diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 907aa2a9df..857d53eb52 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -16,14 +16,16 @@ "password": "[%key:common::config_flow::data::password%]", "headers": "Headers", "method": "Method", - "timeout": "Timeout" + "timeout": "Timeout", + "encoding": "Character encoding" }, "data_description": { "resource": "The URL to the website that contains the value", "authentication": "Type of the HTTP authentication. Either basic or digest", "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed", "headers": "Headers to use for the web request", - "timeout": "Timeout for connection to website" + "timeout": "Timeout for connection to website", + "encoding": "Character encoding to use. Defaults to UTF-8" } }, "sensor": { @@ -110,22 +112,85 @@ "password": "[%key:component::scrape::config::step::user::data::password%]", "headers": "[%key:component::scrape::config::step::user::data::headers%]", "verify_ssl": "[%key:component::scrape::config::step::user::data::verify_ssl%]", - "timeout": "[%key:component::scrape::config::step::user::data::timeout%]" + "timeout": "[%key:component::scrape::config::step::user::data::timeout%]", + "encoding": "[%key:component::scrape::config::step::user::data::encoding%]" }, "data_description": { "resource": "[%key:component::scrape::config::step::user::data_description::resource%]", "authentication": "[%key:component::scrape::config::step::user::data_description::authentication%]", "headers": "[%key:component::scrape::config::step::user::data_description::headers%]", "verify_ssl": "[%key:component::scrape::config::step::user::data_description::verify_ssl%]", - "timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]" + "timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]", + "encoding": "[%key:component::scrape::config::step::user::data_description::encoding%]" } } } }, - "issues": { - "moved_yaml": { - "title": "The Scrape YAML configuration has been moved", - "description": "Configuring Scrape using YAML has been moved to integration key.\n\nYour existing YAML configuration will be working for 2 more versions.\n\nMigrate your YAML configuration to the integration key according to the documentation." + "selector": { + "device_class": { + "options": { + "none": "No device class", + "date": "[%key:component::sensor::entity_component::date::name%]", + "duration": "[%key:component::sensor::entity_component::duration::name%]", + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", + "battery": "[%key:component::sensor::entity_component::battery::name%]", + "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "data_size": "[%key:component::sensor::entity_component::data_size::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "monetary": "[%key:component::sensor::entity_component::monetary::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "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%]", + "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%]", + "water": "[%key:component::sensor::entity_component::water::name%]", + "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, + "state_class": { + "options": { + "none": "No state class", + "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", + "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" + } + }, + "unit_of_measurement": { + "options": { + "none": "No unit of measurement" + } } } } diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index cbfd73e486..9c4137c1be 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -231,7 +231,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -@dataclass +@dataclass(slots=True) class ScriptEntityConfig: """Container for prepared script entity configuration.""" diff --git a/homeassistant/components/script/strings.json b/homeassistant/components/script/strings.json index c78e4265cb..b9624f16a3 100644 --- a/homeassistant/components/script/strings.json +++ b/homeassistant/components/script/strings.json @@ -6,6 +6,29 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "current": { + "name": "[%key:component::automation::entity_component::_::state_attributes::current::name%]" + }, + "last_action": { + "name": "Last action" + }, + "last_triggered": { + "name": "[%key:component::automation::entity_component::_::state_attributes::last_triggered::name%]" + }, + "max": { + "name": "Max running scripts" + }, + "mode": { + "name": "[%key:component::automation::entity_component::_::state_attributes::mode::name%]", + "state": { + "parallel": "[%key:component::automation::entity_component::_::state_attributes::mode::state::parallel%]", + "queued": "[%key:component::automation::entity_component::_::state_attributes::mode::state::queued%]", + "restart": "[%key:component::automation::entity_component::_::state_attributes::mode::state::restart%]", + "single": "[%key:component::automation::entity_component::_::state_attributes::mode::state::single%]" + } + } } } } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 8ccf3aac71..72072d3603 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense_energy==0.11.1"] + "requirements": ["sense_energy==0.11.2"] } diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 1812f41693..d0fdc8a088 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -167,7 +167,6 @@ class SensorEntity(Entity): _attr_unit_of_measurement: None = ( None # Subclasses of SensorEntity should not set this ) - _invalid_numeric_value_reported = False _invalid_state_class_reported = False _invalid_unit_of_measurement_reported = False _last_reset_reported = False @@ -463,7 +462,7 @@ class SensorEntity(Entity): @final @property - def state(self) -> Any: # noqa: C901 + def state(self) -> Any: """Return the state of the sensor and perform unit conversions, if needed.""" native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement @@ -581,33 +580,13 @@ class SensorEntity(Entity): else: numerical_value = float(value) # type:ignore[arg-type] except (TypeError, ValueError) as err: - # Raise if precision is not None, for other cases log a warning - if suggested_precision is not None: - raise ValueError( - f"Sensor {self.entity_id} has device class {device_class}, " - f"state class {state_class} unit {unit_of_measurement} and " - f"suggested precision {suggested_precision} thus indicating it " - f"has a numeric value; however, it has the non-numeric value: " - f"{value} ({type(value)})" - ) from err - # This should raise in Home Assistant Core 2023.4 - if not self._invalid_numeric_value_reported: - self._invalid_numeric_value_reported = True - report_issue = self._suggest_report_issue() - _LOGGER.warning( - "Sensor %s has device class %s, state class %s and unit %s " - "thus indicating it has a numeric value; however, it has the " - "non-numeric value: %s (%s); Please update your configuration " - "if your entity is manually configured, otherwise %s", - self.entity_id, - device_class, - state_class, - unit_of_measurement, - value, - type(value), - report_issue, - ) - return value + raise ValueError( + f"Sensor {self.entity_id} has device class {device_class}, " + f"state class {state_class} unit {unit_of_measurement} and " + f"suggested precision {suggested_precision} thus indicating it " + f"has a numeric value; however, it has the non-numeric value: " + f"{value} ({type(value)})" + ) from err else: numerical_value = value @@ -737,7 +716,7 @@ class SensorEntity(Entity): or "suggested_display_precision" not in self.registry_entry.options ): return - sensor_options = self.registry_entry.options.get(DOMAIN, {}) + sensor_options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) if ( "suggested_display_precision" in sensor_options and sensor_options["suggested_display_precision"] == display_precision diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 356eb68b4d..e829c8a8e4 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -75,7 +75,7 @@ class SensorDeviceClass(StrEnum): DURATION = "duration" """Fixed duration. - Unit of measurement: `d`, `h`, `min`, `s` + Unit of measurement: `d`, `h`, `min`, `s`, `ms` """ ENUM = "enum" @@ -485,6 +485,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.HOURS, UnitOfTime.MINUTES, UnitOfTime.SECONDS, + UnitOfTime.MILLISECONDS, }, SensorDeviceClass.ENERGY: set(UnitOfEnergy), SensorDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 5b34c5a28e..52792e1d1f 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -160,6 +160,9 @@ "energy_storage": { "name": "Stored energy" }, + "enum": { + "name": "[%key:component::sensor::title%]" + }, "frequency": { "name": "Frequency" }, @@ -194,13 +197,13 @@ "name": "Ozone" }, "pm1": { - "name": "Particulate matter 0.1 μm" + "name": "PM1" }, "pm10": { - "name": "Particulate matter 10 μm" + "name": "PM10" }, "pm25": { - "name": "Particulate matter 2.5 μm" + "name": "PM2.5" }, "power_factor": { "name": "Power factor" @@ -235,6 +238,9 @@ "temperature": { "name": "Temperature" }, + "timestamp": { + "name": "Timestamp" + }, "volatile_organic_compounds": { "name": "VOCs" }, diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 95eff4e7a5..924c660da1 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.16.0"] + "requirements": ["sentry-sdk==1.20.0"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 58fe7a4de0..90c5bf59fa 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", - "requirements": ["pillow==9.4.0"] + "requirements": ["pillow==9.5.0"] } diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index 8758764a14..e4d41fb0cb 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -42,28 +42,28 @@ class SFRBoxBinarySensorEntityDescription( DSL_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[DslInfo], ...] = ( SFRBoxBinarySensorEntityDescription[DslInfo]( key="status", - name="DSL status", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda x: x.status == "up", + translation_key="dsl_status", ), ) FTTH_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[FtthInfo], ...] = ( SFRBoxBinarySensorEntityDescription[FtthInfo]( key="status", - name="FTTH status", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda x: x.status == "up", + translation_key="ftth_status", ), ) WAN_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[WanInfo], ...] = ( SFRBoxBinarySensorEntityDescription[WanInfo]( key="status", - name="WAN status", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda x: x.status == "up", + translation_key="wan_status", ), ) diff --git a/homeassistant/components/sfr_box/button.py b/homeassistant/components/sfr_box/button.py index 32ebeac97a..f6741da139 100644 --- a/homeassistant/components/sfr_box/button.py +++ b/homeassistant/components/sfr_box/button.py @@ -66,7 +66,7 @@ BUTTON_TYPES: tuple[SFRBoxButtonEntityDescription, ...] = ( device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, key="system_reboot", - name="Reboot", + translation_key="reboot", ), ) diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index d276d30821..19512f4382 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -46,84 +46,83 @@ class SFRBoxSensorEntityDescription(SensorEntityDescription, SFRBoxSensorMixin[_ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( SFRBoxSensorEntityDescription[DslInfo]( key="linemode", - name="DSL line mode", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + translation_key="dsl_linemode", value_fn=lambda x: x.linemode, ), SFRBoxSensorEntityDescription[DslInfo]( key="counter", - name="DSL counter", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + translation_key="dsl_counter", value_fn=lambda x: x.counter, ), SFRBoxSensorEntityDescription[DslInfo]( key="crc", - name="DSL CRC", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + translation_key="dsl_crc", value_fn=lambda x: x.crc, ), SFRBoxSensorEntityDescription[DslInfo]( key="noise_down", - name="DSL noise down", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, state_class=SensorStateClass.MEASUREMENT, + translation_key="dsl_noise_down", value_fn=lambda x: x.noise_down, ), SFRBoxSensorEntityDescription[DslInfo]( key="noise_up", - name="DSL noise up", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, state_class=SensorStateClass.MEASUREMENT, + translation_key="dsl_noise_up", value_fn=lambda x: x.noise_up, ), SFRBoxSensorEntityDescription[DslInfo]( key="attenuation_down", - name="DSL attenuation down", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, state_class=SensorStateClass.MEASUREMENT, + translation_key="dsl_attenuation_down", value_fn=lambda x: x.attenuation_down, ), SFRBoxSensorEntityDescription[DslInfo]( key="attenuation_up", - name="DSL attenuation up", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, state_class=SensorStateClass.MEASUREMENT, + translation_key="dsl_attenuation_up", value_fn=lambda x: x.attenuation_up, ), SFRBoxSensorEntityDescription[DslInfo]( key="rate_down", - name="DSL rate down", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, + translation_key="dsl_rate_down", value_fn=lambda x: x.rate_down, ), SFRBoxSensorEntityDescription[DslInfo]( key="rate_up", - name="DSL rate up", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, + translation_key="dsl_rate_up", value_fn=lambda x: x.rate_up, ), SFRBoxSensorEntityDescription[DslInfo]( key="line_status", - name="DSL line status", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -135,12 +134,11 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( "loss_of_signal_quality", "unknown", ], - translation_key="line_status", + translation_key="dsl_line_status", value_fn=lambda x: x.line_status.lower().replace(" ", "_"), ), SFRBoxSensorEntityDescription[DslInfo]( key="training", - name="DSL training", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -156,14 +154,13 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( "showtime", "unknown", ], - translation_key="training", + translation_key="dsl_training", value_fn=lambda x: x.training.lower().replace(" ", "_").replace(".", "_"), ), ) SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( SFRBoxSensorEntityDescription[SystemInfo]( key="net_infra", - name="Network infrastructure", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -178,27 +175,26 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( ), SFRBoxSensorEntityDescription[SystemInfo]( key="alimvoltage", - name="Voltage", device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + translation_key="voltage", value_fn=lambda x: x.alimvoltage, ), SFRBoxSensorEntityDescription[SystemInfo]( key="temperature", - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key="temperature", value_fn=lambda x: x.temperature / 1000, ), ) WAN_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[WanInfo], ...] = ( SFRBoxSensorEntityDescription[WanInfo]( key="mode", - name="WAN mode", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 2141abad87..cf74e9eb65 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -31,8 +31,40 @@ } }, "entity": { + "binary_sensor": { + "dsl_status": { + "name": "DSL status" + }, + "ftth_status": { + "name": "FTTH status" + }, + "wan_status": { + "name": "WAN status" + } + }, + "button": { + "reboot": { + "name": "[%key:component::button::entity_component::restart::name%]" + } + }, "sensor": { - "line_status": { + "dsl_attenuation_down": { + "name": "DSL attenuation down" + }, + "dsl_attenuation_up": { + "name": "DSL attenuation up" + }, + "dsl_crc": { + "name": "DSL CRC" + }, + "dsl_counter": { + "name": "DSL counter" + }, + "dsl_linemode": { + "name": "DSL line mode" + }, + "dsl_line_status": { + "name": "DSL line status", "state": { "no_defect": "No Defect", "of_frame": "Of Frame", @@ -42,15 +74,20 @@ "unknown": "Unknown" } }, - "net_infra": { - "state": { - "adsl": "ADSL", - "ftth": "FTTH", - "gprs": "GPRS", - "unknown": "Unknown" - } + "dsl_noise_down": { + "name": "DSL noise down" }, - "training": { + "dsl_noise_up": { + "name": "DSL noise up" + }, + "dsl_rate_down": { + "name": "DSL rate down" + }, + "dsl_rate_up": { + "name": "DSL rate up" + }, + "dsl_training": { + "name": "DSL training", "state": { "idle": "Idle", "g_994_training": "G.994 Training", @@ -64,7 +101,23 @@ "unknown": "Unknown" } }, + "net_infra": { + "name": "Network infrastructure", + "state": { + "adsl": "ADSL", + "ftth": "FTTH", + "gprs": "GPRS", + "unknown": "Unknown" + } + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "voltage": { + "name": "[%key:component::sensor::entity_component::voltage::name%]" + }, "wan_mode": { + "name": "WAN mode", "state": { "adsl_ppp": "ADSL (PPP)", "adsl_routed": "ADSL (Routed)", diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py index 0c4f7bb0bf..b6cae8ad60 100644 --- a/homeassistant/components/sharkiq/__init__.py +++ b/homeassistant/components/sharkiq/__init__.py @@ -13,11 +13,18 @@ from sharkiq import ( from homeassistant import exceptions from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import API_TIMEOUT, DOMAIN, LOGGER, PLATFORMS +from .const import ( + API_TIMEOUT, + DOMAIN, + LOGGER, + PLATFORMS, + SHARKIQ_REGION_DEFAULT, + SHARKIQ_REGION_EUROPE, +) from .update_coordinator import SharkIqUpdateCoordinator @@ -43,10 +50,17 @@ async def async_connect_or_timeout(ayla_api: AylaApi) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Initialize the sharkiq platform via config entry.""" + if CONF_REGION not in config_entry.data: + hass.config_entries.async_update_entry( + config_entry, + data={**config_entry.data, CONF_REGION: SHARKIQ_REGION_DEFAULT}, + ) + ayla_api = get_ayla_api( username=config_entry.data[CONF_USERNAME], password=config_entry.data[CONF_PASSWORD], websession=async_get_clientsession(hass), + europe=(config_entry.data[CONF_REGION] == SHARKIQ_REGION_EUROPE), ) try: diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py index b0aae5259d..4161a5f535 100644 --- a/homeassistant/components/sharkiq/config_flow.py +++ b/homeassistant/components/sharkiq/config_flow.py @@ -11,14 +11,31 @@ from sharkiq import SharkIqAuthError, get_ayla_api import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, LOGGER +from .const import ( + DOMAIN, + LOGGER, + SHARKIQ_REGION_DEFAULT, + SHARKIQ_REGION_EUROPE, + SHARKIQ_REGION_OPTIONS, +) SHARKIQ_SCHEMA = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required( + CONF_REGION, default=SHARKIQ_REGION_DEFAULT + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=SHARKIQ_REGION_OPTIONS, translation_key="region" + ), + ), + } ) @@ -30,16 +47,29 @@ async def _validate_input( username=data[CONF_USERNAME], password=data[CONF_PASSWORD], websession=async_get_clientsession(hass), + europe=(data[CONF_REGION] == SHARKIQ_REGION_EUROPE), ) try: async with async_timeout.timeout(10): LOGGER.debug("Initialize connection to Ayla networks API") await ayla_api.async_sign_in() - except (asyncio.TimeoutError, aiohttp.ClientError) as errors: - raise CannotConnect from errors + except (asyncio.TimeoutError, aiohttp.ClientError, TypeError) as error: + LOGGER.error(error) + raise CannotConnect( + "Unable to connect to SharkIQ services. Check your region settings." + ) from error except SharkIqAuthError as error: - raise InvalidAuth from error + LOGGER.error(error) + raise InvalidAuth( + "Username or password incorrect. Please check your credentials." + ) from error + except Exception as error: + LOGGER.exception("Unexpected exception") + LOGGER.error(error) + raise UnknownAuth( + "An unknown error occurred. Check your region settings and open an issue on Github if the issue persists." + ) from error # Return info that you want to store in the config entry. return {"title": data[CONF_USERNAME]} @@ -64,8 +94,7 @@ class SharkIqConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - LOGGER.exception("Unexpected exception") + except UnknownAuth: errors["base"] = "unknown" return info, errors @@ -114,3 +143,7 @@ class CannotConnect(exceptions.HomeAssistantError): class InvalidAuth(exceptions.HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class UnknownAuth(exceptions.HomeAssistantError): + """Error to indicate there is an uncaught auth error.""" diff --git a/homeassistant/components/sharkiq/const.py b/homeassistant/components/sharkiq/const.py index fb683bb525..b12a86dc24 100644 --- a/homeassistant/components/sharkiq/const.py +++ b/homeassistant/components/sharkiq/const.py @@ -11,3 +11,8 @@ PLATFORMS = [Platform.VACUUM] DOMAIN = "sharkiq" SHARK = "Shark" UPDATE_INTERVAL = timedelta(seconds=30) + +SHARKIQ_REGION_EUROPE = "europe" +SHARKIQ_REGION_ELSEWHERE = "elsewhere" +SHARKIQ_REGION_DEFAULT = SHARKIQ_REGION_ELSEWHERE +SHARKIQ_REGION_OPTIONS = [SHARKIQ_REGION_EUROPE, SHARKIQ_REGION_ELSEWHERE] diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json index bc920ac7c7..23f949be4c 100644 --- a/homeassistant/components/sharkiq/strings.json +++ b/homeassistant/components/sharkiq/strings.json @@ -1,16 +1,23 @@ { "config": { + "flow_title": "Add Shark IQ Account", "step": { "user": { + "description": "Sign into your Shark Clean account to control your devices.", "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "region": "Region" + }, + "data_description": { + "region": "Shark IQ uses different services in the EU. Select your region to connect to the correct service for your account." } }, "reauth": { "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "region": "Region" } } }, @@ -25,5 +32,13 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "selector": { + "region": { + "options": { + "europe": "Europe", + "elsewhere": "Everywhere Else" + } + } } } diff --git a/homeassistant/components/sharkiq/update_coordinator.py b/homeassistant/components/sharkiq/update_coordinator.py index 2afeb574f9..87f5aafe7a 100644 --- a/homeassistant/components/sharkiq/update_coordinator.py +++ b/homeassistant/components/sharkiq/update_coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from datetime import datetime, timedelta from async_timeout import timeout from sharkiq import ( @@ -60,6 +61,13 @@ class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): async def _async_update_data(self) -> bool: """Update data device by device.""" try: + if self.ayla_api.token_expiring_soon: + await self.ayla_api.async_refresh_auth() + elif datetime.now() > self.ayla_api.auth_expiration - timedelta( + seconds=600 + ): + await self.ayla_api.async_refresh_auth() + all_vacuums = await self.ayla_api.async_list_devices() self._online_dsns = { v["dsn"] @@ -78,7 +86,7 @@ class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): LOGGER.debug("Bad auth state. Attempting re-auth", exc_info=err) raise ConfigEntryAuthFailed from err except Exception as err: - LOGGER.exception("Unexpected error updating SharkIQ") + LOGGER.exception("Unexpected error updating SharkIQ. Attempting re-auth") raise UpdateFailed(err) from err return True diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index f2a43accb0..597ff95365 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -195,17 +195,10 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): if block.type == "device": cfg_changed = block.cfgChanged + # Shelly TRV sends information about changing the configuration for no + # reason, reloading the config entry is not needed for it. if self.model == "SHTRV-01": - # Reloading the entry is not needed when the target temperature changes - if "targetTemp" in block.sensor_ids: - if self._last_target_temp != block.targetTemp: - self._last_cfg_changed = None - self._last_target_temp = block.targetTemp - # Reloading the entry is not needed when the mode changes - if "mode" in block.sensor_ids: - if self._last_mode != block.mode: - self._last_cfg_changed = None - self._last_mode = block.mode + self._last_cfg_changed = None # For dual mode bulbs ignore change if it is due to mode/effect change if self.model in DUAL_MODE_LIGHT_MODELS: diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index de08592554..39a1427346 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==5.3.1"], + "requirements": ["aioshelly==5.3.2"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index bd08e19c4e..3dc26fe007 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -18,6 +18,8 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonArrayType, load_json_array from .const import ( + ATTR_REVERSE, + DEFAULT_REVERSE, DOMAIN, EVENT_SHOPPING_LIST_UPDATED, SERVICE_ADD_ITEM, @@ -27,6 +29,7 @@ from .const import ( SERVICE_INCOMPLETE_ALL, SERVICE_INCOMPLETE_ITEM, SERVICE_REMOVE_ITEM, + SERVICE_SORT, ) ATTR_COMPLETE = "complete" @@ -38,6 +41,9 @@ PERSISTENCE = ".shopping_list.json" SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) SERVICE_LIST_SCHEMA = vol.Schema({}) +SERVICE_SORT_SCHEMA = vol.Schema( + {vol.Optional(ATTR_REVERSE, default=DEFAULT_REVERSE): bool} +) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -111,6 +117,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Clear all completed items from the list.""" await data.async_clear_completed() + async def sort_list_service(call: ServiceCall) -> None: + """Sort all items by name.""" + await data.async_sort(call.data[ATTR_REVERSE]) + data = hass.data[DOMAIN] = ShoppingData(hass) await data.async_load() @@ -147,6 +157,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b clear_completed_items_service, schema=SERVICE_LIST_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_SORT, + sort_list_service, + schema=SERVICE_SORT_SCHEMA, + ) hass.http.register_view(ShoppingListView) hass.http.register_view(CreateShoppingListItemView) @@ -277,6 +293,16 @@ class ShoppingData: context=context, ) + async def async_sort(self, reverse=False, context=None): + """Sort items by name.""" + self.items = sorted(self.items, key=lambda item: item["name"], reverse=reverse) + self.hass.async_add_executor_job(self.save) + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "sorted"}, + context=context, + ) + async def async_load(self) -> None: """Load items.""" diff --git a/homeassistant/components/shopping_list/const.py b/homeassistant/components/shopping_list/const.py index 05dc05137c..c519123a41 100644 --- a/homeassistant/components/shopping_list/const.py +++ b/homeassistant/components/shopping_list/const.py @@ -2,6 +2,10 @@ DOMAIN = "shopping_list" EVENT_SHOPPING_LIST_UPDATED = "shopping_list_updated" +ATTR_REVERSE = "reverse" + +DEFAULT_REVERSE = False + SERVICE_ADD_ITEM = "add_item" SERVICE_REMOVE_ITEM = "remove_item" SERVICE_COMPLETE_ITEM = "complete_item" @@ -9,3 +13,4 @@ SERVICE_INCOMPLETE_ITEM = "incomplete_item" SERVICE_COMPLETE_ALL = "complete_all" SERVICE_INCOMPLETE_ALL = "incomplete_all" SERVICE_CLEAR_COMPLETED_ITEMS = "clear_completed_items" +SERVICE_SORT = "sort" diff --git a/homeassistant/components/shopping_list/services.yaml b/homeassistant/components/shopping_list/services.yaml index c41bc1333d..250912f49c 100644 --- a/homeassistant/components/shopping_list/services.yaml +++ b/homeassistant/components/shopping_list/services.yaml @@ -56,3 +56,14 @@ incomplete_all: clear_completed_items: name: Clear completed items description: Clear completed items from the shopping list. + +sort: + name: Sort all items + description: Sort all items by name in the shopping list. + fields: + reverse: + name: Sort reverse + description: Whether to sort in reverse (descending) order. + default: false + selector: + boolean: diff --git a/homeassistant/components/sia/__init__.py b/homeassistant/components/sia/__init__.py index 31ae36f793..befa2c5df9 100644 --- a/homeassistant/components/sia/__init__.py +++ b/homeassistant/components/sia/__init__.py @@ -16,7 +16,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = hub try: - await hub.sia_client.start(reuse_port=True) + if hub.sia_client: + await hub.sia_client.start(reuse_port=True) except OSError as exc: raise ConfigEntryNotReady( f"SIA Server at port {entry.data[CONF_PORT]} could not start." diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index 25d3f447a0..6a86ce8144 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -121,7 +121,9 @@ class SIAAlarmControlPanel(SIABaseEntity, AlarmControlPanelEntity): Return True if the event was relevant for this entity. """ - new_state = self.entity_description.code_consequences.get(sia_event.code) + new_state = None + if sia_event.code: + new_state = self.entity_description.code_consequences[sia_event.code] if new_state is None: return False _LOGGER.debug("New state will be %s", new_state) diff --git a/homeassistant/components/sia/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py index d060af133a..715fa26eee 100644 --- a/homeassistant/components/sia/binary_sensor.py +++ b/homeassistant/components/sia/binary_sensor.py @@ -130,7 +130,9 @@ class SIABinarySensor(SIABaseEntity, BinarySensorEntity): Return True if the event was relevant for this entity. """ - new_state = self.entity_description.code_consequences.get(sia_event.code) + new_state = None + if sia_event.code: + new_state = self.entity_description.code_consequences[sia_event.code] if new_state is None: return False _LOGGER.debug("New state will be %s", new_state) diff --git a/homeassistant/components/sia/hub.py b/homeassistant/components/sia/hub.py index 2c2fb0d2be..fb8d20e183 100644 --- a/homeassistant/components/sia/hub.py +++ b/homeassistant/components/sia/hub.py @@ -47,7 +47,7 @@ class SIAHub: self._accounts: list[dict[str, Any]] = deepcopy(entry.data[CONF_ACCOUNTS]) self._protocol: str = entry.data[CONF_PROTOCOL] self.sia_accounts: list[SIAAccount] | None = None - self.sia_client: SIAClient = None + self.sia_client: SIAClient | None = None @callback def async_setup_hub(self) -> None: @@ -70,7 +70,8 @@ class SIAHub: async def async_shutdown(self, _: Event | None = None) -> None: """Shutdown the SIA server.""" - await self.sia_client.stop() + if self.sia_client: + await self.sia_client.stop() async def async_create_and_fire_event(self, event: SIAEvent) -> None: """Create a event on HA dispatcher and then on HA's bus, with the data from the SIAEvent. @@ -108,12 +109,15 @@ class SIAHub: if self.sia_client is not None: self.sia_client.accounts = self.sia_accounts return - self.sia_client = SIAClient( - host="", - port=self._port, - accounts=self.sia_accounts, - function=self.async_create_and_fire_event, - protocol=CommunicationsProtocol(self._protocol), + # the new client class method creates a subclass based on protocol, hence the type ignore + self.sia_client = ( + SIAClient( # pylint: disable=abstract-class-instantiated # type: ignore + host="", + port=self._port, + accounts=self.sia_accounts, + function=self.async_create_and_fire_event, + protocol=CommunicationsProtocol(self._protocol), + ) ) def _load_options(self) -> None: diff --git a/homeassistant/components/sia/manifest.json b/homeassistant/components/sia/manifest.json index 299b2b63bc..8029aa24ce 100644 --- a/homeassistant/components/sia/manifest.json +++ b/homeassistant/components/sia/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sia", "iot_class": "local_push", "loggers": ["pysiaalarm"], - "requirements": ["pysiaalarm==3.0.2"] + "requirements": ["pysiaalarm==3.1.1"] } diff --git a/homeassistant/components/sia/sia_entity_base.py b/homeassistant/components/sia/sia_entity_base.py index 439e67a5eb..7ca48bdc46 100644 --- a/homeassistant/components/sia/sia_entity_base.py +++ b/homeassistant/components/sia/sia_entity_base.py @@ -126,7 +126,7 @@ class SIABaseEntity(RestoreEntity): then update the availability and schedule the next unavailability check. """ _LOGGER.debug("Received event: %s", sia_event) - if int(sia_event.ri) not in (self.zone, SIA_HUB_ZONE): + if (int(sia_event.ri) if sia_event.ri else 0) not in (self.zone, SIA_HUB_ZONE): return relevant_event = self.update_state(sia_event) diff --git a/homeassistant/components/sia/utils.py b/homeassistant/components/sia/utils.py index cf52122a49..e9db69041d 100644 --- a/homeassistant/components/sia/utils.py +++ b/homeassistant/components/sia/utils.py @@ -1,10 +1,11 @@ """Helper functions for the SIA integration.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any from pysiaalarm import SIAEvent +from pysiaalarm.utils import MessageTypes from homeassistant.util.dt import utcnow @@ -50,21 +51,24 @@ def get_unavailability_interval(ping: int) -> float: def get_attr_from_sia_event(event: SIAEvent) -> dict[str, Any]: """Create the attributes dict from a SIAEvent.""" + timestamp = event.timestamp if event.timestamp else utcnow() return { ATTR_ZONE: event.ri, ATTR_CODE: event.code, ATTR_MESSAGE: event.message, ATTR_ID: event.id, - ATTR_TIMESTAMP: event.timestamp.isoformat() - if event.timestamp - else utcnow().isoformat(), + ATTR_TIMESTAMP: timestamp.isoformat() + if isinstance(timestamp, datetime) + else timestamp, } def get_event_data_from_sia_event(event: SIAEvent) -> dict[str, Any]: """Create a dict from the SIA Event for the HA Event.""" return { - "message_type": event.message_type.value, + "message_type": event.message_type.value + if isinstance(event.message_type, MessageTypes) + else event.message_type, "receiver": event.receiver, "line": event.line, "account": event.account, @@ -77,8 +81,8 @@ def get_event_data_from_sia_event(event: SIAEvent) -> dict[str, Any]: "message": event.message, "x_data": event.x_data, "timestamp": event.timestamp.isoformat() - if event.timestamp - else utcnow().isoformat(), + if isinstance(event.timestamp, datetime) + else event.timestamp, "event_qualifier": event.event_qualifier, "event_type": event.event_type, "partition": event.partition, diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index a4f024a8c0..1b6fbe9548 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/sighthound", "iot_class": "cloud_polling", "loggers": ["simplehound"], - "requirements": ["pillow==9.4.0", "simplehound==0.3"] + "requirements": ["pillow==9.5.0", "simplehound==0.3"] } diff --git a/homeassistant/components/simplepush/const.py b/homeassistant/components/simplepush/const.py index 6195a5fd1d..101e7cb35f 100644 --- a/homeassistant/components/simplepush/const.py +++ b/homeassistant/components/simplepush/const.py @@ -6,6 +6,7 @@ DOMAIN: Final = "simplepush" DEFAULT_NAME: Final = "simplepush" DATA_HASS_CONFIG: Final = "simplepush_hass_config" +ATTR_ATTACHMENTS: Final = "attachments" ATTR_ENCRYPTED: Final = "encrypted" ATTR_EVENT: Final = "event" diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index b1c2eb5680..3e7fad8863 100644 --- a/homeassistant/components/simplepush/notify.py +++ b/homeassistant/components/simplepush/notify.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN +from .const import ATTR_ATTACHMENTS, ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN # Configuring Simplepush under the notify has been removed in 2022.9.0 PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA @@ -61,11 +61,34 @@ class SimplePushNotificationService(BaseNotificationService): """Send a message to a Simplepush user.""" title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + attachments = None # event can now be passed in the service data event = None if data := kwargs.get(ATTR_DATA): event = data.get(ATTR_EVENT) + attachments_data = data.get(ATTR_ATTACHMENTS) + if isinstance(attachments_data, list): + attachments = [] + for attachment in attachments_data: + if not ( + isinstance(attachment, dict) + and ( + "image" in attachment + or "video" in attachment + or ("video" in attachment and "thumbnail" in attachment) + ) + ): + _LOGGER.error("Attachment format is incorrect") + return + + if "video" in attachment and "thumbnail" in attachment: + attachments.append(attachment) + elif "video" in attachment: + attachments.append(attachment["video"]) + elif "image" in attachment: + attachments.append(attachment["image"]) + # use event from config until YAML config is removed event = event or self._event @@ -77,10 +100,17 @@ class SimplePushNotificationService(BaseNotificationService): salt=self._salt, title=title, message=message, + attachments=attachments, event=event, ) else: - send(key=self._device_key, title=title, message=message, event=event) + send( + key=self._device_key, + title=title, + message=message, + attachments=attachments, + event=event, + ) except BadRequest: _LOGGER.error("Bad request. Title or message are too long") diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index 6849c9648d..6b26eb7854 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -1,7 +1,7 @@ """Support for SimpliSafe binary sensors.""" from __future__ import annotations -from simplipy.device import DeviceTypes +from simplipy.device import DeviceTypes, DeviceV3 from simplipy.device.sensor.v3 import SensorV3 from simplipy.system.v3 import SystemV3 @@ -67,6 +67,9 @@ async def async_setup_entry( if sensor.type in SUPPORTED_BATTERY_SENSOR_TYPES: sensors.append(BatteryBinarySensor(simplisafe, system, sensor)) + for lock in system.locks.values(): + sensors.append(BatteryBinarySensor(simplisafe, system, lock)) + async_add_entities(sensors) @@ -99,14 +102,14 @@ class BatteryBinarySensor(SimpliSafeEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( - self, simplisafe: SimpliSafe, system: SystemV3, sensor: SensorV3 + self, simplisafe: SimpliSafe, system: SystemV3, device: DeviceV3 ) -> None: """Initialize.""" - super().__init__(simplisafe, system, device=sensor) + super().__init__(simplisafe, system, device=device) self._attr_name = "Battery" self._attr_unique_id = f"{super().unique_id}-battery" - self._device: SensorV3 + self._device: DeviceV3 @callback def async_update_from_rest_api(self) -> None: diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 9a5a391240..184e1f306e 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["simplipy"], - "requirements": ["simplisafe-python==2022.12.0"] + "requirements": ["simplisafe-python==2023.04.0"] } diff --git a/homeassistant/components/simulated/sensor.py b/homeassistant/components/simulated/sensor.py index f2e64655ac..0f9db48e78 100644 --- a/homeassistant/components/simulated/sensor.py +++ b/homeassistant/components/simulated/sensor.py @@ -34,8 +34,6 @@ DEFAULT_SEED = 999 DEFAULT_UNIT = "value" DEFAULT_RELATIVE_TO_EPOCH = True -ICON = "mdi:chart-line" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_AMP, default=DEFAULT_AMP): vol.Coerce(float), @@ -79,6 +77,8 @@ def setup_platform( class SimulatedSensor(SensorEntity): """Class for simulated sensor.""" + _attr_icon = "mdi:chart-line" + def __init__( self, name, unit, amp, mean, period, phase, fwhm, seed, relative_to_epoch ): @@ -135,11 +135,6 @@ class SimulatedSensor(SensorEntity): """Return the state of the sensor.""" return self._state - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index b77c249dd2..8b6deaa3c7 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/sleepiq", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.2.3"] + "requirements": ["asyncsleepiq==1.3.4"] } diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py index 1e929a5e64..1609dc2e11 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -1,7 +1,7 @@ """Support for SleepIQ foundation preset selection.""" from __future__ import annotations -from asyncsleepiq import BED_PRESETS, Side, SleepIQBed, SleepIQPreset +from asyncsleepiq import Side, SleepIQBed, SleepIQPreset from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry @@ -30,8 +30,6 @@ async def async_setup_entry( class SleepIQSelectEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], SelectEntity): """Representation of a SleepIQ select entity.""" - _attr_options = list(BED_PRESETS) - def __init__( self, coordinator: SleepIQDataUpdateCoordinator, @@ -46,6 +44,7 @@ class SleepIQSelectEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], Select if preset.side != Side.NONE: self._attr_name += f" {preset.side_full}" self._attr_unique_id += f"_{preset.side.value}" + self._attr_options = preset.options super().__init__(coordinator, bed) self._async_update_attrs() diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py index 597ed50f42..641d3b8ae4 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -195,7 +195,9 @@ class SlimProtoPlayer(MediaPlayerEntity): await self.player.play_url(media_id, mime_type=to_send_media_type) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index b179daaf1a..828e4a6812 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -10,7 +10,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN SWITCH_PREFIX = "Switch" -ICON = "mdi:toggle-switch" async def async_setup_entry( @@ -55,6 +54,8 @@ async def async_setup_entry( class SmappeeActuator(SwitchEntity): """Representation of a Smappee Comport Plug.""" + _attr_icon = "mdi:toggle-switch" + def __init__( self, smappee_base, @@ -105,11 +106,6 @@ class SmappeeActuator(SwitchEntity): # Switch or comfort plug return self._state == "ON_ON" - @property - def icon(self): - """Icon to use in the frontend.""" - return ICON - def turn_on(self, **kwargs: Any) -> None: """Turn on Comport Plug.""" if self._actuator_type in ("SWITCH", "COMFORT_PLUG"): diff --git a/homeassistant/components/sms/sensor.py b/homeassistant/components/sms/sensor.py index d72bda048b..cfa31d56e8 100644 --- a/homeassistant/components/sms/sensor.py +++ b/homeassistant/components/sms/sensor.py @@ -17,7 +17,7 @@ from .const import DOMAIN, GATEWAY, NETWORK_COORDINATOR, SIGNAL_COORDINATOR, SMS SIGNAL_SENSORS = ( SensorEntityDescription( key="SignalStrength", - name="Signal Strength", + translation_key="signal_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -27,14 +27,14 @@ SIGNAL_SENSORS = ( SensorEntityDescription( key="SignalPercent", icon="mdi:signal-cellular-3", - name="Signal Percent", + translation_key="signal_percent", native_unit_of_measurement=PERCENTAGE, entity_registry_enabled_default=True, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="BitErrorRate", - name="Bit Error Rate", + translation_key="bit_error_rate", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, entity_registry_enabled_default=False, @@ -45,31 +45,31 @@ SIGNAL_SENSORS = ( NETWORK_SENSORS = ( SensorEntityDescription( key="NetworkName", - name="Network Name", + translation_key="network_name", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), SensorEntityDescription( key="State", - name="Network Status", + translation_key="state", entity_registry_enabled_default=True, ), SensorEntityDescription( key="NetworkCode", - name="GSM network code", + translation_key="network_code", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), SensorEntityDescription( key="CID", - name="Cell ID", + translation_key="cid", icon="mdi:radio-tower", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), SensorEntityDescription( key="LAC", - name="Local Area Code", + translation_key="lac", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -102,6 +102,8 @@ async def async_setup_entry( class DeviceSensor(CoordinatorEntity, SensorEntity): """Implementation of a device sensor.""" + _attr_has_entity_name = True + def __init__(self, coordinator, description, unique_id, gateway): """Initialize the device sensor.""" super().__init__(coordinator) diff --git a/homeassistant/components/sms/strings.json b/homeassistant/components/sms/strings.json index b4a9279845..6bf8cbcc16 100644 --- a/homeassistant/components/sms/strings.json +++ b/homeassistant/components/sms/strings.json @@ -17,5 +17,19 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "entity": { + "sensor": { + "bit_error_rate": { "name": "Bit error rate" }, + "cid": { "name": "Cell ID" }, + "lac": { "name": "Local area code" }, + "network_code": { "name": "GSM network code" }, + "network_name": { "name": "Network name" }, + "signal_percent": { "name": "Signal percent" }, + "signal_strength": { + "name": "[%key:component::sensor::entity_component::signal_strength::name%]" + }, + "state": { "name": "Network status" } + } } } diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index b5279fa3ce..309669a849 100644 --- a/homeassistant/components/snapcast/__init__.py +++ b/homeassistant/components/snapcast/__init__.py @@ -1 +1,41 @@ -"""The snapcast component.""" +"""Snapcast Integration.""" +import logging + +import snapcast.control + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN, PLATFORMS +from .server import HomeAssistantSnapcast + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Snapcast from a config entry.""" + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + try: + server = await snapcast.control.create_server( + hass.loop, host, port, reconnect=True + ) + except OSError as ex: + raise ConfigEntryNotReady( + f"Could not connect to Snapcast server at {host}:{port}" + ) from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast(server) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/snapcast/config_flow.py b/homeassistant/components/snapcast/config_flow.py new file mode 100644 index 0000000000..896d3f8b5a --- /dev/null +++ b/homeassistant/components/snapcast/config_flow.py @@ -0,0 +1,63 @@ +"""Snapcast config flow.""" + +from __future__ import annotations + +import logging +import socket + +import snapcast.control +from snapcast.control.server import CONTROL_PORT +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_TITLE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SNAPCAST_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=CONTROL_PORT): int, + } +) + + +class SnapcastConfigFlow(ConfigFlow, domain=DOMAIN): + """Snapcast config flow.""" + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle first step.""" + errors = {} + if user_input: + self._async_abort_entries_match(user_input) + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + + # Attempt to create the server - make sure it's going to work + try: + client = await snapcast.control.create_server( + self.hass.loop, host, port, reconnect=False + ) + except socket.gaierror: + errors["base"] = "invalid_host" + except OSError: + errors["base"] = "cannot_connect" + else: + await client.stop() + return self.async_create_entry(title=DEFAULT_TITLE, data=user_input) + return self.async_show_form( + step_id="user", data_schema=SNAPCAST_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_config: dict[str, str]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + self._async_abort_entries_match( + { + CONF_HOST: (import_config[CONF_HOST]), + CONF_PORT: (import_config[CONF_PORT]), + } + ) + return self.async_create_entry(title=DEFAULT_TITLE, data=import_config) diff --git a/homeassistant/components/snapcast/const.py b/homeassistant/components/snapcast/const.py index 674a22993b..ded57e6fb0 100644 --- a/homeassistant/components/snapcast/const.py +++ b/homeassistant/components/snapcast/const.py @@ -1,6 +1,7 @@ """Constants for Snapcast.""" +from homeassistant.const import Platform -DATA_KEY = "snapcast" +PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] GROUP_PREFIX = "snapcast_group_" GROUP_SUFFIX = "Snapcast Group" @@ -15,3 +16,6 @@ SERVICE_SET_LATENCY = "set_latency" ATTR_MASTER = "master" ATTR_LATENCY = "latency" + +DOMAIN = "snapcast" +DEFAULT_TITLE = "Snapcast" diff --git a/homeassistant/components/snapcast/manifest.json b/homeassistant/components/snapcast/manifest.json index bdcadc84e7..8701fca0ad 100644 --- a/homeassistant/components/snapcast/manifest.json +++ b/homeassistant/components/snapcast/manifest.json @@ -2,6 +2,7 @@ "domain": "snapcast", "name": "Snapcast", "codeowners": ["@luar123"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/snapcast", "iot_class": "local_polling", "loggers": ["construct", "snapcast"], diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 9e0e10ac0e..4fd7c587d4 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -2,9 +2,7 @@ from __future__ import annotations import logging -import socket -import snapcast.control from snapcast.control.server import CONTROL_PORT import voluptuous as vol @@ -14,10 +12,12 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -25,7 +25,7 @@ from .const import ( ATTR_MASTER, CLIENT_PREFIX, CLIENT_SUFFIX, - DATA_KEY, + DOMAIN, GROUP_PREFIX, GROUP_SUFFIX, SERVICE_JOIN, @@ -34,6 +34,7 @@ from .const import ( SERVICE_SNAPSHOT, SERVICE_UNJOIN, ) +from .server import HomeAssistantSnapcast _LOGGER = logging.getLogger(__name__) @@ -41,19 +42,17 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT): cv.port} ) +STREAM_STATUS = { + "idle": MediaPlayerState.IDLE, + "playing": MediaPlayerState.PLAYING, + "unknown": None, +} -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Snapcast platform.""" - - host = config.get(CONF_HOST) - port = config.get(CONF_PORT, CONTROL_PORT) +def register_services(): + """Register snapcast services.""" platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service(SERVICE_SNAPSHOT, {}, "snapshot") platform.async_register_entity_service(SERVICE_RESTORE, {}, "async_restore") platform.async_register_entity_service( @@ -66,23 +65,55 @@ async def async_setup_platform( handle_set_latency, ) - try: - server = await snapcast.control.create_server( - hass.loop, host, port, reconnect=True - ) - except socket.gaierror: - _LOGGER.error("Could not connect to Snapcast server at %s:%d", host, port) - return - # Note: Host part is needed, when using multiple snapservers +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the snapcast config entry.""" + snapcast_data: HomeAssistantSnapcast = hass.data[DOMAIN][config_entry.entry_id] + + register_services() + + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] hpid = f"{host}:{port}" - devices: list[MediaPlayerEntity] = [ - SnapcastGroupDevice(group, hpid) for group in server.groups + snapcast_data.groups = [ + SnapcastGroupDevice(group, hpid) for group in snapcast_data.server.groups ] - devices.extend(SnapcastClientDevice(client, hpid) for client in server.clients) - hass.data[DATA_KEY] = devices - async_add_entities(devices) + snapcast_data.clients = [ + SnapcastClientDevice(client, hpid, config_entry.entry_id) + for client in snapcast_data.server.clients + ] + async_add_entities(snapcast_data.clients + snapcast_data.groups) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Snapcast platform.""" + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + config[CONF_PORT] = config.get(CONF_PORT, CONTROL_PORT) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) async def handle_async_join(entity, service_call): @@ -132,11 +163,9 @@ class SnapcastGroupDevice(MediaPlayerEntity): @property def state(self) -> MediaPlayerState | None: """Return the state of the player.""" - return { - "idle": MediaPlayerState.IDLE, - "playing": MediaPlayerState.PLAYING, - "unknown": None, - }.get(self._group.stream_status) + if self.is_volume_muted: + return MediaPlayerState.IDLE + return STREAM_STATUS.get(self._group.stream_status) @property def unique_id(self): @@ -211,10 +240,11 @@ class SnapcastClientDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, client, uid_part): + def __init__(self, client, uid_part, entry_id): """Initialize the Snapcast client device.""" self._client = client self._uid = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}" + self._entry_id = entry_id async def async_added_to_hass(self) -> None: """Subscribe to client events.""" @@ -263,11 +293,13 @@ class SnapcastClientDevice(MediaPlayerEntity): return list(self._client.group.streams_by_name().keys()) @property - def state(self) -> MediaPlayerState: + def state(self) -> MediaPlayerState | None: """Return the state of the player.""" if self._client.connected: - return MediaPlayerState.ON - return MediaPlayerState.OFF + if self.is_volume_muted or self._client.group.muted: + return MediaPlayerState.IDLE + return STREAM_STATUS.get(self._client.group.stream_status) + return MediaPlayerState.STANDBY @property def extra_state_attributes(self): @@ -303,9 +335,10 @@ class SnapcastClientDevice(MediaPlayerEntity): async def async_join(self, master): """Join the group of the master player.""" - master_entity = next( - entity for entity in self.hass.data[DATA_KEY] if entity.entity_id == master + entity + for entity in self.hass.data[DOMAIN][self._entry_id].clients + if entity.entity_id == master ) if not isinstance(master_entity, SnapcastClientDevice): raise TypeError("Master is not a client device. Can only join clients.") diff --git a/homeassistant/components/snapcast/server.py b/homeassistant/components/snapcast/server.py new file mode 100644 index 0000000000..507ad6393a --- /dev/null +++ b/homeassistant/components/snapcast/server.py @@ -0,0 +1,15 @@ +"""Snapcast Integration.""" +from dataclasses import dataclass, field + +from snapcast.control import Snapserver + +from homeassistant.components.media_player import MediaPlayerEntity + + +@dataclass +class HomeAssistantSnapcast: + """Snapcast data stored in the Home Assistant data object.""" + + server: Snapserver + clients: list[MediaPlayerEntity] = field(default_factory=list) + groups: list[MediaPlayerEntity] = field(default_factory=list) diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json new file mode 100644 index 0000000000..0087b70d82 --- /dev/null +++ b/homeassistant/components/snapcast/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "description": "Please enter your server connection details", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "title": "Connect" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Snapcast YAML configuration is being removed", + "description": "Configuring Snapcast using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Snapcast YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/snips/__init__.py b/homeassistant/components/snips/__init__.py index d4619fa3b3..01471b13bc 100644 --- a/homeassistant/components/snips/__init__.py +++ b/homeassistant/components/snips/__init__.py @@ -90,14 +90,6 @@ SERVICE_SCHEMA_FEEDBACK = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Activate Snips component.""" - # Make sure MQTT is available and the entry is loaded - if not hass.config_entries.async_entries( - mqtt.DOMAIN - ) or not await hass.config_entries.async_wait_component( - hass.config_entries.async_entries(mqtt.DOMAIN)[0] - ): - _LOGGER.error("MQTT integration is not available") - return False async def async_set_feedback(site_ids, state): """Set Feedback sound state.""" diff --git a/homeassistant/components/sonarr/manifest.json b/homeassistant/components/sonarr/manifest.json index 19531b08de..bfc2b6f787 100644 --- a/homeassistant/components/sonarr/manifest.json +++ b/homeassistant/components/sonarr/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["aiopyarr"], "quality_scale": "silver", - "requirements": ["aiopyarr==22.11.0"] + "requirements": ["aiopyarr==23.4.0"] } diff --git a/homeassistant/components/songpal/manifest.json b/homeassistant/components/songpal/manifest.json index d41cc2ad58..aa1157e8d0 100644 --- a/homeassistant/components/songpal/manifest.json +++ b/homeassistant/components/songpal/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "loggers": ["songpal"], "quality_scale": "gold", - "requirements": ["python-songpal==0.15.1"], + "requirements": ["python-songpal==0.15.2"], "ssdp": [ { "st": "urn:schemas-sony-com:service:ScalarWebAPI:1", diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index e181e995c7..ea0a16229c 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -26,7 +26,11 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + issue_registry as ir, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType @@ -43,11 +47,14 @@ from .const import ( SONOS_REBOOTED, SONOS_SPEAKER_ACTIVITY, SONOS_VANISHED, + SUB_FAIL_ISSUE_ID, + SUB_FAIL_URL, SUBSCRIPTION_TIMEOUT, UPNP_ST, ) from .exception import SonosUpdateError from .favorites import SonosFavorites +from .helpers import sync_get_visible_zones from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -177,6 +184,7 @@ class SonosDiscoveryManager: self.entry = entry self.data = data self.hosts = set(hosts) + self.hosts_in_error: dict[str, bool] = {} self.discovery_lock = asyncio.Lock() self.creation_lock = asyncio.Lock() self._known_invisible: set[SoCo] = set() @@ -225,6 +233,24 @@ class SonosDiscoveryManager: async def async_subscription_failed(now: datetime.datetime) -> None: """Fallback logic if the subscription callback never arrives.""" + addr, port = sub.event_listener.address + listener_address = f"{addr}:{port}" + if advertise_ip := soco_config.EVENT_ADVERTISE_IP: + listener_address += f" (advertising as {advertise_ip})" + ir.async_create_issue( + self.hass, + DOMAIN, + SUB_FAIL_ISSUE_ID, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="subscriptions_failed", + translation_placeholders={ + "device_ip": ip_address, + "listener_address": listener_address, + "sub_fail_url": SUB_FAIL_URL, + }, + ) + _LOGGER.warning( "Subscription to %s failed, attempting to poll directly", ip_address ) @@ -254,6 +280,11 @@ class SonosDiscoveryManager: """Create SonosSpeakers when subscription callbacks successfully arrive.""" _LOGGER.debug("Subscription to %s succeeded", ip_address) cancel_failure_callback() + ir.async_delete_issue( + self.hass, + DOMAIN, + SUB_FAIL_ISSUE_ID, + ) _async_add_visible_zones(subscription_succeeded=True) sub.callback = _async_subscription_succeeded @@ -337,26 +368,28 @@ class SonosDiscoveryManager: self, now: datetime.datetime | None = None ) -> None: """Add and maintain Sonos devices from a manual configuration.""" - - def get_sync_attributes(soco: SoCo) -> set[SoCo]: - """Ensure I/O attributes are cached and return visible zones.""" - _ = soco.household_id - _ = soco.uid - return soco.visible_zones - for host in self.hosts: - ip_addr = socket.gethostbyname(host) + ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host) soco = SoCo(ip_addr) try: visible_zones = await self.hass.async_add_executor_job( - get_sync_attributes, + sync_get_visible_zones, soco, ) except (OSError, SoCoException, Timeout) as ex: - _LOGGER.warning( - "Could not get visible Sonos devices from %s: %s", ip_addr, ex - ) + if not self.hosts_in_error.get(ip_addr): + _LOGGER.warning( + "Could not get visible Sonos devices from %s: %s", ip_addr, ex + ) + self.hosts_in_error[ip_addr] = True + else: + _LOGGER.debug( + "Could not get visible Sonos devices from %s: %s", ip_addr, ex + ) + else: + if self.hosts_in_error.pop(ip_addr, None): + _LOGGER.info("Connection restablished to Sonos device %s", ip_addr) if new_hosts := { x.ip_address for x in visible_zones @@ -372,7 +405,7 @@ class SonosDiscoveryManager: break for host in self.hosts.copy(): - ip_addr = socket.gethostbyname(host) + ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host) if self.is_device_invisible(ip_addr): _LOGGER.debug("Discarding %s from manual hosts", ip_addr) self.hosts.discard(ip_addr) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 9476b361ae..e42fb7d67c 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -19,6 +19,9 @@ PLATFORMS = [ Platform.SWITCH, ] +SUB_FAIL_ISSUE_ID = "subscriptions_failed" +SUB_FAIL_URL = "https://www.home-assistant.io/integrations/sonos/#network-requirements" + SONOS_ARTIST = "artists" SONOS_ALBUM = "albums" SONOS_PLAYLISTS = "playlists" diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index ac7b96ec96..0b51687a46 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -5,10 +5,8 @@ from abc import abstractmethod import datetime import logging -import soco.config as soco_config from soco.core import SoCo -from homeassistant.components import persistent_notification import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity @@ -17,8 +15,6 @@ from .const import DATA_SONOS, DOMAIN, SONOS_FALLBACK_POLL, SONOS_STATE_UPDATED from .exception import SonosUpdateError from .speaker import SonosSpeaker -SUB_FAIL_URL = "https://www.home-assistant.io/integrations/sonos/#network-requirements" - _LOGGER = logging.getLogger(__name__) @@ -57,29 +53,6 @@ class SonosEntity(Entity): async def async_fallback_poll(self, now: datetime.datetime) -> None: """Poll the entity if subscriptions fail.""" if not self.speaker.subscriptions_failed: - if soco_config.EVENT_ADVERTISE_IP: - listener_msg = ( - f"{self.speaker.subscription_address}" - f" (advertising as {soco_config.EVENT_ADVERTISE_IP})" - ) - else: - listener_msg = self.speaker.subscription_address - message = ( - f"{self.speaker.zone_name} cannot reach {listener_msg}," - " falling back to polling, functionality may be limited" - ) - log_link_msg = f", see {SUB_FAIL_URL} for more details" - notification_link_msg = ( - f'.\n\nSee Sonos documentation' - " for more details." - ) - _LOGGER.warning(message + log_link_msg) - persistent_notification.async_create( - self.hass, - message + notification_link_msg, - "Sonos networking issue", - "sonos_subscriptions_failed", - ) self.speaker.subscriptions_failed = True await self.speaker.async_unsubscribe() try: diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 5f44b9bae6..1005b6c7d6 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -117,3 +117,10 @@ def hostname_to_uid(hostname: str) -> str: else: raise ValueError(f"{hostname} is not a sonos device.") return f"{UID_PREFIX}{baseuid}{UID_POSTFIX}" + + +def sync_get_visible_zones(soco: SoCo) -> set[SoCo]: + """Ensure I/O attributes are cached and return visible zones.""" + _ = soco.household_id + _ = soco.uid + return soco.visible_zones diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index e1b3c6c113..9c6f93fc2a 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.29.1"], + "requirements": ["soco==0.29.1", "sonos-websocket==0.0.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index fbd74e5774..7ef103e4d0 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -1,8 +1,8 @@ """Support to interface with Sonos players.""" from __future__ import annotations -from asyncio import run_coroutine_threadsafe import datetime +from functools import partial import logging from typing import Any @@ -14,11 +14,13 @@ from soco.core import ( PLAY_MODES, ) from soco.data_structures import DidlFavorite +from sonos_websocket.exception import SonosWebsocketError import voluptuous as vol from homeassistant.components import media_source, spotify from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, + ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_ENQUEUE, BrowseMedia, MediaPlayerDeviceClass, @@ -491,8 +493,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Clear players playlist.""" self.coordinator.soco.clear_queue() - @soco_error() - def play_media( # noqa: C901 + async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the play_media command to the media player. @@ -505,8 +506,21 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): If media_type is "playlist", media_id should be a Sonos Playlist name. Otherwise, media_id should be a URI. """ - # Use 'replace' as the default enqueue option - enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE) + if kwargs.get(ATTR_MEDIA_ANNOUNCE): + volume = kwargs.get("extra", {}).get("volume") + _LOGGER.debug("Playing %s using websocket audioclip", media_id) + try: + assert self.speaker.websocket + response, _ = await self.speaker.websocket.play_clip( + media_id, + volume=volume, + ) + except SonosWebsocketError as exc: + raise HomeAssistantError( + f"Error when calling Sonos websocket: {exc}" + ) from exc + if response["success"]: + return if spotify.is_spotify_media_type(media_type): media_type = spotify.resolve_spotify_media_type(media_type) @@ -517,16 +531,21 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if media_source.is_media_source_id(media_id): is_radio = media_id.startswith("media-source://radio_browser/") media_type = MediaType.MUSIC - media_id = ( - run_coroutine_threadsafe( - media_source.async_resolve_media( - self.hass, media_id, self.entity_id - ), - self.hass.loop, - ) - .result() - .url + media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id ) + media_id = media.url + + await self.hass.async_add_executor_job( + partial(self._play_media, media_type, media_id, is_radio, **kwargs) + ) + + @soco_error() + def _play_media( + self, media_type: MediaType | str, media_id: str, is_radio: bool, **kwargs: Any + ) -> None: + """Wrap sync calls to async_play_media.""" + enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE) if media_type == "favorite_item_id": favorite = self.speaker.favorites.lookup_by_item_id(media_id) @@ -691,7 +710,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: @@ -712,7 +731,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): return (None, None) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_browser.async_browse_media( diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index dc0958f619..8a9b8e9af7 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -19,6 +19,7 @@ from .speaker import SonosSpeaker LEVEL_TYPES = { "audio_delay": (0, 5), "bass": (-10, 10), + "balance": (-100, 100), "treble": (-10, 10), "sub_gain": (-15, 15), "surround_level": (-15, 15), @@ -30,6 +31,40 @@ SocoFeatures = list[tuple[str, tuple[int, int]]] _LOGGER = logging.getLogger(__name__) +def _balance_to_number(state: tuple[int, int]) -> float: + """Represent a balance measure returned by SoCo as a number. + + SoCo returns a pair of volumes, one for the left side and one + for the right side. When the two are equal, sound is centered; + HA will show that as 0. When the left side is louder, HA will + show a negative value, and a positive value means the right + side is louder. Maximum absolute value is 100, which means only + one side produces sound at all. + """ + left, right = state + return (right - left) * 100 // max(right, left) + + +def _balance_from_number(value: float) -> tuple[int, int]: + """Convert a balance value from -100 to 100 into SoCo format. + + 0 becomes (100, 100), fully enabling both sides. Note that + the master volume control is separate, so this does not + turn up the speakers to maximum volume. Negative values + reduce the volume of the right side, and positive values + reduce the volume of the left side. -100 becomes (100, 0), + fully muting the right side, and +100 becomes (0, 100), + muting the left side. + """ + left = min(100, 100 - int(value)) + right = min(100, int(value) + 100) + return left, right + + +LEVEL_TO_NUMBER = {"balance": _balance_to_number} +LEVEL_FROM_NUMBER = {"balance": _balance_from_number} + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -92,9 +127,11 @@ class SonosLevelEntity(SonosEntity, NumberEntity): @soco_error() def set_native_value(self, value: float) -> None: """Set a new value.""" - setattr(self.soco, self.level_type, value) + from_number = LEVEL_FROM_NUMBER.get(self.level_type, int) + setattr(self.soco, self.level_type, from_number(value)) @property def native_value(self) -> float: """Return the current value.""" - return cast(float, getattr(self.speaker, self.level_type)) + to_number = LEVEL_TO_NUMBER.get(self.level_type, int) + return cast(float, to_number(getattr(self.speaker, self.level_type))) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f97d134c9c..e576d3f790 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -18,12 +18,14 @@ from soco.exceptions import SoCoException, SoCoUPnPException from soco.plugins.plex import PlexPlugin from soco.plugins.sharelink import ShareLinkPlugin from soco.snapshot import Snapshot +from sonos_websocket import SonosWebsocket from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -97,6 +99,7 @@ class SonosSpeaker: """Initialize a SonosSpeaker.""" self.hass = hass self.soco = soco + self.websocket: SonosWebsocket | None = None self.household_id: str = soco.household_id self.media = SonosMedia(hass, soco) self._plex_plugin: PlexPlugin | None = None @@ -142,6 +145,7 @@ class SonosSpeaker: self.volume: int | None = None self.muted: bool | None = None self.cross_fade: bool | None = None + self.balance: tuple[int, int] | None = None self.bass: int | None = None self.treble: int | None = None self.loudness: bool | None = None @@ -170,8 +174,13 @@ class SonosSpeaker: self.snapshot_group: list[SonosSpeaker] = [] self._group_members_missing: set[str] = set() - async def async_setup_dispatchers(self, entry: ConfigEntry) -> None: - """Connect dispatchers in async context during setup.""" + async def async_setup(self, entry: ConfigEntry) -> None: + """Complete setup in async context.""" + self.websocket = SonosWebsocket( + self.soco.ip_address, + player_id=self.soco.uid, + session=async_get_clientsession(self.hass), + ) dispatch_pairs: tuple[tuple[str, Callable[..., Any]], ...] = ( (SONOS_CHECK_ACTIVITY, self.async_check_activity), (SONOS_SPEAKER_ADDED, self.update_group_for_uid), @@ -198,7 +207,7 @@ class SonosSpeaker: self.media.poll_media() future = asyncio.run_coroutine_threadsafe( - self.async_setup_dispatchers(entry), self.hass.loop + self.async_setup(entry), self.hass.loop ) future.result(timeout=10) @@ -528,7 +537,10 @@ class SonosSpeaker: variables = event.variables if "volume" in variables: - self.volume = int(variables["volume"]["Master"]) + volume = variables["volume"] + self.volume = int(volume["Master"]) + if "LF" in volume and "RF" in volume: + self.balance = (int(volume["LF"]), int(volume["RF"])) if "mute" in variables: self.muted = variables["mute"]["Master"] == "1" @@ -591,13 +603,20 @@ class SonosSpeaker: self.async_write_entity_states() self.hass.async_create_task(self.async_subscribe()) - async def async_check_activity(self, now: datetime.datetime) -> None: + @callback + def async_check_activity(self, now: datetime.datetime) -> None: """Validate availability of the speaker based on recent activity.""" if not self.available: return if time.monotonic() - self._last_activity < AVAILABILITY_TIMEOUT: return + # Ensure the ping is canceled at shutdown + self.hass.async_create_background_task( + self._async_check_activity(), f"sonos {self.uid} {self.zone_name} ping" + ) + async def _async_check_activity(self) -> None: + """Validate availability of the speaker based on recent activity.""" try: await self.hass.async_add_executor_job(self.ping) except SonosUpdateError: diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index fb73e30421..75c1b85014 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -10,5 +10,11 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "issues": { + "subscriptions_failed": { + "title": "Networking error: subscriptions failed", + "description": "Falling back to polling, functionality may be limited.\n\nSonos device at {device_ip} cannot reach Home Assistant at {listener_address}.\n\nSee our [documentation]({sub_fail_url}) for more information on how to solve this issue." + } } } diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 111a13c2c9..7211843133 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -398,7 +398,9 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): return attributes async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media(self.hass, media_content_id) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 085146d4ef..7ca1533744 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotipy==2.22.1"], + "requirements": ["spotipy==2.23.0"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 7c583eb533..a738952d2c 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -95,6 +95,7 @@ def spotify_exception_handler(func): self._attr_available = False if exc.reason == "NO_ACTIVE_DEVICE": raise HomeAssistantError("No active playback device found") from None + raise HomeAssistantError(f"Spotify error: {exc.reason}") from exc return wrapper @@ -398,7 +399,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity): self._playlist = self.data.client.playlist(current["context"]["uri"]) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index d52f2d10d0..23be7735c3 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -6,7 +6,7 @@ from typing import Any import sqlalchemy from sqlalchemy.engine import Result -from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.exc import NoSuchColumnError, SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker import voluptuous as vol @@ -22,19 +22,32 @@ from .util import resolve_db_url _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema( +OPTIONS_SCHEMA: vol.Schema = vol.Schema( { - vol.Required(CONF_NAME, default="Select SQL Query"): selector.TextSelector(), - vol.Optional(CONF_DB_URL): selector.TextSelector(), - vol.Required(CONF_COLUMN_NAME): selector.TextSelector(), - vol.Required(CONF_QUERY): selector.TextSelector( - selector.TextSelectorConfig(multiline=True) - ), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.TextSelector(), - vol.Optional(CONF_VALUE_TEMPLATE): selector.TemplateSelector(), + vol.Optional( + CONF_DB_URL, + ): selector.TextSelector(), + vol.Required( + CONF_COLUMN_NAME, + ): selector.TextSelector(), + vol.Required( + CONF_QUERY, + ): selector.TextSelector(selector.TextSelectorConfig(multiline=True)), + vol.Optional( + CONF_UNIT_OF_MEASUREMENT, + ): selector.TextSelector(), + vol.Optional( + CONF_VALUE_TEMPLATE, + ): selector.TemplateSelector(), } ) +CONFIG_SCHEMA: vol.Schema = vol.Schema( + { + vol.Required(CONF_NAME, default="Select SQL Query"): selector.TextSelector(), + } +).extend(OPTIONS_SCHEMA.schema) + def validate_sql_select(value: str) -> str | None: """Validate that value is a SQL SELECT query.""" @@ -56,14 +69,23 @@ def validate_query(db_url: str, query: str, column: str) -> bool: _LOGGER.debug("Execution error %s", error) if sess: sess.close() + engine.dispose() raise ValueError(error) from error for res in result.mappings(): + if column not in res: + _LOGGER.debug("Column `%s` is not returned by the query", column) + if sess: + sess.close() + engine.dispose() + raise NoSuchColumnError(f"Column {column} is not returned by the query.") + data = res[column] _LOGGER.debug("Return value from query: %s", data) if sess: sess.close() + engine.dispose() return True @@ -86,6 +108,7 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the user step.""" errors = {} + description_placeholders = {} if user_input is not None: db_url = user_input.get(CONF_DB_URL) @@ -102,6 +125,9 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job( validate_query, db_url_for_validation, query, column ) + except NoSuchColumnError: + errors["column"] = "column_invalid" + description_placeholders = {"column": column} except SQLAlchemyError: errors["db_url"] = "db_url_invalid" except ValueError: @@ -127,8 +153,9 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=DATA_SCHEMA, + data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input), errors=errors, + description_placeholders=description_placeholders, ) @@ -144,6 +171,7 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow): ) -> FlowResult: """Manage SQL options.""" errors = {} + description_placeholders = {} if user_input is not None: db_url = user_input.get(CONF_DB_URL) @@ -157,6 +185,9 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow): await self.hass.async_add_executor_job( validate_query, db_url_for_validation, query, column ) + except NoSuchColumnError: + errors["column"] = "column_invalid" + description_placeholders = {"column": column} except SQLAlchemyError: errors["db_url"] = "db_url_invalid" except ValueError: @@ -175,43 +206,9 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow): return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_DB_URL, - description={ - "suggested_value": self.entry.options.get(CONF_DB_URL) - }, - ): selector.TextSelector(), - vol.Required( - CONF_QUERY, - description={"suggested_value": self.entry.options[CONF_QUERY]}, - ): selector.TextSelector( - selector.TextSelectorConfig(multiline=True) - ), - vol.Required( - CONF_COLUMN_NAME, - description={ - "suggested_value": self.entry.options[CONF_COLUMN_NAME] - }, - ): selector.TextSelector(), - vol.Optional( - CONF_UNIT_OF_MEASUREMENT, - description={ - "suggested_value": self.entry.options.get( - CONF_UNIT_OF_MEASUREMENT - ) - }, - ): selector.TextSelector(), - vol.Optional( - CONF_VALUE_TEMPLATE, - description={ - "suggested_value": self.entry.options.get( - CONF_VALUE_TEMPLATE - ) - }, - ): selector.TemplateSelector(), - } + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, user_input or self.entry.options ), errors=errors, + description_placeholders=description_placeholders, ) diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 7513bbd8c7..61328de953 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -1,9 +1,9 @@ { "domain": "sql", "name": "SQL", - "codeowners": ["@dgomes", "@gjohansson-ST"], + "codeowners": ["@dgomes", "@gjohansson-ST", "@dougiteixeira"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["sqlalchemy==2.0.6"] + "requirements": ["sqlalchemy==2.0.10"] } diff --git a/homeassistant/components/sql/models.py b/homeassistant/components/sql/models.py new file mode 100644 index 0000000000..feac9ebf20 --- /dev/null +++ b/homeassistant/components/sql/models.py @@ -0,0 +1,16 @@ +"""The sql integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import scoped_session + +from homeassistant.core import CALLBACK_TYPE + + +@dataclass(slots=True) +class SQLData: + """Data for the sql integration.""" + + shutdown_event_cancel: CALLBACK_TYPE + session_makers_by_db_url: dict[str, scoped_session] diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 95227bac65..eb0e9c9c46 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -6,11 +6,18 @@ import decimal import logging import sqlalchemy +from sqlalchemy import lambda_stmt from sqlalchemy.engine import Result from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker +from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.util import LRUCache -from homeassistant.components.recorder import CONF_DB_URL, get_instance +from homeassistant.components.recorder import ( + CONF_DB_URL, + SupportedDialect, + get_instance, +) from homeassistant.components.sensor import ( CONF_STATE_CLASS, SensorDeviceClass, @@ -24,9 +31,11 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, + EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import TemplateError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -34,10 +43,13 @@ from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_COLUMN_NAME, CONF_QUERY, DB_URL_RE, DOMAIN +from .models import SQLData from .util import resolve_db_url _LOGGER = logging.getLogger(__name__) +_SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000) + def redact_credentials(data: str) -> str: """Redact credentials from string data.""" @@ -121,6 +133,36 @@ async def async_setup_entry( ) +@callback +def _async_get_or_init_domain_data(hass: HomeAssistant) -> SQLData: + """Get or initialize domain data.""" + if DOMAIN in hass.data: + sql_data: SQLData = hass.data[DOMAIN] + return sql_data + + session_makers_by_db_url: dict[str, scoped_session] = {} + + # + # Ensure we dispose of all engines at shutdown + # to avoid unclean disconnects + # + # Shutdown all sessions in the executor since they will + # do blocking I/O + # + def _shutdown_db_engines(event: Event) -> None: + """Shutdown all database engines.""" + for sessmaker in session_makers_by_db_url.values(): + sessmaker.connection().engine.dispose() + + cancel_shutdown = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _shutdown_db_engines + ) + + sql_data = SQLData(cancel_shutdown, session_makers_by_db_url) + hass.data[DOMAIN] = sql_data + return sql_data + + async def async_setup_sensor( hass: HomeAssistant, name: str, @@ -136,34 +178,73 @@ async def async_setup_sensor( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SQL sensor.""" - try: - engine = sqlalchemy.create_engine(db_url, future=True) - sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) - - # Run a dummy query just to test the db_url - sess: Session = sessmaker() - sess.execute(sqlalchemy.text("SELECT 1;")) - - except SQLAlchemyError as err: - _LOGGER.error( - "Couldn't connect using %s DB_URL: %s", - redact_credentials(db_url), - redact_credentials(str(err)), - ) + instance = get_instance(hass) + sessmaker: scoped_session | None + sql_data = _async_get_or_init_domain_data(hass) + uses_recorder_db = db_url == instance.db_url + use_database_executor = False + if uses_recorder_db and instance.dialect_name == SupportedDialect.SQLITE: + use_database_executor = True + assert instance.engine is not None + sessmaker = scoped_session(sessionmaker(bind=instance.engine, future=True)) + # For other databases we need to create a new engine since + # we want the connection to use the default timezone and these + # database engines will use QueuePool as its only sqlite that + # needs our custom pool. If there is already a session maker + # for this db_url we can use that so we do not create a new engine + # for every sensor. + elif db_url in sql_data.session_makers_by_db_url: + sessmaker = sql_data.session_makers_by_db_url[db_url] + elif sessmaker := await hass.async_add_executor_job( + _validate_and_get_session_maker_for_db_url, db_url + ): + sql_data.session_makers_by_db_url[db_url] = sessmaker + else: return - finally: - if sess: - sess.close() + + upper_query = query_str.upper() + if uses_recorder_db: + redacted_query = redact_credentials(query_str) + + issue_key = unique_id if unique_id else redacted_query + # If the query has a unique id and they fix it we can dismiss the issue + # but if it doesn't have a unique id they have to ignore it instead + + if ( + "ENTITY_ID," in upper_query or "ENTITY_ID " in upper_query + ) and "STATES_META" not in upper_query: + _LOGGER.error( + "The query `%s` contains the keyword `entity_id` but does not " + "reference the `states_meta` table. This will cause a full table " + "scan and database instability. Please check the documentation and use " + "`states_meta.entity_id` instead", + redacted_query, + ) + + ir.async_create_issue( + hass, + DOMAIN, + f"entity_id_query_does_full_table_scan_{issue_key}", + translation_key="entity_id_query_does_full_table_scan", + translation_placeholders={"query": redacted_query}, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + ) + raise ValueError( + "Query contains entity_id but does not reference states_meta" + ) + + ir.async_delete_issue( + hass, DOMAIN, f"entity_id_query_does_full_table_scan_{issue_key}" + ) # MSSQL uses TOP and not LIMIT - if not ("LIMIT" in query_str.upper() or "SELECT TOP" in query_str.upper()): + if not ("LIMIT" in upper_query or "SELECT TOP" in upper_query): if "mssql" in db_url: - query_str = query_str.upper().replace("SELECT", "SELECT TOP 1") + query_str = upper_query.replace("SELECT", "SELECT TOP 1") else: query_str = query_str.replace(";", "") + " LIMIT 1;" - use_database_executor = db_url == get_instance(hass).db_url - async_add_entities( [ SQLSensor( @@ -184,6 +265,39 @@ async def async_setup_sensor( ) +def _validate_and_get_session_maker_for_db_url(db_url: str) -> scoped_session | None: + """Validate the db_url and return a session maker. + + This does I/O and should be run in the executor. + """ + sess: Session | None = None + try: + engine = sqlalchemy.create_engine(db_url, future=True) + sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) + # Run a dummy query just to test the db_url + sess = sessmaker() + sess.execute(sqlalchemy.text("SELECT 1;")) + + except SQLAlchemyError as err: + _LOGGER.error( + "Couldn't connect using %s DB_URL: %s", + redact_credentials(db_url), + redact_credentials(str(err)), + ) + return None + else: + return sessmaker + finally: + if sess: + sess.close() + + +def _generate_lambda_stmt(query: str) -> StatementLambdaElement: + """Generate the lambda statement.""" + text = sqlalchemy.text(query) + return lambda_stmt(lambda: text, lambda_cache=_SQL_LAMBDA_CACHE) + + class SQLSensor(SensorEntity): """Representation of an SQL sensor.""" @@ -216,6 +330,7 @@ class SQLSensor(SensorEntity): self._attr_extra_state_attributes = {} self._attr_unique_id = unique_id self._use_database_executor = use_database_executor + self._lambda_stmt = _generate_lambda_stmt(query) if not yaml and unique_id: self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -237,7 +352,7 @@ class SQLSensor(SensorEntity): self._attr_extra_state_attributes = {} sess: scoped_session = self.sessionmaker() try: - result: Result = sess.execute(sqlalchemy.text(self._query)) + result: Result = sess.execute(self._lambda_stmt) except SQLAlchemyError as err: _LOGGER.error( "Error executing query %s: %s", diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index 2a300f75b3..6888652cb4 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -5,7 +5,8 @@ }, "error": { "db_url_invalid": "Database URL invalid", - "query_invalid": "SQL Query invalid" + "query_invalid": "SQL Query invalid", + "column_invalid": "The column `{column}` is not returned by the query" }, "step": { "user": { @@ -18,7 +19,7 @@ "value_template": "Value Template" }, "data_description": { - "db_url": "Database URL, leave empty to use default HA database", + "db_url": "Database URL, leave empty to use HA recorder database", "name": "Name that will be used for Config Entry and also the Sensor", "query": "Query to run, needs to start with 'SELECT'", "column": "Column for returned query to present as state", @@ -51,7 +52,14 @@ }, "error": { "db_url_invalid": "[%key:component::sql::config::error::db_url_invalid%]", - "query_invalid": "[%key:component::sql::config::error::query_invalid%]" + "query_invalid": "[%key:component::sql::config::error::query_invalid%]", + "column_invalid": "[%key:component::sql::config::error::column_invalid%]" + } + }, + "issues": { + "entity_id_query_does_full_table_scan": { + "title": "SQL query does full table scan", + "description": "The query `{query}` contains the keyword `entity_id` but does not reference the `states_meta` table. This will cause a full table scan and database instability. Please check the documentation and use `states_meta.entity_id` instead." } } } diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 5c6f45c6ae..d3fae39bc4 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -634,7 +634,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/srp_energy/__init__.py b/homeassistant/components/srp_energy/__init__.py index b98b8a39df..ea80a29d99 100644 --- a/homeassistant/components/srp_energy/__init__.py +++ b/homeassistant/components/srp_energy/__init__.py @@ -1,34 +1,31 @@ """The SRP Energy integration.""" -import logging - from srpenergy.client import SrpEnergyClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import SRP_ENERGY_DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, LOGGER PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the SRP Energy component from a config entry.""" - # Store an SrpEnergyClient object for your srp_energy to access - try: - srp_energy_client = SrpEnergyClient( - entry.data.get(CONF_ID), - entry.data.get(CONF_USERNAME), - entry.data.get(CONF_PASSWORD), - ) - hass.data[SRP_ENERGY_DOMAIN] = srp_energy_client - except Exception as ex: - _LOGGER.error("Unable to connect to Srp Energy: %s", str(ex)) - raise ConfigEntryNotReady from ex + api_account_id: str = entry.data[CONF_ID] + api_username: str = entry.data[CONF_USERNAME] + api_password: str = entry.data[CONF_PASSWORD] + + LOGGER.debug("Configuring client using account_id %s", api_account_id) + + api_instance = SrpEnergyClient( + api_account_id, + api_username, + api_password, + ) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = api_instance await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -37,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - # unload srp client - hass.data[SRP_ENERGY_DOMAIN] = None - # Remove config entry - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/srp_energy/config_flow.py b/homeassistant/components/srp_energy/config_flow.py index 2d5505b763..c52574ff31 100644 --- a/homeassistant/components/srp_energy/config_flow.py +++ b/homeassistant/components/srp_energy/config_flow.py @@ -1,65 +1,85 @@ """Config flow for SRP Energy.""" -import logging +from __future__ import annotations + +from typing import Any from srpenergy.client import SrpEnergyClient import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError -from .const import CONF_IS_TOU, DEFAULT_NAME, SRP_ENERGY_DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_IS_TOU, DEFAULT_NAME, DOMAIN, LOGGER -class ConfigFlow(config_entries.ConfigFlow, domain=SRP_ENERGY_DOMAIN): - """Handle a config flow for SRP Energy.""" +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + srp_client = SrpEnergyClient( + data[CONF_ID], + data[CONF_USERNAME], + data[CONF_PASSWORD], + ) + + is_valid = await hass.async_add_executor_job(srp_client.validate) + + LOGGER.debug("Is user input valid: %s", is_valid) + if not is_valid: + raise InvalidAuth + + return is_valid + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle an SRP Energy config flow.""" VERSION = 1 - config = { - vol.Required(CONF_ID): str, - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, - vol.Optional(CONF_IS_TOU, default=False): bool, - } - - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} + default_title: str = DEFAULT_NAME if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - if user_input is not None: + if self.hass.config.location_name: + default_title = self.hass.config.location_name + + if user_input: try: - srp_client = SrpEnergyClient( - user_input[CONF_ID], - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) - - is_valid = await self.hass.async_add_executor_job(srp_client.validate) - - if is_valid: - return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input - ) - - errors["base"] = "invalid_auth" - + await validate_input(self.hass, user_input) except ValueError: + # Thrown when the account id is malformed errors["base"] = "invalid_account" + except InvalidAuth: + errors["base"] = "invalid_auth" except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + else: + return self.async_create_entry(title=default_title, data=user_input) return self.async_show_form( - step_id="user", data_schema=vol.Schema(self.config), errors=errors + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ID): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_IS_TOU, default=False): bool, + } + ), + errors=errors or {}, ) - async def async_step_import(self, import_config): - """Import from config.""" - # Validate config values - return await self.async_step_user(user_input=import_config) + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/srp_energy/const.py b/homeassistant/components/srp_energy/const.py index 527a1ed78b..5128dc48b3 100644 --- a/homeassistant/components/srp_energy/const.py +++ b/homeassistant/components/srp_energy/const.py @@ -1,15 +1,16 @@ """Constants for the SRP Energy integration.""" from datetime import timedelta +import logging -SRP_ENERGY_DOMAIN = "srp_energy" -DEFAULT_NAME = "SRP Energy" +LOGGER = logging.getLogger(__package__) + +DOMAIN = "srp_energy" +DEFAULT_NAME = "Home" CONF_IS_TOU = "is_tou" -ATTRIBUTION = "Powered by SRP Energy" +PHOENIX_TIME_ZONE = "America/Phoenix" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) -SENSOR_NAME = "Usage" +SENSOR_NAME = "Energy Usage" SENSOR_TYPE = "usage" - -ICON = "mdi:flash" diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 1aaf5175e5..cdfd53d40a 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -1,6 +1,7 @@ """Support for SRP Energy Sensor.""" -from datetime import datetime, timedelta -import logging +from __future__ import annotations + +from datetime import timedelta import async_timeout from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout @@ -14,30 +15,29 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import ( - ATTRIBUTION, + CONF_IS_TOU, DEFAULT_NAME, - ICON, + DOMAIN, + LOGGER, MIN_TIME_BETWEEN_UPDATES, + PHOENIX_TIME_ZONE, SENSOR_NAME, SENSOR_TYPE, - SRP_ENERGY_DOMAIN, ) -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the SRP Energy Usage sensor.""" # API object stored here by __init__.py - is_time_of_use = False - api = hass.data[SRP_ENERGY_DOMAIN] - if entry and entry.data: - is_time_of_use = entry.data["is_tou"] + api = hass.data[DOMAIN][entry.entry_id] + is_time_of_use = entry.data[CONF_IS_TOU] async def async_update_data(): """Fetch data from API endpoint. @@ -45,10 +45,13 @@ async def async_setup_entry( This is the place to pre-process the data to lookup tables so entities can quickly look up their data. """ + LOGGER.debug("async_update_data enter") try: # Fetch srp_energy data - start_date = datetime.now() + timedelta(days=-1) - end_date = datetime.now() + phx_time_zone = dt_util.get_time_zone(PHOENIX_TIME_ZONE) + end_date = dt_util.now(phx_time_zone) + start_date = end_date - timedelta(days=1) + async with async_timeout.timeout(10): hourly_usage = await hass.async_add_executor_job( api.usage, @@ -57,9 +60,22 @@ async def async_setup_entry( is_time_of_use, ) + LOGGER.debug( + "async_update_data: Received %s record(s) from %s to %s", + len(hourly_usage) if hourly_usage else "None", + start_date, + end_date, + ) + previous_daily_usage = 0.0 for _, _, _, kwh, _ in hourly_usage: previous_daily_usage += float(kwh) + + LOGGER.debug( + "async_update_data: previous_daily_usage %s", + previous_daily_usage, + ) + return previous_daily_usage except TimeoutError as timeout_err: raise UpdateFailed("Timeout communicating with API") from timeout_err @@ -68,7 +84,7 @@ async def async_setup_entry( coordinator = DataUpdateCoordinator( hass, - _LOGGER, + LOGGER, name="sensor", update_method=async_update_data, update_interval=MIN_TIME_BETWEEN_UPDATES, @@ -83,10 +99,11 @@ async def async_setup_entry( class SrpEntity(SensorEntity): """Implementation of a Srp Energy Usage sensor.""" - _attr_attribution = ATTRIBUTION + _attr_attribution = "Powered by SRP Energy" + _attr_icon = "mdi:flash" _attr_should_poll = False - def __init__(self, coordinator): + def __init__(self, coordinator) -> None: """Initialize the SrpEntity class.""" self._name = SENSOR_NAME self.type = SENSOR_TYPE @@ -95,51 +112,32 @@ class SrpEntity(SensorEntity): self._state = None @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return f"{DEFAULT_NAME} {self._name}" @property - def unique_id(self): - """Return sensor unique_id.""" - return self.type - - @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the device.""" - if self._state: - return f"{self._state:.2f}" - return None + return self.coordinator.data @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str: """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement @property - def icon(self): - """Return icon.""" - return ICON - - @property - def usage(self): - """Return entity state.""" - if self.coordinator.data: - return f"{self.coordinator.data:.2f}" - return None - - @property - def available(self): + def available(self) -> bool: """Return if entity is available.""" return self.coordinator.last_update_success @property - def device_class(self): + def device_class(self) -> SensorDeviceClass: """Return the device class.""" return SensorDeviceClass.ENERGY @property - def state_class(self): + def state_class(self) -> SensorStateClass: """Return the state class.""" return SensorStateClass.TOTAL_INCREASING diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index b7e28f2704..570e79e499 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -106,42 +106,20 @@ PRIMARY_MATCH_KEYS = [ _LOGGER = logging.getLogger(__name__) -@dataclass -class _HaServiceDescription: - """Keys added by HA.""" - - x_homeassistant_matching_domains: set[str] = field(default_factory=set) - - -@dataclass -class _SsdpServiceDescription: - """SSDP info with optional keys.""" +@dataclass(slots=True) +class SsdpServiceInfo(BaseServiceInfo): + """Prepared info from ssdp/upnp entries.""" ssdp_usn: str ssdp_st: str + upnp: Mapping[str, Any] ssdp_location: str | None = None ssdp_nt: str | None = None ssdp_udn: str | None = None ssdp_ext: str | None = None ssdp_server: str | None = None ssdp_headers: Mapping[str, Any] = field(default_factory=dict) - - -@dataclass -class _UpnpServiceDescription: - """UPnP info.""" - - upnp: Mapping[str, Any] - - -@dataclass -class SsdpServiceInfo( - _HaServiceDescription, - _SsdpServiceDescription, - _UpnpServiceDescription, - BaseServiceInfo, -): - """Prepared info from ssdp/upnp entries.""" + x_homeassistant_matching_domains: set[str] = field(default_factory=set) SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") @@ -401,7 +379,7 @@ class Scanner: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) self._cancel_scan = async_track_time_interval( - self.hass, self.async_scan, SCAN_INTERVAL + self.hass, self.async_scan, SCAN_INTERVAL, name="SSDP scanner" ) # Trigger the initial-scan. diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py index 350c420d5d..f4a8783787 100644 --- a/homeassistant/components/starlingbank/sensor.py +++ b/homeassistant/components/starlingbank/sensor.py @@ -26,7 +26,7 @@ CONF_SANDBOX = "sandbox" DEFAULT_SANDBOX = False DEFAULT_ACCOUNT_NAME = "Starling" -ICON = "mdi:currency-gbp" + SCAN_INTERVAL = timedelta(seconds=180) ACCOUNT_SCHEMA = vol.Schema( @@ -76,6 +76,8 @@ def setup_platform( class StarlingBalanceSensor(SensorEntity): """Representation of a Starling balance sensor.""" + _attr_icon = "mdi:currency-gbp" + def __init__(self, starling_account, account_name, balance_data_type): """Initialize the sensor.""" self._starling_account = starling_account @@ -100,11 +102,6 @@ class StarlingBalanceSensor(SensorEntity): """Return the unit of measurement.""" return self._starling_account.currency - @property - def icon(self): - """Return the entity icon.""" - return ICON - def update(self) -> None: """Fetch new state data for the sensor.""" self._starling_account.update_balance_data() diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index 79cd5ca389..a1cc60da79 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -12,7 +12,13 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEGREE, EntityCategory, UnitOfDataRate, UnitOfTime +from homeassistant.const import ( + DEGREE, + PERCENTAGE, + EntityCategory, + UnitOfDataRate, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -119,4 +125,11 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: now() - timedelta(seconds=data.status["uptime"]), ), + StarlinkSensorEntityDescription( + key="ping_drop_rate", + name="Ping Drop Rate", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: data.status["pop_ping_drop_rate"], + ), ) diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py index 2629962565..2b1b322321 100644 --- a/homeassistant/components/steam_online/__init__.py +++ b/homeassistant/components/steam_online/__init__.py @@ -4,13 +4,10 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN from .coordinator import SteamDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -47,20 +44,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class SteamEntity(CoordinatorEntity[SteamDataUpdateCoordinator]): - """Representation of a Steam entity.""" - - _attr_attribution = "Data provided by Steam" - - def __init__(self, coordinator: SteamDataUpdateCoordinator) -> None: - """Initialize a Steam entity.""" - super().__init__(coordinator) - self._attr_device_info = DeviceInfo( - configuration_url="https://store.steampowered.com", - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, - manufacturer=DEFAULT_NAME, - name=DEFAULT_NAME, - ) diff --git a/homeassistant/components/steam_online/entity.py b/homeassistant/components/steam_online/entity.py new file mode 100644 index 0000000000..364f2e7232 --- /dev/null +++ b/homeassistant/components/steam_online/entity.py @@ -0,0 +1,24 @@ +"""Entity classes for the Steam integration.""" +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import SteamDataUpdateCoordinator + + +class SteamEntity(CoordinatorEntity[SteamDataUpdateCoordinator]): + """Representation of a Steam entity.""" + + _attr_attribution = "Data provided by Steam" + + def __init__(self, coordinator: SteamDataUpdateCoordinator) -> None: + """Initialize a Steam entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + configuration_url="https://store.steampowered.com", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer=DEFAULT_NAME, + name=DEFAULT_NAME, + ) diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 10e507775d..d3ae69e251 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utc_from_timestamp -from . import SteamEntity from .const import ( CONF_ACCOUNTS, DOMAIN, @@ -23,6 +22,7 @@ from .const import ( STEAM_STATUSES, ) from .coordinator import SteamDataUpdateCoordinator +from .entity import SteamEntity PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 94e08d2536..5e34a567c9 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -1,27 +1,34 @@ """Provide functionality to STT.""" from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod import asyncio -from dataclasses import asdict, dataclass +from collections.abc import AsyncIterable +from dataclasses import asdict import logging -from typing import Any +from typing import Any, final -from aiohttp import StreamReader, web +from aiohttp import web from aiohttp.hdrs import istr from aiohttp.web_exceptions import ( HTTPBadRequest, HTTPNotFound, HTTPUnsupportedMediaType, ) +import voluptuous as vol +from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_prepare_setup_platform +from homeassistant.util import dt as dt_util, language as language_util from .const import ( + DATA_PROVIDERS, DOMAIN, AudioBitRates, AudioChannels, @@ -30,96 +37,124 @@ from .const import ( AudioSampleRates, SpeechResultState, ) +from .legacy import ( + Provider, + SpeechMetadata, + SpeechResult, + async_default_provider, + async_get_provider, + async_setup_legacy, +) + +__all__ = [ + "async_get_provider", + "async_get_speech_to_text_engine", + "async_get_speech_to_text_entity", + "AudioBitRates", + "AudioChannels", + "AudioCodecs", + "AudioFormats", + "AudioSampleRates", + "DOMAIN", + "Provider", + "SpeechToTextEntity", + "SpeechMetadata", + "SpeechResult", + "SpeechResultState", +] _LOGGER = logging.getLogger(__name__) @callback -def async_get_provider(hass: HomeAssistant, domain: str | None = None) -> Provider: - """Return provider.""" - if domain is None: - domain = next(iter(hass.data[DOMAIN])) +def async_default_engine(hass: HomeAssistant) -> str | None: + """Return the domain or entity id of the default engine.""" + return async_default_provider(hass) or next( + iter(hass.states.async_entity_ids(DOMAIN)), None + ) - return hass.data[DOMAIN][domain] + +@callback +def async_get_speech_to_text_entity( + hass: HomeAssistant, entity_id: str +) -> SpeechToTextEntity | None: + """Return stt entity.""" + component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] + + return component.get_entity(entity_id) + + +@callback +def async_get_speech_to_text_engine( + hass: HomeAssistant, engine_id: str +) -> SpeechToTextEntity | Provider | None: + """Return stt entity or legacy provider.""" + if entity := async_get_speech_to_text_entity(hass, engine_id): + return entity + return async_get_provider(hass, engine_id) + + +@callback +def async_get_speech_to_text_languages(hass: HomeAssistant) -> set[str]: + """Return a set with the union of languages supported by stt engines.""" + languages = set() + + component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] + legacy_providers: dict[str, Provider] = hass.data[DATA_PROVIDERS] + for entity in component.entities: + for language_tag in entity.supported_languages: + languages.add(language_tag) + + for engine in legacy_providers.values(): + for language_tag in engine.supported_languages: + languages.add(language_tag) + + return languages async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up STT.""" - providers = hass.data[DOMAIN] = {} + websocket_api.async_register_command(hass, websocket_list_engines) - async def async_setup_platform(p_type, p_config=None, discovery_info=None): - """Set up a TTS platform.""" - if p_config is None: - p_config = {} + component = hass.data[DOMAIN] = EntityComponent[SpeechToTextEntity]( + _LOGGER, DOMAIN, hass + ) - platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) - if platform is None: - return + component.register_shutdown() + platform_setups = async_setup_legacy(hass, config) - try: - provider = await platform.async_get_engine(hass, p_config, discovery_info) - if provider is None: - _LOGGER.error("Error setting up platform %s", p_type) - return + if platform_setups: + await asyncio.wait([asyncio.create_task(setup) for setup in platform_setups]) - provider.name = p_type - provider.hass = hass - - providers[provider.name] = provider - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error setting up platform: %s", p_type) - return - - setup_tasks = [ - asyncio.create_task(async_setup_platform(p_type, p_config)) - for p_type, p_config in config_per_platform(config, DOMAIN) - ] - - if setup_tasks: - await asyncio.wait(setup_tasks) - - # Add discovery support - async def async_platform_discovered(platform, info): - """Handle for discovered platform.""" - await async_setup_platform(platform, discovery_info=info) - - discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) - - hass.http.register_view(SpeechToTextView(providers)) + hass.http.register_view(SpeechToTextView(hass.data[DATA_PROVIDERS])) return True -@dataclass -class SpeechMetadata: - """Metadata of audio stream.""" - - language: str - format: AudioFormats - codec: AudioCodecs - bit_rate: AudioBitRates - sample_rate: AudioSampleRates - channel: AudioChannels - - def __post_init__(self) -> None: - """Finish initializing the metadata.""" - self.bit_rate = AudioBitRates(int(self.bit_rate)) - self.sample_rate = AudioSampleRates(int(self.sample_rate)) - self.channel = AudioChannels(int(self.channel)) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) -@dataclass -class SpeechResult: - """Result of audio Speech.""" - - text: str | None - result: SpeechResultState +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) -class Provider(ABC): +class SpeechToTextEntity(RestoreEntity): """Represent a single STT provider.""" - hass: HomeAssistant | None = None - name: str | None = None + _attr_should_poll = False + __last_processed: str | None = None + + @property + @final + def state(self) -> str | None: + """Return the state of the provider entity.""" + if self.__last_processed is None: + return None + return self.__last_processed @property @abstractmethod @@ -151,13 +186,36 @@ class Provider(ABC): def supported_channels(self) -> list[AudioChannels]: """Return a list of supported channels.""" - @abstractmethod - async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: StreamReader + async def async_internal_added_to_hass(self) -> None: + """Call when the provider entity is added to hass.""" + await super().async_internal_added_to_hass() + state = await self.async_get_last_state() + if ( + state is not None + and state.state is not None + and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + ): + self.__last_processed = state.state + + @final + async def internal_async_process_audio_stream( + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service. - Only streaming of content are allow! + Only streaming content is allowed! + """ + self.__last_processed = dt_util.utcnow().isoformat() + self.async_write_ha_state() + return await self.async_process_audio_stream(metadata=metadata, stream=stream) + + @abstractmethod + async def async_process_audio_stream( + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] + ) -> SpeechResult: + """Process an audio stream to STT service. + + Only streaming content is allowed! """ @callback @@ -178,6 +236,7 @@ class Provider(ABC): class SpeechToTextView(HomeAssistantView): """STT view to generate a text from audio stream.""" + _legacy_provider_reported = False requires_auth = True url = "/api/stt/{provider}" name = "api:stt:provider" @@ -188,47 +247,117 @@ class SpeechToTextView(HomeAssistantView): async def post(self, request: web.Request, provider: str) -> web.Response: """Convert Speech (audio) to text.""" - if provider not in self.providers: + hass: HomeAssistant = request.app["hass"] + provider_entity: SpeechToTextEntity | None = None + if ( + not (provider_entity := async_get_speech_to_text_entity(hass, provider)) + and provider not in self.providers + ): raise HTTPNotFound() - stt_provider: Provider = self.providers[provider] # Get metadata try: - metadata = metadata_from_header(request) + metadata = _metadata_from_header(request) except ValueError as err: raise HTTPBadRequest(text=str(err)) from err - # Check format - if not stt_provider.check_metadata(metadata): - raise HTTPUnsupportedMediaType() + if not provider_entity: + stt_provider = self._get_provider(provider) - # Process audio stream - result = await stt_provider.async_process_audio_stream( - metadata, request.content - ) + # Check format + if not stt_provider.check_metadata(metadata): + raise HTTPUnsupportedMediaType() + + # Process audio stream + result = await stt_provider.async_process_audio_stream( + metadata, request.content + ) + else: + # Check format + if not provider_entity.check_metadata(metadata): + raise HTTPUnsupportedMediaType() + + # Process audio stream + result = await provider_entity.internal_async_process_audio_stream( + metadata, request.content + ) # Return result return self.json(asdict(result)) async def get(self, request: web.Request, provider: str) -> web.Response: """Return provider specific audio information.""" - if provider not in self.providers: + hass: HomeAssistant = request.app["hass"] + if ( + not (provider_entity := async_get_speech_to_text_entity(hass, provider)) + and provider not in self.providers + ): raise HTTPNotFound() - stt_provider: Provider = self.providers[provider] + + if not provider_entity: + stt_provider = self._get_provider(provider) + + return self.json( + { + "languages": stt_provider.supported_languages, + "formats": stt_provider.supported_formats, + "codecs": stt_provider.supported_codecs, + "sample_rates": stt_provider.supported_sample_rates, + "bit_rates": stt_provider.supported_bit_rates, + "channels": stt_provider.supported_channels, + } + ) return self.json( { - "languages": stt_provider.supported_languages, - "formats": stt_provider.supported_formats, - "codecs": stt_provider.supported_codecs, - "sample_rates": stt_provider.supported_sample_rates, - "bit_rates": stt_provider.supported_bit_rates, - "channels": stt_provider.supported_channels, + "languages": provider_entity.supported_languages, + "formats": provider_entity.supported_formats, + "codecs": provider_entity.supported_codecs, + "sample_rates": provider_entity.supported_sample_rates, + "bit_rates": provider_entity.supported_bit_rates, + "channels": provider_entity.supported_channels, } ) + def _get_provider(self, provider: str) -> Provider: + """Get provider. -def metadata_from_header(request: web.Request) -> SpeechMetadata: + Method for legacy providers. + This can be removed when we remove the legacy provider support. + """ + stt_provider = self.providers[provider] + + if not self._legacy_provider_reported: + self._legacy_provider_reported = True + report_issue = self._suggest_report_issue(provider, stt_provider) + # This should raise in Home Assistant Core 2023.9 + _LOGGER.warning( + "Provider %s (%s) is using a legacy implementation, " + "and should be updated to use the SpeechToTextEntity. Please " + "%s", + provider, + type(stt_provider), + report_issue, + ) + + return stt_provider + + def _suggest_report_issue(self, provider: str, provider_instance: object) -> str: + """Suggest to report an issue.""" + report_issue = "" + if "custom_components" in type(provider_instance).__module__: + report_issue = "report it to the custom integration author." + else: + report_issue = ( + "create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + report_issue += f"+label%3A%22integration%3A+{provider}%22" + + return report_issue + + +def _metadata_from_header(request: web.Request) -> SpeechMetadata: """Extract STT metadata from header. X-Speech-Content: @@ -253,7 +382,7 @@ def metadata_from_header(request: web.Request) -> SpeechMetadata: for entry in data: key, _, value = entry.strip().partition("=") if key not in fields: - raise ValueError(f"Invalid field {key}") + raise ValueError(f"Invalid field: {key}") args[key] = value for field in fields: @@ -269,5 +398,52 @@ def metadata_from_header(request: web.Request) -> SpeechMetadata: sample_rate=args["sample_rate"], channel=args["channel"], ) - except TypeError as err: + except ValueError as err: raise ValueError(f"Wrong format of X-Speech-Content: {err}") from err + + +@websocket_api.websocket_command( + { + "type": "stt/engine/list", + vol.Optional("language"): str, + vol.Optional("country"): str, + } +) +@callback +def websocket_list_engines( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """List speech to text engines and, optionally, if they support a given language.""" + component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] + legacy_providers: dict[str, Provider] = hass.data[DATA_PROVIDERS] + + country = msg.get("country") + language = msg.get("language") + providers = [] + provider_info: dict[str, Any] + + for entity in component.entities: + provider_info = { + "engine_id": entity.entity_id, + "supported_languages": entity.supported_languages, + } + if language: + provider_info["supported_languages"] = language_util.matches( + language, entity.supported_languages, country + ) + providers.append(provider_info) + + for engine_id, provider in legacy_providers.items(): + provider_info = { + "engine_id": engine_id, + "supported_languages": provider.supported_languages, + } + if language: + provider_info["supported_languages"] = language_util.matches( + language, provider.supported_languages, country + ) + providers.append(provider_info) + + connection.send_message( + websocket_api.result_message(msg["id"], {"providers": providers}) + ) diff --git a/homeassistant/components/stt/const.py b/homeassistant/components/stt/const.py index c111aed82a..c9f5eb13d1 100644 --- a/homeassistant/components/stt/const.py +++ b/homeassistant/components/stt/const.py @@ -2,6 +2,7 @@ from enum import Enum DOMAIN = "stt" +DATA_PROVIDERS = f"{DOMAIN}_providers" class AudioCodecs(str, Enum): diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py new file mode 100644 index 0000000000..f2a5854e56 --- /dev/null +++ b/homeassistant/components/stt/legacy.py @@ -0,0 +1,174 @@ +"""Handle legacy speech to text platforms.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import AsyncIterable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_prepare_setup_platform + +from .const import ( + DATA_PROVIDERS, + DOMAIN, + AudioBitRates, + AudioChannels, + AudioCodecs, + AudioFormats, + AudioSampleRates, + SpeechResultState, +) + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_default_provider(hass: HomeAssistant) -> str | None: + """Return the domain of the default provider.""" + if "cloud" in hass.data[DATA_PROVIDERS]: + return "cloud" + + return next(iter(hass.data[DATA_PROVIDERS]), None) + + +@callback +def async_get_provider( + hass: HomeAssistant, domain: str | None = None +) -> Provider | None: + """Return provider.""" + if domain: + return hass.data[DATA_PROVIDERS].get(domain) + + provider = async_default_provider(hass) + return hass.data[DATA_PROVIDERS][provider] if provider is not None else None + + +@callback +def async_setup_legacy( + hass: HomeAssistant, config: ConfigType +) -> list[Coroutine[Any, Any, None]]: + """Set up legacy speech to text providers.""" + providers = hass.data[DATA_PROVIDERS] = {} + + async def async_setup_platform(p_type, p_config=None, discovery_info=None): + """Set up a TTS platform.""" + if p_config is None: + p_config = {} + + platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) + if platform is None: + _LOGGER.error("Unknown speech to text platform specified") + return + + try: + provider = await platform.async_get_engine(hass, p_config, discovery_info) + + provider.name = p_type + provider.hass = hass + + providers[provider.name] = provider + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error setting up platform: %s", p_type) + return + + # Add discovery support + async def async_platform_discovered(platform, info): + """Handle for discovered platform.""" + await async_setup_platform(platform, discovery_info=info) + + discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) + + return [ + async_setup_platform(p_type, p_config) + for p_type, p_config in config_per_platform(config, DOMAIN) + ] + + +@dataclass +class SpeechMetadata: + """Metadata of audio stream.""" + + language: str + format: AudioFormats + codec: AudioCodecs + bit_rate: AudioBitRates + sample_rate: AudioSampleRates + channel: AudioChannels + + def __post_init__(self) -> None: + """Finish initializing the metadata.""" + self.bit_rate = AudioBitRates(int(self.bit_rate)) + self.sample_rate = AudioSampleRates(int(self.sample_rate)) + self.channel = AudioChannels(int(self.channel)) + + +@dataclass +class SpeechResult: + """Result of audio Speech.""" + + text: str | None + result: SpeechResultState + + +class Provider(ABC): + """Represent a single STT provider.""" + + hass: HomeAssistant | None = None + name: str | None = None + + @property + @abstractmethod + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + + @property + @abstractmethod + def supported_formats(self) -> list[AudioFormats]: + """Return a list of supported formats.""" + + @property + @abstractmethod + def supported_codecs(self) -> list[AudioCodecs]: + """Return a list of supported codecs.""" + + @property + @abstractmethod + def supported_bit_rates(self) -> list[AudioBitRates]: + """Return a list of supported bit rates.""" + + @property + @abstractmethod + def supported_sample_rates(self) -> list[AudioSampleRates]: + """Return a list of supported sample rates.""" + + @property + @abstractmethod + def supported_channels(self) -> list[AudioChannels]: + """Return a list of supported channels.""" + + @abstractmethod + async def async_process_audio_stream( + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] + ) -> SpeechResult: + """Process an audio stream to STT service. + + Only streaming of content are allow! + """ + + @callback + def check_metadata(self, metadata: SpeechMetadata) -> bool: + """Check if given metadata supported by this provider.""" + if ( + metadata.language not in self.supported_languages + or metadata.format not in self.supported_formats + or metadata.codec not in self.supported_codecs + or metadata.bit_rate not in self.supported_bit_rates + or metadata.sample_rate not in self.supported_sample_rates + or metadata.channel not in self.supported_channels + ): + return False + return True diff --git a/homeassistant/components/stt/manifest.json b/homeassistant/components/stt/manifest.json index b594f8f91b..73eb0fa4c0 100644 --- a/homeassistant/components/stt/manifest.json +++ b/homeassistant/components/stt/manifest.json @@ -1,7 +1,7 @@ { "domain": "stt", "name": "Speech-to-Text (STT)", - "codeowners": ["@pvizeli"], + "codeowners": ["@home-assistant/core", "@pvizeli"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/stt", "integration_type": "entity", diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 5852136ca4..9fae6ca9f7 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/subaru", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], - "requirements": ["subarulink==0.7.5"] + "requirements": ["subarulink==0.7.6"] } diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index 65836e0c61..a43bf4fd80 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( EVENT_CORE_CONFIG_UPDATE, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, + Platform, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import event @@ -97,15 +98,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # we will create entities before firing EVENT_COMPONENT_LOADED await async_process_integration_platform_for_component(hass, DOMAIN) hass.data[DOMAIN] = Sun(hass) + await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - sun = hass.data.pop(DOMAIN) - sun.remove_listeners() - hass.states.async_remove(sun.entity_id) - return True + if unload_ok := await hass.config_entries.async_unload_platforms( + entry, [Platform.SENSOR] + ): + sun: Sun = hass.data.pop(DOMAIN) + sun.remove_listeners() + hass.states.async_remove(sun.entity_id) + return unload_ok class Sun(Entity): diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py new file mode 100644 index 0000000000..8a390ea2a2 --- /dev/null +++ b/homeassistant/components/sun/sensor.py @@ -0,0 +1,142 @@ +"""Sensor platform for Sun integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEGREE +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 . import Sun +from .const import DOMAIN + +ENTITY_ID_SENSOR_FORMAT = SENSOR_DOMAIN + ".sun_{}" + + +@dataclass +class SunEntityDescriptionMixin: + """Mixin for required Sun base description keys.""" + + value_fn: Callable[[Sun], StateType | datetime] + + +@dataclass +class SunSensorEntityDescription(SensorEntityDescription, SunEntityDescriptionMixin): + """Describes Sun sensor entity.""" + + +SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( + SunSensorEntityDescription( + key="next_dawn", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="next_dawn", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_dawn, + ), + SunSensorEntityDescription( + key="next_dusk", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="next_dusk", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_dusk, + ), + SunSensorEntityDescription( + key="next_midnight", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="next_midnight", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_midnight, + ), + SunSensorEntityDescription( + key="next_noon", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="next_noon", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_noon, + ), + SunSensorEntityDescription( + key="next_rising", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="next_rising", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_rising, + ), + SunSensorEntityDescription( + key="next_setting", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="next_setting", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_setting, + ), + SunSensorEntityDescription( + key="solar_elevation", + translation_key="solar_elevation", + icon="mdi:theme-light-dark", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.solar_elevation, + entity_registry_enabled_default=False, + native_unit_of_measurement=DEGREE, + ), + SunSensorEntityDescription( + key="solar_azimuth", + translation_key="solar_azimuth", + icon="mdi:sun-angle", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.solar_azimuth, + entity_registry_enabled_default=False, + native_unit_of_measurement=DEGREE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Sun sensor platform.""" + + sun: Sun = hass.data[DOMAIN] + + async_add_entities( + [SunSensor(sun, description, entry.entry_id) for description in SENSOR_TYPES] + ) + + +class SunSensor(SensorEntity): + """Representation of a Sun Sensor.""" + + _attr_has_entity_name = True + entity_description: SunSensorEntityDescription + + def __init__( + self, sun: Sun, entity_description: SunSensorEntityDescription, entry_id: str + ) -> None: + """Initiate Sun Sensor.""" + self.entity_description = entity_description + self.entity_id = ENTITY_ID_SENSOR_FORMAT.format(entity_description.key) + self._attr_unique_id = f"{entry_id}-{entity_description.key}" + self.sun = sun + + self._attr_device_info = DeviceInfo( + name="Sun", + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def native_value(self) -> StateType | datetime: + """Return value of sensor.""" + state = self.entity_description.value_fn(self.sun) + return state diff --git a/homeassistant/components/sun/strings.json b/homeassistant/components/sun/strings.json index 9a49a061c1..3d0374f1de 100644 --- a/homeassistant/components/sun/strings.json +++ b/homeassistant/components/sun/strings.json @@ -18,5 +18,17 @@ "below_horizon": "Below horizon" } } + }, + "entity": { + "sensor": { + "next_dawn": { "name": "Next dawn" }, + "next_dusk": { "name": "Next dusk" }, + "next_midnight": { "name": "Next midnight" }, + "next_noon": { "name": "Next noon" }, + "next_rising": { "name": "Next rising" }, + "next_setting": { "name": "Next setting" }, + "solar_azimuth": { "name": "Solar azimuth" }, + "solar_elevation": { "name": "Solar elevation" } + } } } diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 7295622894..541b31eb0a 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -14,10 +14,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -30,6 +27,7 @@ SCAN_INTERVAL = timedelta(seconds=10) SUPLA_FUNCTION_HA_CMP_MAP = { "CONTROLLINGTHEROLLERSHUTTER": Platform.COVER, "CONTROLLINGTHEGATE": Platform.COVER, + "CONTROLLINGTHEGARAGEDOOR": Platform.COVER, "LIGHTSWITCH": Platform.SWITCH, } SUPLA_FUNCTION_NONE = "NONE" @@ -154,58 +152,3 @@ async def discover_devices(hass, hass_config): # Load discovered devices for component_name, config in component_configs.items(): await async_load_platform(hass, component_name, DOMAIN, config, hass_config) - - -class SuplaChannel(CoordinatorEntity): - """Base class of a Supla Channel (an equivalent of HA's Entity).""" - - def __init__(self, config, server, coordinator): - """Init from config, hookup[ server and coordinator.""" - super().__init__(coordinator) - self.server_name = config["server_name"] - self.channel_id = config["channel_id"] - self.server = server - - @property - def channel_data(self): - """Return channel data taken from coordinator.""" - return self.coordinator.data.get(self.channel_id) - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return "supla-{}-{}".format( - self.channel_data["iodevice"]["gUIDString"].lower(), - self.channel_data["channelNumber"], - ) - - @property - def name(self) -> str | None: - """Return the name of the device.""" - return self.channel_data["caption"] - - @property - def available(self) -> bool: - """Return True if entity is available.""" - if self.channel_data is None: - return False - if (state := self.channel_data.get("state")) is None: - return False - return state.get("connected") - - async def async_action(self, action, **add_pars): - """Run server action. - - Actions are currently hardcoded in components. - Supla's API enables autodiscovery - """ - _LOGGER.debug( - "Executing action %s on channel %d, params: %s", - action, - self.channel_data["id"], - add_pars, - ) - await self.server.execute_action(self.channel_data["id"], action, **add_pars) - - # Update state - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py index c6c1d9c07d..53e57fe185 100644 --- a/homeassistant/components/supla/cover.py +++ b/homeassistant/components/supla/cover.py @@ -10,12 +10,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, SUPLA_COORDINATORS, SUPLA_SERVERS, SuplaChannel +from . import DOMAIN, SUPLA_COORDINATORS, SUPLA_SERVERS +from .entity import SuplaEntity _LOGGER = logging.getLogger(__name__) SUPLA_SHUTTER = "CONTROLLINGTHEROLLERSHUTTER" SUPLA_GATE = "CONTROLLINGTHEGATE" +SUPLA_GARAGE_DOOR = "CONTROLLINGTHEGARAGEDOOR" async def async_setup_platform( @@ -37,16 +39,16 @@ async def async_setup_platform( if device_name == SUPLA_SHUTTER: entities.append( - SuplaCover( + SuplaCoverEntity( device, hass.data[DOMAIN][SUPLA_SERVERS][server_name], hass.data[DOMAIN][SUPLA_COORDINATORS][server_name], ) ) - elif device_name == SUPLA_GATE: + elif device_name in {SUPLA_GATE, SUPLA_GARAGE_DOOR}: entities.append( - SuplaGateDoor( + SuplaDoorEntity( device, hass.data[DOMAIN][SUPLA_SERVERS][server_name], hass.data[DOMAIN][SUPLA_COORDINATORS][server_name], @@ -56,7 +58,7 @@ async def async_setup_platform( async_add_entities(entities) -class SuplaCover(SuplaChannel, CoverEntity): +class SuplaCoverEntity(SuplaEntity, CoverEntity): """Representation of a Supla Cover.""" @property @@ -90,33 +92,33 @@ class SuplaCover(SuplaChannel, CoverEntity): await self.async_action("STOP") -class SuplaGateDoor(SuplaChannel, CoverEntity): - """Representation of a Supla gate door.""" +class SuplaDoorEntity(SuplaEntity, CoverEntity): + """Representation of a Supla door.""" @property def is_closed(self) -> bool | None: - """Return if the gate is closed or not.""" + """Return if the door is closed or not.""" state = self.channel_data.get("state") if state and "hi" in state: return state.get("hi") return None async def async_open_cover(self, **kwargs: Any) -> None: - """Open the gate.""" + """Open the door.""" if self.is_closed: await self.async_action("OPEN_CLOSE") async def async_close_cover(self, **kwargs: Any) -> None: - """Close the gate.""" + """Close the door.""" if not self.is_closed: await self.async_action("OPEN_CLOSE") async def async_stop_cover(self, **kwargs: Any) -> None: - """Stop the gate.""" + """Stop the door.""" await self.async_action("OPEN_CLOSE") async def async_toggle(self, **kwargs: Any) -> None: - """Toggle the gate.""" + """Toggle the door.""" await self.async_action("OPEN_CLOSE") @property diff --git a/homeassistant/components/supla/entity.py b/homeassistant/components/supla/entity.py new file mode 100644 index 0000000000..ae0a627b53 --- /dev/null +++ b/homeassistant/components/supla/entity.py @@ -0,0 +1,63 @@ +"""Base class for Supla channels.""" +from __future__ import annotations + +import logging + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +_LOGGER = logging.getLogger(__name__) + + +class SuplaEntity(CoordinatorEntity): + """Base class of a Supla Channel (an equivalent of HA's Entity).""" + + def __init__(self, config, server, coordinator): + """Init from config, hookup[ server and coordinator.""" + super().__init__(coordinator) + self.server_name = config["server_name"] + self.channel_id = config["channel_id"] + self.server = server + + @property + def channel_data(self): + """Return channel data taken from coordinator.""" + return self.coordinator.data.get(self.channel_id) + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return "supla-{}-{}".format( + self.channel_data["iodevice"]["gUIDString"].lower(), + self.channel_data["channelNumber"], + ) + + @property + def name(self) -> str | None: + """Return the name of the device.""" + return self.channel_data["caption"] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.channel_data is None: + return False + if (state := self.channel_data.get("state")) is None: + return False + return state.get("connected") + + async def async_action(self, action, **add_pars): + """Run server action. + + Actions are currently hardcoded in components. + Supla's API enables autodiscovery + """ + _LOGGER.debug( + "Executing action %s on channel %d, params: %s", + action, + self.channel_data["id"], + add_pars, + ) + await self.server.execute_action(self.channel_data["id"], action, **add_pars) + + # Update state + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/supla/switch.py b/homeassistant/components/supla/switch.py index 9c4c53c1e9..b270f4300e 100644 --- a/homeassistant/components/supla/switch.py +++ b/homeassistant/components/supla/switch.py @@ -10,7 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, SUPLA_COORDINATORS, SUPLA_SERVERS, SuplaChannel +from . import DOMAIN, SUPLA_COORDINATORS, SUPLA_SERVERS +from .entity import SuplaEntity _LOGGER = logging.getLogger(__name__) @@ -32,7 +33,7 @@ async def async_setup_platform( server_name = device["server_name"] entities.append( - SuplaSwitch( + SuplaSwitchEntity( device, hass.data[DOMAIN][SUPLA_SERVERS][server_name], hass.data[DOMAIN][SUPLA_COORDINATORS][server_name], @@ -42,7 +43,7 @@ async def async_setup_platform( async_add_entities(entities) -class SuplaSwitch(SuplaChannel, SwitchEntity): +class SuplaSwitchEntity(SuplaEntity, SwitchEntity): """Representation of a Supla Switch.""" async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 8735726f89..12007e1741 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -35,7 +35,6 @@ CONF_START = "from" DEFAULT_NAME = "Next Departure" -ICON = "mdi:bus" SCAN_INTERVAL = timedelta(seconds=90) @@ -79,6 +78,7 @@ class SwissPublicTransportSensor(SensorEntity): """Implementation of an Swiss public transport sensor.""" _attr_attribution = "Data provided by transport.opendata.ch" + _attr_icon = "mdi:bus" def __init__(self, opendata, start, destination, name): """Initialize the sensor.""" @@ -125,11 +125,6 @@ class SwissPublicTransportSensor(SensorEntity): ATTR_DELAY: self._opendata.connections[0]["delay"], } - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - async def async_update(self) -> None: """Get the latest data from opendata.ch and update the states.""" diff --git a/homeassistant/components/switch_as_x/cover.py b/homeassistant/components/switch_as_x/cover.py index 9d7a7bf617..7df3b17721 100644 --- a/homeassistant/components/switch_as_x/cover.py +++ b/homeassistant/components/switch_as_x/cover.py @@ -3,7 +3,11 @@ from __future__ import annotations from typing import Any -from homeassistant.components.cover import CoverEntity, CoverEntityFeature +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + CoverEntity, + CoverEntityFeature, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -30,18 +34,15 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ CoverSwitch( + hass, config_entry.title, + COVER_DOMAIN, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index ac56b4c607..21a7b88244 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -10,36 +10,57 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, STATE_UNAVAILABLE, - EntityCategory, ) -from homeassistant.core import Event, callback -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity import Entity, ToggleEntity +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import DeviceInfo, Entity, ToggleEntity from homeassistant.helpers.event import async_track_state_change_event from .const import DOMAIN as SWITCH_AS_X_DOMAIN class BaseEntity(Entity): - """Represents a Switch as a X.""" + """Represents a Switch as an X.""" _attr_should_poll = False + _is_new_entity: bool def __init__( self, - name: str, + hass: HomeAssistant, + config_entry_title: str, + domain: str, switch_entity_id: str, - unique_id: str | None, - device_id: str | None, - entity_category: EntityCategory | None, + unique_id: str, ) -> None: - """Initialize Light Switch.""" + """Initialize Switch as an X.""" + registry = er.async_get(hass) + device_registry = dr.async_get(hass) + wrapped_switch = registry.async_get(switch_entity_id) + device_id = wrapped_switch.device_id if wrapped_switch else None + entity_category = wrapped_switch.entity_category if wrapped_switch else None + has_entity_name = wrapped_switch.has_entity_name if wrapped_switch else False + + name: str | None = config_entry_title + if wrapped_switch: + name = wrapped_switch.original_name + self._device_id = device_id + if device_id and (device := device_registry.async_get(device_id)): + self._attr_device_info = DeviceInfo( + connections=device.connections, + identifiers=device.identifiers, + ) self._attr_entity_category = entity_category + self._attr_has_entity_name = has_entity_name self._attr_name = name self._attr_unique_id = unique_id self._switch_entity_id = switch_entity_id + self._is_new_entity = ( + registry.async_get_entity_id(domain, SWITCH_AS_X_DOMAIN, unique_id) is None + ) + @callback def async_state_changed_listener(self, event: Event | None = None) -> None: """Handle child updates.""" @@ -52,7 +73,7 @@ class BaseEntity(Entity): self._attr_available = True async def async_added_to_hass(self) -> None: - """Register callbacks.""" + """Register callbacks and copy the wrapped entity's custom name if set.""" @callback def _async_state_changed_listener(event: Event | None = None) -> None: @@ -69,16 +90,24 @@ class BaseEntity(Entity): # Call once on adding _async_state_changed_listener() - # Add this entity to the wrapped switch's device + # Update entity options registry = er.async_get(self.hass) if registry.async_get(self.entity_id) is not None: - registry.async_update_entity(self.entity_id, device_id=self._device_id) registry.async_update_entity_options( self.entity_id, SWITCH_AS_X_DOMAIN, {"entity_id": self._switch_entity_id}, ) + if not self._is_new_entity: + return + + wrapped_switch = registry.async_get(self._switch_entity_id) + if not wrapped_switch or wrapped_switch.name is None: + return + + registry.async_update_entity(self.entity_id, name=wrapped_switch.name) + class BaseToggleEntity(BaseEntity, ToggleEntity): """Represents a Switch as a ToggleEntity.""" diff --git a/homeassistant/components/switch_as_x/fan.py b/homeassistant/components/switch_as_x/fan.py index bfc4d2e037..d8c43cfe38 100644 --- a/homeassistant/components/switch_as_x/fan.py +++ b/homeassistant/components/switch_as_x/fan.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from homeassistant.components.fan import FanEntity +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN, FanEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant @@ -23,18 +23,15 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ FanSwitch( + hass, config_entry.title, + FAN_DOMAIN, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/light.py b/homeassistant/components/switch_as_x/light.py index c8181bf35f..e6183c95d9 100644 --- a/homeassistant/components/switch_as_x/light.py +++ b/homeassistant/components/switch_as_x/light.py @@ -1,7 +1,11 @@ """Light support for switch entities.""" from __future__ import annotations -from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + ColorMode, + LightEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant @@ -21,18 +25,15 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ LightSwitch( + hass, config_entry.title, + LIGHT_DOMAIN, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/lock.py b/homeassistant/components/switch_as_x/lock.py index a0aac15a70..9778caf8e6 100644 --- a/homeassistant/components/switch_as_x/lock.py +++ b/homeassistant/components/switch_as_x/lock.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from homeassistant.components.lock import LockEntity +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -30,18 +30,15 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ LockSwitch( + hass, config_entry.title, + LOCK_DOMAIN, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/siren.py b/homeassistant/components/switch_as_x/siren.py index 635aa4e2d7..c9981b17cf 100644 --- a/homeassistant/components/switch_as_x/siren.py +++ b/homeassistant/components/switch_as_x/siren.py @@ -1,7 +1,11 @@ """Siren support for switch entities.""" from __future__ import annotations -from homeassistant.components.siren import SirenEntity, SirenEntityFeature +from homeassistant.components.siren import ( + DOMAIN as SIREN_DOMAIN, + SirenEntity, + SirenEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant @@ -21,18 +25,15 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ SirenSwitch( + hass, config_entry.title, + SIREN_DOMAIN, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index 675f312872..00b22d9428 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -20,50 +20,50 @@ PARALLEL_UPDATES = 0 BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { "calibration": BinarySensorEntityDescription( key="calibration", - name="Calibration", + translation_key="calibration", entity_category=EntityCategory.DIAGNOSTIC, ), "motion_detected": BinarySensorEntityDescription( key="pir_state", - name="Motion detected", + translation_key="motion", device_class=BinarySensorDeviceClass.MOTION, ), "contact_open": BinarySensorEntityDescription( key="contact_open", - name="Door open", + translation_key="door_open", device_class=BinarySensorDeviceClass.DOOR, ), "contact_timeout": BinarySensorEntityDescription( key="contact_timeout", - name="Door timeout", + translation_key="door_timeout", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, ), "is_light": BinarySensorEntityDescription( key="is_light", - name="Light", + translation_key="light", device_class=BinarySensorDeviceClass.LIGHT, ), "door_open": BinarySensorEntityDescription( key="door_status", - name="Door status", + translation_key="door_open", device_class=BinarySensorDeviceClass.DOOR, ), "unclosed_alarm": BinarySensorEntityDescription( key="unclosed_alarm", - name="Door unclosed alarm", + translation_key="door_unclosed_alarm", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, ), "unlocked_alarm": BinarySensorEntityDescription( key="unlocked_alarm", - name="Door unlocked alarm", + translation_key="door_unclosed_alarm", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, ), "auto_lock_paused": BinarySensorEntityDescription( key="auto_lock_paused", - name="Door auto-lock paused", + translation_key="door_auto_lock_paused", entity_category=EntityCategory.DIAGNOSTIC, ), } diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 2637f578b8..c90a1a6428 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -40,5 +40,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.37.3"] + "requirements": ["PySwitchbot==0.37.6"] } diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index 3ed43790b8..b5b34bf54e 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -28,7 +28,7 @@ PARALLEL_UPDATES = 0 SENSOR_TYPES: dict[str, SensorEntityDescription] = { "rssi": SensorEntityDescription( key="rssi", - name="Bluetooth signal strength", + translation_key="bluetooth_signal", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, @@ -37,7 +37,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { ), "wifi_rssi": SensorEntityDescription( key="wifi_rssi", - name="Wi-Fi signal strength", + translation_key="wifi_signal", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, @@ -46,7 +46,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { ), "battery": SensorEntityDescription( key="battery", - name="Battery", + translation_key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, @@ -54,27 +54,27 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { ), "lightLevel": SensorEntityDescription( key="lightLevel", - name="Light level", + translation_key="light_level", native_unit_of_measurement="Level", state_class=SensorStateClass.MEASUREMENT, ), "humidity": SensorEntityDescription( key="humidity", - name="Humidity", + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.HUMIDITY, ), "temperature": SensorEntityDescription( key="temperature", - name="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, ), "power": SensorEntityDescription( key="power", - name="Power", + translation_key="power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 3262ffbd7b..7422658342 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -58,5 +58,56 @@ } } } + }, + "entity": { + "binary_sensor": { + "calibration": { + "name": "Calibration" + }, + "motion": { + "name": "[%key:component::binary_sensor::entity_component::motion::name%]" + }, + "door_open": { + "name": "[%key:component::binary_sensor::entity_component::door::name%]" + }, + "door_timeout": { + "name": "Timeout" + }, + "light": { + "name": "[%key:component::binary_sensor::entity_component::light::name%]" + }, + "door_unclosed_alarm": { + "name": "Unclosed alarm" + }, + "door_unlocked_alarm": { + "name": "Unlocked alarm" + }, + "door_auto_lock_paused": { + "name": "Auto-lock paused" + } + }, + "sensor": { + "bluetooth_signal": { + "name": "Bluetooth signal" + }, + "wifi_signal": { + "name": "Wi-Fi signal" + }, + "battery": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + }, + "light_level": { + "name": "Light level" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "power": { + "name": "[%key:component::sensor::entity_component::power::name%]" + } + } } } diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 422adf6c51..2be541c810 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aioswitcher"], "quality_scale": "platinum", - "requirements": ["aioswitcher==3.2.1"] + "requirements": ["aioswitcher==3.3.0"] } diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 9dc40d2dc5..1f335aee4b 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -38,7 +38,7 @@ SECURITY_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ...] = SynologyDSMBinarySensorEntityDescription( api_key=SynoCoreSecurity.API_KEY, key="status", - name="Security Status", + translation_key="status", device_class=BinarySensorDeviceClass.SAFETY, ), ) @@ -47,14 +47,14 @@ STORAGE_DISK_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ... SynologyDSMBinarySensorEntityDescription( api_key=SynoStorage.API_KEY, key="disk_exceed_bad_sector_thr", - name="Exceeded Max Bad Sectors", + translation_key="disk_exceed_bad_sector_thr", device_class=BinarySensorDeviceClass.SAFETY, entity_category=EntityCategory.DIAGNOSTIC, ), SynologyDSMBinarySensorEntityDescription( api_key=SynoStorage.API_KEY, key="disk_below_remain_life_thr", - name="Below Min Remaining Life", + translation_key="disk_below_remain_life_thr", device_class=BinarySensorDeviceClass.SAFETY, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 9c96cfc429..b5a2c7bfad 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -12,6 +12,7 @@ from synology_dsm.api.core.upgrade import SynoCoreUpgrade from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.dsm.network import SynoDSMNetwork +from synology_dsm.api.photos import SynoPhotos from synology_dsm.api.storage.storage import SynoStorage from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.exceptions import ( @@ -56,6 +57,7 @@ class SynoApi: self.network: SynoDSMNetwork = None self.security: SynoCoreSecurity = None self.storage: SynoStorage = None + self.photos: SynoPhotos = None self.surveillance_station: SynoSurveillanceStation = None self.system: SynoCoreSystem = None self.upgrade: SynoCoreUpgrade = None @@ -66,6 +68,7 @@ class SynoApi: self._with_information = True self._with_security = True self._with_storage = True + self._with_photos = True self._with_surveillance_station = True self._with_system = True self._with_upgrade = True @@ -163,6 +166,7 @@ class SynoApi: self._fetching_entities.get(SynoCoreSecurity.API_KEY) ) self._with_storage = bool(self._fetching_entities.get(SynoStorage.API_KEY)) + self._with_photos = bool(self._fetching_entities.get(SynoStorage.API_KEY)) self._with_upgrade = bool(self._fetching_entities.get(SynoCoreUpgrade.API_KEY)) self._with_utilisation = bool( self._fetching_entities.get(SynoCoreUtilization.API_KEY) @@ -180,6 +184,13 @@ class SynoApi: self.dsm.reset(self.security) self.security = None + if not self._with_photos: + LOGGER.debug( + "Disable photos api from being updated or '%s'", self._entry.unique_id + ) + self.dsm.reset(self.photos) + self.photos = None + if not self._with_storage: LOGGER.debug( "Disable storage api from being updatedf or '%s'", self._entry.unique_id @@ -219,6 +230,10 @@ class SynoApi: LOGGER.debug("Enable security api updates for '%s'", self._entry.unique_id) self.security = self.dsm.security + if self._with_photos: + LOGGER.debug("Enable photos api updates for '%s'", self._entry.unique_id) + self.photos = self.dsm.photos + if self._with_storage: LOGGER.debug("Enable storage api updates for '%s'", self._entry.unique_id) self.storage = self.dsm.storage diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index 0cc1722280..0865686ef2 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -35,6 +35,7 @@ class SynologyDSMBaseEntity(CoordinatorEntity[_CoordinatorT]): entity_description: SynologyDSMEntityDescription unique_id: str _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, @@ -47,7 +48,6 @@ class SynologyDSMBaseEntity(CoordinatorEntity[_CoordinatorT]): self.entity_description = description self._api = api - self._attr_name = f"{api.network.hostname} {description.name}" self._attr_unique_id: str = ( f"{api.information.serial}_{description.api_key}:{description.key}" ) @@ -110,9 +110,6 @@ class SynologyDSMDeviceEntity( self._device_firmware = disk["firm"] self._device_type = disk["diskType"] - self._attr_name = ( - f"{self._api.network.hostname} ({self._device_name}) {description.name}" - ) self._attr_unique_id += f"_{self._device_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{self._api.information.serial}_{self._device_id}")}, diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index 1149012cbb..8060bce5c9 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -3,6 +3,7 @@ "name": "Synology DSM", "codeowners": ["@hacf-fr", "@Quentame", "@mib1185"], "config_flow": true, + "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py new file mode 100644 index 0000000000..16db365f70 --- /dev/null +++ b/homeassistant/components/synology_dsm/media_source.py @@ -0,0 +1,231 @@ +"""Expose Synology DSM as a media source.""" +from __future__ import annotations + +import mimetypes + +from aiohttp import web +from synology_dsm.api.photos import SynoPhotosAlbum, SynoPhotosItem +from synology_dsm.exceptions import SynologyDSMException + +from homeassistant.components import http +from homeassistant.components.media_player import MediaClass +from homeassistant.components.media_source import ( + BrowseError, + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, + Unresolvable, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .models import SynologyDSMData + + +async def async_get_media_source(hass: HomeAssistant) -> MediaSource: + """Set up Synology media source.""" + entries = hass.config_entries.async_entries(DOMAIN) + hass.http.register_view(SynologyDsmMediaView(hass)) + return SynologyPhotosMediaSource(hass, entries) + + +class SynologyPhotosMediaSourceIdentifier: + """Synology Photos item identifier.""" + + def __init__(self, identifier: str) -> None: + """Split identifier into parts.""" + parts = identifier.split("/") + + self.unique_id = None + self.album_id = None + self.cache_key = None + self.file_name = None + + if parts: + self.unique_id = parts[0] + if len(parts) > 1: + self.album_id = parts[1] + if len(parts) > 2: + self.cache_key = parts[2] + if len(parts) > 3: + self.file_name = parts[3] + + +class SynologyPhotosMediaSource(MediaSource): + """Provide Synology Photos as media sources.""" + + name = "Synology Photos" + + def __init__(self, hass: HomeAssistant, entries: list[ConfigEntry]) -> None: + """Initialize Synology source.""" + super().__init__(DOMAIN) + self.hass = hass + self.entries = entries + + async def async_browse_media( + self, + item: MediaSourceItem, + ) -> BrowseMediaSource: + """Return media.""" + if not self.hass.data.get(DOMAIN): + raise BrowseError("Diskstation not initialized") + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title="Synology Photos", + can_play=False, + can_expand=True, + children_media_class=MediaClass.DIRECTORY, + children=[ + *await self._async_build_diskstations(item), + ], + ) + + async def _async_build_diskstations( + self, item: MediaSourceItem + ) -> list[BrowseMediaSource]: + """Handle browsing different diskstations.""" + if not item.identifier: + ret = [] + for entry in self.entries: + ret.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=entry.unique_id, + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=f"{entry.title} - {entry.unique_id}", + can_play=False, + can_expand=True, + ) + ) + return ret + identifier = SynologyPhotosMediaSourceIdentifier(item.identifier) + diskstation: SynologyDSMData = self.hass.data[DOMAIN][identifier.unique_id] + + if identifier.album_id is None: + # Get Albums + try: + albums = await diskstation.api.photos.get_albums() + except SynologyDSMException: + return [] + + ret = [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{item.identifier}/0", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title="All images", + can_play=False, + can_expand=True, + ) + ] + for album in albums: + ret.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{item.identifier}/{album.album_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=album.name, + can_play=False, + can_expand=True, + ) + ) + + return ret + + # Request items of album + # Get Items + album = SynoPhotosAlbum(int(identifier.album_id), "", 0) + try: + album_items = await diskstation.api.photos.get_items_from_album( + album, 0, 1000 + ) + except SynologyDSMException: + return [] + + ret = [] + for album_item in album_items: + mime_type, _ = mimetypes.guess_type(album_item.file_name) + assert isinstance(mime_type, str) + if mime_type.startswith("image/"): + # Force small small thumbnails + album_item.thumbnail_size = "sm" + ret.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}/{identifier.album_id}/{album_item.thumbnail_cache_key}/{album_item.file_name}", + media_class=MediaClass.IMAGE, + media_content_type=mime_type, + title=album_item.file_name, + can_play=True, + can_expand=False, + thumbnail=await self.async_get_thumbnail( + album_item, diskstation + ), + ) + ) + return ret + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media to a url.""" + identifier = SynologyPhotosMediaSourceIdentifier(item.identifier) + if identifier.album_id is None: + raise Unresolvable("No album id") + if identifier.file_name is None: + raise Unresolvable("No file name") + mime_type, _ = mimetypes.guess_type(identifier.file_name) + if not isinstance(mime_type, str): + raise Unresolvable("No file extension") + return PlayMedia( + f"/synology_dsm/{identifier.unique_id}/{identifier.cache_key}/{identifier.file_name}", + mime_type, + ) + + async def async_get_thumbnail( + self, item: SynoPhotosItem, diskstation: SynologyDSMData + ) -> str | None: + """Get thumbnail.""" + try: + thumbnail = await diskstation.api.photos.get_item_thumbnail_url(item) + except SynologyDSMException: + return None + return str(thumbnail) + + +class SynologyDsmMediaView(http.HomeAssistantView): + """Synology Media Finder View.""" + + url = "/synology_dsm/{source_dir_id}/{location:.*}" + name = "synology_dsm" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the media view.""" + self.hass = hass + + async def get( + self, request: web.Request, source_dir_id: str, location: str + ) -> web.Response: + """Start a GET request.""" + if not self.hass.data.get(DOMAIN): + raise web.HTTPNotFound() + # location: {cache_key}/{filename} + cache_key, file_name = location.split("/") + image_id = cache_key.split("_")[0] + mime_type, _ = mimetypes.guess_type(file_name) + if not isinstance(mime_type, str): + raise web.HTTPNotFound() + diskstation: SynologyDSMData = self.hass.data[DOMAIN][source_dir_id] + + item = SynoPhotosItem(image_id, "", "", "", cache_key, "") + try: + image = await diskstation.api.photos.download_item(item) + except SynologyDSMException as exc: + raise web.HTTPNotFound() from exc + return web.Response(body=image, content_type=mime_type) diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 4031a921a7..2929864732 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -50,7 +50,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="cpu_other_load", - name="CPU Utilization (Other)", + translation_key="cpu_other_load", native_unit_of_measurement=PERCENTAGE, icon="mdi:chip", entity_registry_enabled_default=False, @@ -59,7 +59,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="cpu_user_load", - name="CPU Utilization (User)", + translation_key="cpu_user_load", native_unit_of_measurement=PERCENTAGE, icon="mdi:chip", state_class=SensorStateClass.MEASUREMENT, @@ -67,7 +67,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="cpu_system_load", - name="CPU Utilization (System)", + translation_key="cpu_system_load", native_unit_of_measurement=PERCENTAGE, icon="mdi:chip", entity_registry_enabled_default=False, @@ -76,7 +76,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="cpu_total_load", - name="CPU Utilization (Total)", + translation_key="cpu_total_load", native_unit_of_measurement=PERCENTAGE, icon="mdi:chip", state_class=SensorStateClass.MEASUREMENT, @@ -84,7 +84,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="cpu_1min_load", - name="CPU Load Average (1 min)", + translation_key="cpu_1min_load", native_unit_of_measurement=ENTITY_UNIT_LOAD, icon="mdi:chip", entity_registry_enabled_default=False, @@ -92,21 +92,21 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="cpu_5min_load", - name="CPU Load Average (5 min)", + translation_key="cpu_5min_load", native_unit_of_measurement=ENTITY_UNIT_LOAD, icon="mdi:chip", ), SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="cpu_15min_load", - name="CPU Load Average (15 min)", + translation_key="cpu_15min_load", native_unit_of_measurement=ENTITY_UNIT_LOAD, icon="mdi:chip", ), SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_real_usage", - name="Memory Usage (Real)", + translation_key="memory_real_usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, @@ -114,8 +114,10 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_size", - name="Memory Size", - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + translation_key="memory_size", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", entity_registry_enabled_default=False, @@ -124,8 +126,10 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_cached", - name="Memory Cached", - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + translation_key="memory_cached", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", entity_registry_enabled_default=False, @@ -134,8 +138,10 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_available_swap", - name="Memory Available (Swap)", - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + translation_key="memory_available_swap", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, @@ -143,8 +149,10 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_available_real", - name="Memory Available (Real)", - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + translation_key="memory_available_real", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, @@ -152,8 +160,10 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_total_swap", - name="Memory Total (Swap)", - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + translation_key="memory_total_swap", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, @@ -161,8 +171,10 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_total_real", - name="Memory Total (Real)", - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + translation_key="memory_total_real", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, @@ -170,8 +182,10 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="network_up", - name="Upload Throughput", - native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, + translation_key="network_up", + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", state_class=SensorStateClass.MEASUREMENT, @@ -179,8 +193,10 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="network_down", - name="Download Throughput", - native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, + translation_key="network_down", + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", state_class=SensorStateClass.MEASUREMENT, @@ -190,14 +206,16 @@ STORAGE_VOL_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="volume_status", - name="Status", + translation_key="volume_status", icon="mdi:checkbox-marked-circle-outline", ), SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="volume_size_total", - name="Total Size", - native_unit_of_measurement=UnitOfInformation.TERABYTES, + translation_key="volume_size_total", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.TERABYTES, + suggested_display_precision=2, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", entity_registry_enabled_default=False, @@ -206,8 +224,10 @@ STORAGE_VOL_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="volume_size_used", - name="Used Space", - native_unit_of_measurement=UnitOfInformation.TERABYTES, + translation_key="volume_size_used", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.TERABYTES, + suggested_display_precision=2, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", state_class=SensorStateClass.MEASUREMENT, @@ -215,14 +235,14 @@ STORAGE_VOL_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="volume_percentage_used", - name="Volume Used", + translation_key="volume_percentage_used", native_unit_of_measurement=PERCENTAGE, icon="mdi:chart-pie", ), SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="volume_disk_temp_avg", - name="Average Disk Temp", + translation_key="volume_disk_temp_avg", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, @@ -230,7 +250,7 @@ STORAGE_VOL_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="volume_disk_temp_max", - name="Maximum Disk Temp", + translation_key="volume_disk_temp_max", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, @@ -241,7 +261,7 @@ STORAGE_DISK_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="disk_smart_status", - name="Status (Smart)", + translation_key="disk_smart_status", icon="mdi:checkbox-marked-circle-outline", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -249,14 +269,14 @@ STORAGE_DISK_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="disk_status", - name="Status", + translation_key="disk_status", icon="mdi:checkbox-marked-circle-outline", entity_category=EntityCategory.DIAGNOSTIC, ), SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="disk_temp", - name="Temperature", + translation_key="disk_temp", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -268,7 +288,7 @@ INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoDSMInformation.API_KEY, key="temperature", - name="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -277,7 +297,7 @@ INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoDSMInformation.API_KEY, key="uptime", - name="Last Boot", + translation_key="uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -354,26 +374,15 @@ class SynoDSMUtilSensor(SynoDSMSensor): attr = getattr(self._api.utilisation, self.entity_description.key) if callable(attr): attr = attr() - if attr is None: - return None - - result: StateType = attr - # Data (RAM) - if self.native_unit_of_measurement == UnitOfInformation.MEGABYTES: - result = round(attr / 1024.0**2, 1) - return result - - # Network - if self.native_unit_of_measurement == UnitOfDataRate.KILOBYTES_PER_SECOND: - result = round(attr / 1024.0, 1) - return result # CPU load average - if self.native_unit_of_measurement == ENTITY_UNIT_LOAD: - result = round(attr / 100, 2) - return result + if ( + isinstance(attr, int) + and self.native_unit_of_measurement == ENTITY_UNIT_LOAD + ): + return round(attr / 100, 2) - return result + return attr # type: ignore[no-any-return] @property def available(self) -> bool: @@ -400,13 +409,6 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor): def native_value(self) -> StateType: """Return the state.""" attr = getattr(self._api.storage, self.entity_description.key)(self._device_id) - if attr is None: - return None - - # Data (disk space) - if self.native_unit_of_measurement == UnitOfInformation.TERABYTES: - return round(attr / 1024.0**4, 2) # type: ignore[no-any-return] - return attr # type: ignore[no-any-return] diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index f571b9c532..92903b1d2a 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -29,7 +29,7 @@ } }, "reauth_confirm": { - "title": "Synology DSM [%key:common::config_flow::title::reauth%]", + "title": "Reauthenticate Synology DSM", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -60,5 +60,51 @@ } } } + }, + "entity": { + "binary_sensor": { + "disk_below_remain_life_thr": { "name": "Below min remaining life" }, + "disk_exceed_bad_sector_thr": { "name": "Exceeded max bad sectors" }, + "status": { "name": "Security status" } + }, + "sensor": { + "cpu_15min_load": { "name": "CPU load average (15 min)" }, + "cpu_1min_load": { "name": "CPU load average (1 min)" }, + "cpu_5min_load": { "name": "CPU load average (5 min)" }, + "cpu_other_load": { "name": "CPU utilization (other)" }, + "cpu_system_load": { "name": "CPU utilization (system)" }, + "cpu_total_load": { "name": "CPU utilization (total)" }, + "cpu_user_load": { "name": "CPU utilization (user)" }, + "disk_smart_status": { "name": "Status (smart)" }, + "disk_status": { "name": "Status" }, + "disk_temp": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "memory_available_real": { "name": "Memory available (real)" }, + "memory_available_swap": { "name": "Memory available (swap)" }, + "memory_cached": { "name": "Memory cached" }, + "memory_real_usage": { "name": "Memory usage (real)" }, + "memory_size": { "name": "Memory size" }, + "memory_total_real": { "name": "Memory total (real)" }, + "memory_total_swap": { "name": "Memory total (swap)" }, + "network_down": { "name": "Download throughput" }, + "network_up": { "name": "Upload throughput" }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "uptime": { "name": "Last boot" }, + "volume_disk_temp_avg": { "name": "Average disk temp" }, + "volume_disk_temp_max": { "name": "Maximum disk temp" }, + "volume_percentage_used": { "name": "Volume used" }, + "volume_size_total": { "name": "Total size" }, + "volume_size_used": { "name": "Used space" }, + "volume_status": { "name": "Status" } + }, + "switch": { + "home_mode": { "name": "Home mode" } + }, + "update": { + "update": { "name": "DSM update" } + } } } diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index e44c578f4d..208d299cc2 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -33,7 +33,7 @@ SURVEILLANCE_SWITCH: tuple[SynologyDSMSwitchEntityDescription, ...] = ( SynologyDSMSwitchEntityDescription( api_key=SynoSurveillanceStation.HOME_MODE_API_KEY, key="home_mode", - name="Home Mode", + translation_key="home_mode", icon="mdi:home-account", ), ) @@ -72,10 +72,6 @@ class SynoDSMSurveillanceHomeModeToggle( super().__init__(api, coordinator, description) self._version = version - self._attr_name = ( - f"{self._api.network.hostname} Surveillance Station {description.name}" - ) - @property def is_on(self) -> bool: """Return the state.""" diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index ec3ea52293..c550b18055 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -30,7 +30,7 @@ UPDATE_ENTITIES: Final = [ SynologyDSMUpdateEntityEntityDescription( api_key=SynoCoreUpgrade.API_KEY, key="update", - name="DSM Update", + translation_key="update", entity_category=EntityCategory.DIAGNOSTIC, ) ] diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index a8d3a4372c..05e607d56e 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -53,7 +53,10 @@ SERVICE_SEND_KEYPRESS = "send_keypress" SERVICE_SEND_TEXT = "send_text" -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, +) -> bool: """Set up System Bridge from a config entry.""" # Check version before initialising @@ -64,11 +67,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=async_get_clientsession(hass), ) try: - if not await version.check_supported(): - raise ConfigEntryNotReady( - "You are not running a supported version of System Bridge. Please" - f" update to {SUPPORTED_VERSION} or higher." - ) + async with async_timeout.timeout(10): + if not await version.check_supported(): + raise ConfigEntryNotReady( + "You are not running a supported version of System Bridge. Please" + f" update to {SUPPORTED_VERSION} or higher." + ) except AuthenticationException as exception: _LOGGER.error("Authentication failed for %s: %s", entry.title, exception) raise ConfigEntryAuthFailed from exception @@ -87,7 +91,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry=entry, ) try: - async with async_timeout.timeout(30): + async with async_timeout.timeout(10): await coordinator.async_get_data(MODULES) except AuthenticationException as exception: _LOGGER.error("Authentication failed for %s: %s", entry.title, exception) @@ -105,8 +109,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: # Wait for initial data - async with async_timeout.timeout(30): - while not coordinator.is_ready(): + async with async_timeout.timeout(10): + while not coordinator.is_ready: _LOGGER.debug( "Waiting for initial data from %s (%s)", entry.title, diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index 46914c0ea1..a73740e5db 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -55,7 +55,7 @@ async def _validate_input( data[CONF_API_KEY], ) try: - async with async_timeout.timeout(30): + async with async_timeout.timeout(15): await websocket_client.connect(session=async_get_clientsession(hass)) hass.async_create_task(websocket_client.listen()) response = await websocket_client.get_data(GetData(modules=["system"])) diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 2810bcfac7..adb88efd5e 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -82,6 +82,7 @@ class SystemBridgeDataUpdateCoordinator( hass, LOGGER, name=DOMAIN, update_interval=timedelta(seconds=30) ) + @property def is_ready(self) -> bool: """Return if the data is ready.""" if self.data is None: @@ -157,7 +158,7 @@ class SystemBridgeDataUpdateCoordinator( self.last_update_success = False self.async_update_listeners() except (ConnectionClosedException, ConnectionResetError) as exception: - self.logger.info( + self.logger.debug( "Websocket connection closed for %s. Will retry: %s", self.title, exception, @@ -168,7 +169,7 @@ class SystemBridgeDataUpdateCoordinator( self.last_update_success = False self.async_update_listeners() except ConnectionErrorException as exception: - self.logger.warning( + self.logger.debug( "Connection error occurred for %s. Will retry: %s", self.title, exception, @@ -187,7 +188,10 @@ class SystemBridgeDataUpdateCoordinator( session=async_get_clientsession(self.hass), ) - self.hass.async_create_task(self._listen_for_data()) + self.hass.async_create_background_task( + self._listen_for_data(), + name="System Bridge WebSocket Listener", + ) await self.websocket_client.register_data_listener( RegisterDataListener(modules=MODULES) diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 9f45108d61..3d149b3a40 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -188,7 +188,7 @@ async def handle_info( ) -@dataclasses.dataclass() +@dataclasses.dataclass(slots=True) class SystemHealthRegistration: """Helper class to track platform registration.""" diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index cd46b4fd95..8a5f53d52d 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -106,8 +106,11 @@ def _safe_get_message(record: logging.LogRecord) -> str: """ try: return record.getMessage() - except Exception: # pylint: disable=broad-except - return f"Bad logger message: {record.msg} ({record.args})" + except Exception as ex: # pylint: disable=broad-except + try: + return f"Bad logger message: {record.msg} ({record.args})" + except Exception: # pylint: disable=broad-except + return f"Bad logger message: {ex}" class LogEntry: diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 5ed6abe75a..e02d0421f8 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil==5.9.4"] + "requirements": ["psutil==5.9.5"] } diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index 7f009c278f..24d62d7602 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -1,14 +1,21 @@ """Support for Tado sensors for each zone.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass import logging +from typing import Any from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import ( DATA, @@ -24,31 +31,99 @@ from .entity import TadoDeviceEntity, TadoZoneEntity _LOGGER = logging.getLogger(__name__) + +@dataclass +class TadoBinarySensorEntityDescriptionMixin: + """Mixin for required keys.""" + + state_fn: Callable[[Any], bool] + + +@dataclass +class TadoBinarySensorEntityDescription( + BinarySensorEntityDescription, TadoBinarySensorEntityDescriptionMixin +): + """Describes Tado binary sensor entity.""" + + attributes_fn: Callable[[Any], dict[Any, StateType]] | None = None + + +BATTERY_STATE_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="battery state", + name="Battery state", + state_fn=lambda data: data["batteryState"] == "LOW", + device_class=BinarySensorDeviceClass.BATTERY, +) +CONNECTION_STATE_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="connection state", + name="Connection state", + state_fn=lambda data: data.get("connectionState", {}).get("value", False), + device_class=BinarySensorDeviceClass.CONNECTIVITY, +) +POWER_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="power", + name="Power", + state_fn=lambda data: data.power == "ON", + device_class=BinarySensorDeviceClass.POWER, +) +LINK_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="link", + name="Link", + state_fn=lambda data: data.link == "ONLINE", + device_class=BinarySensorDeviceClass.CONNECTIVITY, +) +OVERLAY_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="overlay", + name="Overlay", + state_fn=lambda data: data.overlay_active, + attributes_fn=lambda data: {"termination": data.overlay_termination_type} + if data.overlay_active + else {}, + device_class=BinarySensorDeviceClass.POWER, +) +OPEN_WINDOW_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="open window", + name="Open window", + state_fn=lambda data: bool(data.open_window or data.open_window_detected), + attributes_fn=lambda data: data.open_window_attr, + device_class=BinarySensorDeviceClass.WINDOW, +) +EARLY_START_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="early start", + name="Early start", + state_fn=lambda data: data.preparation, + device_class=BinarySensorDeviceClass.POWER, +) + DEVICE_SENSORS = { TYPE_BATTERY: [ - "battery state", - "connection state", + BATTERY_STATE_ENTITY_DESCRIPTION, + CONNECTION_STATE_ENTITY_DESCRIPTION, ], TYPE_POWER: [ - "connection state", + CONNECTION_STATE_ENTITY_DESCRIPTION, ], } ZONE_SENSORS = { TYPE_HEATING: [ - "power", - "link", - "overlay", - "early start", - "open window", + POWER_ENTITY_DESCRIPTION, + LINK_ENTITY_DESCRIPTION, + OVERLAY_ENTITY_DESCRIPTION, + OPEN_WINDOW_ENTITY_DESCRIPTION, + EARLY_START_ENTITY_DESCRIPTION, ], TYPE_AIR_CONDITIONING: [ - "power", - "link", - "overlay", - "open window", + POWER_ENTITY_DESCRIPTION, + LINK_ENTITY_DESCRIPTION, + OVERLAY_ENTITY_DESCRIPTION, + OPEN_WINDOW_ENTITY_DESCRIPTION, + ], + TYPE_HOT_WATER: [ + POWER_ENTITY_DESCRIPTION, + LINK_ENTITY_DESCRIPTION, + OVERLAY_ENTITY_DESCRIPTION, ], - TYPE_HOT_WATER: ["power", "link", "overlay"], } @@ -71,8 +146,8 @@ async def async_setup_entry( entities.extend( [ - TadoDeviceBinarySensor(tado, device, variable) - for variable in DEVICE_SENSORS[device_type] + TadoDeviceBinarySensor(tado, device, entity_description) + for entity_description in DEVICE_SENSORS[device_type] ] ) @@ -85,8 +160,8 @@ async def async_setup_entry( entities.extend( [ - TadoZoneBinarySensor(tado, zone["name"], zone["id"], variable) - for variable in ZONE_SENSORS[zone_type] + TadoZoneBinarySensor(tado, zone["name"], zone["id"], entity_description) + for entity_description in ZONE_SENSORS[zone_type] ] ) @@ -96,16 +171,21 @@ async def async_setup_entry( class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): """Representation of a tado Sensor.""" - def __init__(self, tado, device_info, device_variable): + entity_description: TadoBinarySensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, tado, device_info, entity_description: TadoBinarySensorEntityDescription + ) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description self._tado = tado super().__init__(device_info) - self.device_variable = device_variable - - self._unique_id = f"{device_variable} {self.device_id} {tado.home_id}" - - self._state = None + self._attr_unique_id = ( + f"{entity_description.key} {self.device_id} {tado.home_id}" + ) async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -121,30 +201,6 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): ) self._async_update_device_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.device_name} {self.device_variable}" - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the class of this sensor.""" - if self.device_variable == "battery state": - return BinarySensorDeviceClass.BATTERY - if self.device_variable == "connection state": - return BinarySensorDeviceClass.CONNECTIVITY - return None - @callback def _async_update_callback(self): """Update and write state.""" @@ -159,29 +215,33 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): except KeyError: return - if self.device_variable == "battery state": - self._state = self._device_info["batteryState"] == "LOW" - elif self.device_variable == "connection state": - self._state = self._device_info.get("connectionState", {}).get( - "value", False + self._attr_is_on = self.entity_description.state_fn(self._device_info) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + self._device_info ) class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): """Representation of a tado Sensor.""" - def __init__(self, tado, zone_name, zone_id, zone_variable): + entity_description: TadoBinarySensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + tado, + zone_name, + zone_id, + entity_description: TadoBinarySensorEntityDescription, + ) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description self._tado = tado super().__init__(zone_name, tado.home_id, zone_id) - self.zone_variable = zone_variable - - self._unique_id = f"{zone_variable} {zone_id} {tado.home_id}" - - self._state = None - self._state_attributes = None - self._tado_zone_data = None + self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -197,41 +257,6 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): ) self._async_update_zone_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.zone_name} {self.zone_variable}" - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the class of this sensor.""" - if self.zone_variable == "early start": - return BinarySensorDeviceClass.POWER - if self.zone_variable == "link": - return BinarySensorDeviceClass.CONNECTIVITY - if self.zone_variable == "open window": - return BinarySensorDeviceClass.WINDOW - if self.zone_variable == "overlay": - return BinarySensorDeviceClass.POWER - if self.zone_variable == "power": - return BinarySensorDeviceClass.POWER - return None - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._state_attributes - @callback def _async_update_callback(self): """Update and write state.""" @@ -242,29 +267,12 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): def _async_update_zone_data(self): """Handle update callbacks.""" try: - self._tado_zone_data = self._tado.data["zone"][self.zone_id] + tado_zone_data = self._tado.data["zone"][self.zone_id] except KeyError: return - if self.zone_variable == "power": - self._state = self._tado_zone_data.power == "ON" - - elif self.zone_variable == "link": - self._state = self._tado_zone_data.link == "ONLINE" - - elif self.zone_variable == "overlay": - self._state = self._tado_zone_data.overlay_active - if self._tado_zone_data.overlay_active: - self._state_attributes = { - "termination": self._tado_zone_data.overlay_termination_type - } - - elif self.zone_variable == "early start": - self._state = self._tado_zone_data.preparation - - elif self.zone_variable == "open window": - self._state = bool( - self._tado_zone_data.open_window - or self._tado_zone_data.open_window_detected + self._attr_is_on = self.entity_description.state_fn(tado_zone_data) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + tado_zone_data ) - self._state_attributes = self._tado_zone_data.open_window_attr diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index a72451b002..cab3c42184 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -240,7 +240,11 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self.zone_id = zone_id self.zone_type = zone_type - self._unique_id = f"{zone_type} {zone_id} {tado.home_id}" + + self._attr_unique_id = f"{zone_type} {zone_id} {tado.home_id}" + self._attr_name = zone_name + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._device_info = device_info self._device_id = self._device_info["shortSerialNo"] @@ -288,16 +292,6 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ) ) - @property - def name(self): - """Return the name of the entity.""" - return self.zone_name - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - @property def current_humidity(self): """Return the current humidity.""" diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index 11de7ceb31..c825bafc4b 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -33,6 +33,8 @@ class TadoDeviceEntity(Entity): class TadoHomeEntity(Entity): """Base implementation for Tado home.""" + _attr_should_poll = False + def __init__(self, tado): """Initialize a Tado home.""" super().__init__() diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 4289813494..d218e9ca93 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -1,9 +1,15 @@ """Support for Tado sensors for each zone.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass import logging +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -11,6 +17,7 @@ from homeassistant.const import PERCENTAGE, UnitOfTemperature 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 StateType from .const import ( CONDITIONS_MAP, @@ -25,26 +32,108 @@ from .entity import TadoHomeEntity, TadoZoneEntity _LOGGER = logging.getLogger(__name__) -HOME_SENSORS = { - "outdoor temperature", - "solar percentage", - "weather condition", -} + +@dataclass +class TadoSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + state_fn: Callable[[Any], StateType] + + +@dataclass +class TadoSensorEntityDescription( + SensorEntityDescription, TadoSensorEntityDescriptionMixin +): + """Describes Tado sensor entity.""" + + attributes_fn: Callable[[Any], dict[Any, StateType]] | None = None + + +HOME_SENSORS = [ + TadoSensorEntityDescription( + key="outdoor temperature", + name="Outdoor temperature", + state_fn=lambda data: data["outsideTemperature"]["celsius"], + attributes_fn=lambda data: { + "time": data["outsideTemperature"]["timestamp"], + }, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TadoSensorEntityDescription( + key="solar percentage", + name="Solar percentage", + state_fn=lambda data: data["solarIntensity"]["percentage"], + attributes_fn=lambda data: { + "time": data["solarIntensity"]["timestamp"], + }, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + TadoSensorEntityDescription( + key="weather condition", + name="Weather condition", + state_fn=lambda data: format_condition(data["weatherState"]["value"]), + attributes_fn=lambda data: {"time": data["weatherState"]["timestamp"]}, + ), +] + +TEMPERATURE_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="temperature", + name="Temperature", + state_fn=lambda data: data.current_temp, + attributes_fn=lambda data: { + "time": data.current_temp_timestamp, + "setting": 0, # setting is used in climate device + }, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, +) +HUMIDITY_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="humidity", + name="Humidity", + state_fn=lambda data: data.current_humidity, + attributes_fn=lambda data: {"time": data.current_humidity_timestamp}, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, +) +TADO_MODE_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="tado mode", + name="Tado mode", + state_fn=lambda data: data.tado_mode, +) +HEATING_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="heating", + name="Heating", + state_fn=lambda data: data.heating_power_percentage, + attributes_fn=lambda data: {"time": data.heating_power_timestamp}, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, +) +AC_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="ac", + name="AC", + state_fn=lambda data: data.ac_power, + attributes_fn=lambda data: {"time": data.ac_power_timestamp}, +) ZONE_SENSORS = { TYPE_HEATING: [ - "temperature", - "humidity", - "heating", - "tado mode", + TEMPERATURE_ENTITY_DESCRIPTION, + HUMIDITY_ENTITY_DESCRIPTION, + TADO_MODE_ENTITY_DESCRIPTION, + HEATING_ENTITY_DESCRIPTION, ], TYPE_AIR_CONDITIONING: [ - "temperature", - "humidity", - "ac", - "tado mode", + TEMPERATURE_ENTITY_DESCRIPTION, + HUMIDITY_ENTITY_DESCRIPTION, + TADO_MODE_ENTITY_DESCRIPTION, + AC_ENTITY_DESCRIPTION, ], - TYPE_HOT_WATER: ["tado mode"], + TYPE_HOT_WATER: [TADO_MODE_ENTITY_DESCRIPTION], } @@ -66,7 +155,12 @@ async def async_setup_entry( entities: list[SensorEntity] = [] # Create home sensors - entities.extend([TadoHomeSensor(tado, variable) for variable in HOME_SENSORS]) + entities.extend( + [ + TadoHomeSensor(tado, entity_description) + for entity_description in HOME_SENSORS + ] + ) # Create zone sensors for zone in zones: @@ -77,8 +171,8 @@ async def async_setup_entry( entities.extend( [ - TadoZoneSensor(tado, zone["name"], zone["id"], variable) - for variable in ZONE_SENSORS[zone_type] + TadoZoneSensor(tado, zone["name"], zone["id"], entity_description) + for entity_description in ZONE_SENSORS[zone_type] ] ) @@ -88,18 +182,17 @@ async def async_setup_entry( class TadoHomeSensor(TadoHomeEntity, SensorEntity): """Representation of a Tado Sensor.""" - def __init__(self, tado, home_variable): + entity_description: TadoSensorEntityDescription + + _attr_has_entity_name = True + + def __init__(self, tado, entity_description: TadoSensorEntityDescription) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description super().__init__(tado) self._tado = tado - self.home_variable = home_variable - - self._unique_id = f"{home_variable} {tado.home_id}" - - self._state = None - self._state_attributes = None - self._tado_weather_data = self._tado.data["weather"] + self._attr_unique_id = f"{entity_description.key} {tado.home_id}" async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -115,50 +208,6 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): ) self._async_update_home_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._tado.home_name} {self.home_variable}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._state_attributes - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - if self.home_variable in ["temperature", "outdoor temperature"]: - return UnitOfTemperature.CELSIUS - if self.home_variable == "solar percentage": - return PERCENTAGE - if self.home_variable == "weather condition": - return None - - @property - def device_class(self): - """Return the device class.""" - if self.home_variable == "outdoor temperature": - return SensorDeviceClass.TEMPERATURE - return None - - @property - def state_class(self): - """Return the state class.""" - if self.home_variable in ["outdoor temperature", "solar percentage"]: - return SensorStateClass.MEASUREMENT - return None - @callback def _async_update_callback(self): """Update and write state.""" @@ -169,46 +218,37 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): def _async_update_home_data(self): """Handle update callbacks.""" try: - self._tado_weather_data = self._tado.data["weather"] + tado_weather_data = self._tado.data["weather"] except KeyError: return - if self.home_variable == "outdoor temperature": - self._state = self._tado_weather_data["outsideTemperature"]["celsius"] - self._state_attributes = { - "time": self._tado_weather_data["outsideTemperature"]["timestamp"], - } - - elif self.home_variable == "solar percentage": - self._state = self._tado_weather_data["solarIntensity"]["percentage"] - self._state_attributes = { - "time": self._tado_weather_data["solarIntensity"]["timestamp"], - } - - elif self.home_variable == "weather condition": - self._state = format_condition( - self._tado_weather_data["weatherState"]["value"] + self._attr_native_value = self.entity_description.state_fn(tado_weather_data) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + tado_weather_data ) - self._state_attributes = { - "time": self._tado_weather_data["weatherState"]["timestamp"] - } class TadoZoneSensor(TadoZoneEntity, SensorEntity): """Representation of a tado Sensor.""" - def __init__(self, tado, zone_name, zone_id, zone_variable): + entity_description: TadoSensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + tado, + zone_name, + zone_id, + entity_description: TadoSensorEntityDescription, + ) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description self._tado = tado super().__init__(zone_name, tado.home_id, zone_id) - self.zone_variable = zone_variable - - self._unique_id = f"{zone_variable} {zone_id} {tado.home_id}" - - self._state = None - self._state_attributes = None - self._tado_zone_data = None + self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -224,54 +264,6 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): ) self._async_update_zone_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.zone_name} {self.zone_variable}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._state_attributes - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - if self.zone_variable == "temperature": - return UnitOfTemperature.CELSIUS - if self.zone_variable == "humidity": - return PERCENTAGE - if self.zone_variable == "heating": - return PERCENTAGE - if self.zone_variable == "ac": - return None - - @property - def device_class(self): - """Return the device class.""" - if self.zone_variable == "humidity": - return SensorDeviceClass.HUMIDITY - if self.zone_variable == "temperature": - return SensorDeviceClass.TEMPERATURE - return None - - @property - def state_class(self): - """Return the state class.""" - if self.zone_variable in ["heating", "humidity", "temperature"]: - return SensorStateClass.MEASUREMENT - return None - @callback def _async_update_callback(self): """Update and write state.""" @@ -282,32 +274,12 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): def _async_update_zone_data(self): """Handle update callbacks.""" try: - self._tado_zone_data = self._tado.data["zone"][self.zone_id] + tado_zone_data = self._tado.data["zone"][self.zone_id] except KeyError: return - if self.zone_variable == "temperature": - self._state = self._tado_zone_data.current_temp - self._state_attributes = { - "time": self._tado_zone_data.current_temp_timestamp, - "setting": 0, # setting is used in climate device - } - - elif self.zone_variable == "humidity": - self._state = self._tado_zone_data.current_humidity - self._state_attributes = { - "time": self._tado_zone_data.current_humidity_timestamp - } - - elif self.zone_variable == "heating": - self._state = self._tado_zone_data.heating_power_percentage - self._state_attributes = { - "time": self._tado_zone_data.heating_power_timestamp - } - - elif self.zone_variable == "ac": - self._state = self._tado_zone_data.ac_power - self._state_attributes = {"time": self._tado_zone_data.ac_power_timestamp} - - elif self.zone_variable == "tado mode": - self._state = self._tado_zone_data.tado_mode + self._attr_native_value = self.entity_description.state_fn(tado_zone_data) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + tado_zone_data + ) diff --git a/homeassistant/components/tado/services.yaml b/homeassistant/components/tado/services.yaml index 3c5a830698..211ae4cd1f 100644 --- a/homeassistant/components/tado/services.yaml +++ b/homeassistant/components/tado/services.yaml @@ -41,7 +41,7 @@ set_water_heater_timer: target: entity: integration: tado - domain: climate + domain: water_heater fields: time_period: name: Time period diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 090835103f..cd0dd00afe 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -59,7 +59,7 @@ class TagIDManager(collection.IDManager): return suggestion -class TagStorageCollection(collection.StorageCollection): +class TagStorageCollection(collection.DictStorageCollection): """Tag collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -80,9 +80,9 @@ class TagStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[TAG_ID] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" - data = {**data, **self.UPDATE_SCHEMA(update_data)} + data = {**item, **self.UPDATE_SCHEMA(update_data)} # make last_scanned JSON serializeable if LAST_SCANNED in update_data: data[LAST_SCANNED] = data[LAST_SCANNED].isoformat() @@ -95,11 +95,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: id_manager = TagIDManager() hass.data[DOMAIN][TAGS] = storage_collection = TagStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py index 2bc23655a2..d84087b313 100644 --- a/homeassistant/components/tasmota/binary_sensor.py +++ b/homeassistant/components/tasmota/binary_sensor.py @@ -58,17 +58,17 @@ class TasmotaBinarySensor( ): """Representation a Tasmota binary sensor.""" - _attr_force_update = True + _delay_listener: Callable | None = None + _on_off_state: bool | None = None _tasmota_entity: tasmota_switch.TasmotaSwitch def __init__(self, **kwds: Any) -> None: """Initialize the Tasmota binary sensor.""" - self._delay_listener: Callable | None = None - self._on_off_state: bool | None = None - super().__init__( **kwds, ) + if self._tasmota_entity.off_delay is not None: + self._attr_force_update = True async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index d9f417b2c8..bfa6d01032 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -159,8 +159,16 @@ class TasmotaDiscoveryUpdate(TasmotaEntity): self._removed_from_hass = False await super().async_added_to_hass() - async def discovery_callback(config: TasmotaEntityConfig) -> None: - """Handle discovery update.""" + @callback + def discovery_callback(config: TasmotaEntityConfig) -> None: + """Handle discovery update. + + If the config has changed we will create a task to + do the discovery update. + + As this callback can fire when nothing has changed, this + is a normal function to avoid task creation until it is needed. + """ _LOGGER.debug( "Got update for entity with hash: %s '%s'", self._discovery_hash, @@ -169,7 +177,7 @@ class TasmotaDiscoveryUpdate(TasmotaEntity): if not self._tasmota_entity.config_same(config): # Changed payload: Notify component _LOGGER.debug("Updating component: %s", self.entity_id) - await self.discovery_update(config) + self.hass.async_create_task(self.discovery_update(config)) else: # Unchanged payload: Ignore to avoid changing states _LOGGER.debug("Ignoring unchanged update for: %s", self.entity_id) diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index 61c03b707c..ddcdb3e8c2 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -268,7 +268,6 @@ async def async_setup_entry( class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): """Representation of a Tasmota sensor.""" - _attr_force_update = True _tasmota_entity: tasmota_sensor.TasmotaSensor def __init__(self, **kwds: Any) -> None: diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 1e0fdfacc8..256773b714 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -52,6 +52,7 @@ _VALID_STATES = [ STATE_CLOSING, "true", "false", + "none", ] CONF_POSITION_TEMPLATE = "position_template" @@ -238,6 +239,10 @@ class CoverTemplate(TemplateEntity, CoverEntity): @callback def _update_position(self, result): + if result is None: + self._position = None + return + try: state = float(result) except ValueError as err: @@ -256,6 +261,10 @@ class CoverTemplate(TemplateEntity, CoverEntity): @callback def _update_tilt(self, result): + if result is None: + self._tilt_value = None + return + try: state = float(result) except ValueError as err: diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index b5696003c9..72165ddbf5 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -1,40 +1,16 @@ """Trigger entity.""" from __future__ import annotations -import logging -from typing import Any - -from homeassistant.const import ( - ATTR_ENTITY_PICTURE, - ATTR_FRIENDLY_NAME, - ATTR_ICON, - CONF_DEVICE_CLASS, - CONF_ICON, - CONF_NAME, - CONF_UNIQUE_ID, -) -from homeassistant.core import HomeAssistant, State, callback -from homeassistant.exceptions import TemplateError -from homeassistant.helpers import template +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.template_entity import TriggerBaseEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import TriggerUpdateCoordinator -from .const import CONF_ATTRIBUTES, CONF_AVAILABILITY, CONF_PICTURE - -CONF_TO_ATTRIBUTE = { - CONF_ICON: ATTR_ICON, - CONF_NAME: ATTR_FRIENDLY_NAME, - CONF_PICTURE: ATTR_ENTITY_PICTURE, -} -class TriggerEntity(CoordinatorEntity[TriggerUpdateCoordinator]): +class TriggerEntity(TriggerBaseEntity, CoordinatorEntity[TriggerUpdateCoordinator]): """Template entity based on trigger data.""" - domain: str - extra_template_keys: tuple | None = None - extra_template_keys_complex: tuple | None = None - def __init__( self, hass: HomeAssistant, @@ -42,107 +18,22 @@ class TriggerEntity(CoordinatorEntity[TriggerUpdateCoordinator]): config: dict, ) -> None: """Initialize the entity.""" - super().__init__(coordinator) - - entity_unique_id = config.get(CONF_UNIQUE_ID) - - self._unique_id: str | None - if entity_unique_id and coordinator.unique_id: - self._unique_id = f"{coordinator.unique_id}-{entity_unique_id}" - else: - self._unique_id = entity_unique_id - - self._config = config - - self._static_rendered = {} - self._to_render_simple = [] - self._to_render_complex: list[str] = [] - - for itm in ( - CONF_AVAILABILITY, - CONF_ICON, - CONF_NAME, - CONF_PICTURE, - ): - if itm not in config: - continue - - if config[itm].is_static: - self._static_rendered[itm] = config[itm].template - else: - self._to_render_simple.append(itm) - - if self.extra_template_keys is not None: - self._to_render_simple.extend(self.extra_template_keys) - - if self.extra_template_keys_complex is not None: - self._to_render_complex.extend(self.extra_template_keys_complex) - - # We make a copy so our initial render is 'unknown' and not 'unavailable' - self._rendered = dict(self._static_rendered) - self._parse_result = {CONF_AVAILABILITY} - - @property - def name(self): - """Name of the entity.""" - return self._rendered.get(CONF_NAME) - - @property - def unique_id(self): - """Return unique ID of the entity.""" - return self._unique_id - - @property - def device_class(self): - """Return device class of the entity.""" - return self._config.get(CONF_DEVICE_CLASS) - - @property - def icon(self) -> str | None: - """Return icon.""" - return self._rendered.get(CONF_ICON) - - @property - def entity_picture(self) -> str | None: - """Return entity picture.""" - return self._rendered.get(CONF_PICTURE) - - @property - def available(self): - """Return availability of the entity.""" - return ( - self._rendered is not self._static_rendered - and - # Check against False so `None` is ok - self._rendered.get(CONF_AVAILABILITY) is not False - ) - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return extra attributes.""" - return self._rendered.get(CONF_ATTRIBUTES) + super(CoordinatorEntity, self).__init__(coordinator) + super().__init__(hass, config) async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" - template.attach(self.hass, self._config) await super().async_added_to_hass() + await super(CoordinatorEntity, self).async_added_to_hass() if self.coordinator.data is not None: self._process_data() - def restore_attributes(self, last_state: State) -> None: - """Restore attributes.""" - for conf_key, attr in CONF_TO_ATTRIBUTE.items(): - if conf_key not in self._config or attr not in last_state.attributes: - continue - self._rendered[conf_key] = last_state.attributes[attr] - - if CONF_ATTRIBUTES in self._config: - extra_state_attributes = {} - for attr in self._config[CONF_ATTRIBUTES]: - if attr not in last_state.attributes: - continue - extra_state_attributes[attr] = last_state.attributes[attr] - self._rendered[CONF_ATTRIBUTES] = extra_state_attributes + def _set_unique_id(self, unique_id: str | None) -> None: + """Set unique id.""" + if unique_id and self.coordinator.unique_id: + self._unique_id = f"{self.coordinator.unique_id}-{unique_id}" + else: + self._unique_id = unique_id @callback def _process_data(self) -> None: @@ -154,33 +45,7 @@ class TriggerEntity(CoordinatorEntity[TriggerUpdateCoordinator]): run_variables = self.coordinator.data["run_variables"] variables = {"this": this, **(run_variables or {})} - try: - rendered = dict(self._static_rendered) - - for key in self._to_render_simple: - rendered[key] = self._config[key].async_render( - variables, - parse_result=key in self._parse_result, - ) - - for key in self._to_render_complex: - rendered[key] = template.render_complex( - self._config[key], - variables, - ) - - if CONF_ATTRIBUTES in self._config: - rendered[CONF_ATTRIBUTES] = template.render_complex( - self._config[CONF_ATTRIBUTES], - variables, - ) - - self._rendered = rendered - except TemplateError as err: - logging.getLogger(f"{__package__}.{self.entity_id.split('.')[0]}").error( - "Error rendering %s template for %s: %s", key, self.entity_id, err - ) - self._rendered = self._static_rendered + self._render_templates(variables) self.async_set_context(self.coordinator.data["context"]) diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 668467c88d..2178930199 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,6 +10,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.1", "numpy==1.23.2", - "pillow==9.4.0" + "pillow==9.5.0" ] } diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index ea5a16f90c..bcadf3ca5f 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -1,6 +1,7 @@ """Persistently store thread datasets.""" from __future__ import annotations +from contextlib import suppress import dataclasses from datetime import datetime from functools import cached_property @@ -35,6 +36,15 @@ class DatasetEntry: created: datetime = dataclasses.field(default_factory=dt_util.utcnow) id: str = dataclasses.field(default_factory=ulid_util.ulid) + @property + def channel(self) -> int | None: + """Return channel as an integer.""" + if (channel := self.dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None: + return None + with suppress(ValueError): + return int(channel, 16) + return None + @cached_property def dataset(self) -> dict[tlv_parser.MeshcopTLVType, str]: """Return the dataset in dict format.""" @@ -72,7 +82,7 @@ class DatasetStore: """Initialize the dataset store.""" self.hass = hass self.datasets: dict[str, DatasetEntry] = {} - self.preferred_dataset: str | None = None + self._preferred_dataset: str | None = None self._store: Store[dict[str, Any]] = Store( hass, STORAGE_VERSION_MAJOR, @@ -93,14 +103,14 @@ class DatasetStore: entry = DatasetEntry(source=source, tlv=tlv) self.datasets[entry.id] = entry # Set to preferred if there is no preferred dataset - if self.preferred_dataset is None: - self.preferred_dataset = entry.id + if self._preferred_dataset is None: + self._preferred_dataset = entry.id self.async_schedule_save() @callback def async_delete(self, dataset_id: str) -> None: """Delete dataset.""" - if self.preferred_dataset == dataset_id: + if self._preferred_dataset == dataset_id: raise DatasetPreferredError("attempt to remove preferred dataset") del self.datasets[dataset_id] self.async_schedule_save() @@ -110,6 +120,21 @@ class DatasetStore: """Get dataset by id.""" return self.datasets.get(dataset_id) + @property + @callback + def preferred_dataset(self) -> str | None: + """Get the id of the preferred dataset.""" + return self._preferred_dataset + + @preferred_dataset.setter + @callback + def preferred_dataset(self, dataset_id: str) -> None: + """Set the preferred dataset.""" + if dataset_id not in self.datasets: + raise KeyError("unknown dataset") + self._preferred_dataset = dataset_id + self.async_schedule_save() + async def async_load(self) -> None: """Load the datasets.""" data = await self._store.async_load() @@ -129,7 +154,7 @@ class DatasetStore: preferred_dataset = data["preferred_dataset"] self.datasets = datasets - self.preferred_dataset = preferred_dataset + self._preferred_dataset = preferred_dataset @callback def async_schedule_save(self) -> None: @@ -141,7 +166,7 @@ class DatasetStore: """Return data of datasets to store in a file.""" data: dict[str, Any] = {} data["datasets"] = [dataset.to_json() for dataset in self.datasets.values()] - data["preferred_dataset"] = self.preferred_dataset + data["preferred_dataset"] = self._preferred_dataset return data diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 7dce5a429d..1006a44d5d 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -17,6 +17,7 @@ _LOGGER = logging.getLogger(__name__) KNOWN_BRANDS: dict[str | None, str] = { "Apple Inc.": "apple", + "eero": "eero", "Google Inc.": "google", "HomeAssistant": "homeassistant", "Home Assistant": "homeassistant", diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 9f9bc3455a..60941426b7 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -65,13 +65,14 @@ async def ws_set_preferred_dataset( dataset_id = msg["dataset_id"] store = await dataset_store.async_get_store(hass) - if not (store.async_get(dataset_id)): + try: + store.preferred_dataset = dataset_id + except KeyError: connection.send_error( msg["id"], websocket_api.const.ERR_NOT_FOUND, "unknown dataset" ) return - store.preferred_dataset = dataset_id connection.send_result(msg["id"]) @@ -144,6 +145,7 @@ async def ws_list_datasets( for dataset in store.datasets.values(): result.append( { + "channel": dataset.channel, "created": dataset.created, "dataset_id": dataset.id, "extended_pan_id": dataset.extended_pan_id, diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 0badf7eb41..538655ec0c 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -114,6 +114,15 @@ async def async_setup_platform( ) +def _threshold_type(lower: float | None, upper: float | None) -> str: + """Return the type of threshold this sensor represents.""" + if lower is not None and upper is not None: + return TYPE_RANGE + if lower is not None: + return TYPE_LOWER + return TYPE_UPPER + + class ThresholdSensor(BinarySensorEntity): """Representation of a Threshold sensor.""" @@ -134,8 +143,11 @@ class ThresholdSensor(BinarySensorEntity): self._attr_unique_id = unique_id self._entity_id = entity_id self._name = name - self._threshold_lower = lower - self._threshold_upper = upper + if lower is not None: + self._threshold_lower = lower + if upper is not None: + self._threshold_upper = upper + self.threshold_type = _threshold_type(lower, upper) self._hysteresis: float = hysteresis self._device_class = device_class self._state_position = POSITION_UNKNOWN @@ -187,26 +199,17 @@ class ThresholdSensor(BinarySensorEntity): """Return the sensor class of the sensor.""" return self._device_class - @property - def threshold_type(self) -> str: - """Return the type of threshold this sensor represents.""" - if self._threshold_lower is not None and self._threshold_upper is not None: - return TYPE_RANGE - if self._threshold_lower is not None: - return TYPE_LOWER - return TYPE_UPPER - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, ATTR_HYSTERESIS: self._hysteresis, - ATTR_LOWER: self._threshold_lower, + ATTR_LOWER: getattr(self, "_threshold_lower", None), ATTR_POSITION: self._state_position, ATTR_SENSOR_VALUE: self.sensor_value, ATTR_TYPE: self.threshold_type, - ATTR_UPPER: self._threshold_upper, + ATTR_UPPER: getattr(self, "_threshold_upper", None), } @callback @@ -223,30 +226,42 @@ class ThresholdSensor(BinarySensorEntity): if self.sensor_value is None: self._state_position = POSITION_UNKNOWN - self._state = False + self._state = None return - if self.threshold_type == TYPE_LOWER and self._threshold_lower is not None: + if self.threshold_type == TYPE_LOWER: + if self._state is None: + self._state = False + self._state_position = POSITION_ABOVE + if below(self.sensor_value, self._threshold_lower): self._state_position = POSITION_BELOW self._state = True elif above(self.sensor_value, self._threshold_lower): self._state_position = POSITION_ABOVE self._state = False + return + + if self.threshold_type == TYPE_UPPER: + assert self._threshold_upper is not None + + if self._state is None: + self._state = False + self._state_position = POSITION_BELOW - if self.threshold_type == TYPE_UPPER and self._threshold_upper is not None: if above(self.sensor_value, self._threshold_upper): self._state_position = POSITION_ABOVE self._state = True elif below(self.sensor_value, self._threshold_upper): self._state_position = POSITION_BELOW self._state = False + return + + if self.threshold_type == TYPE_RANGE: + if self._state is None: + self._state = True + self._state_position = POSITION_IN_RANGE - if ( - self.threshold_type == TYPE_RANGE - and self._threshold_lower is not None - and self._threshold_upper is not None - ): if below(self.sensor_value, self._threshold_lower): self._state_position = POSITION_BELOW self._state = False @@ -258,3 +273,4 @@ class ThresholdSensor(BinarySensorEntity): ): self._state_position = POSITION_IN_RANGE self._state = True + return diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index e716192b8b..d6df026bbd 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.27.0"] + "requirements": ["pyTibber==0.27.1"] } diff --git a/homeassistant/components/tile/manifest.json b/homeassistant/components/tile/manifest.json index 78208c7a1c..6f311fc559 100644 --- a/homeassistant/components/tile/manifest.json +++ b/homeassistant/components/tile/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pytile"], - "requirements": ["pytile==2022.02.0"] + "requirements": ["pytile==2023.04.0"] } diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 62d962ee52..7cb2c10425 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -119,7 +119,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = TimerStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -131,7 +130,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) @@ -163,7 +162,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class TimerStorageCollection(collection.StorageCollection): +class TimerStorageCollection(collection.DictStorageCollection): """Timer storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -180,9 +179,9 @@ class TimerStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" - data = {CONF_ID: data[CONF_ID]} | self.CREATE_UPDATE_SCHEMA(update_data) + data = {CONF_ID: item[CONF_ID]} | self.CREATE_UPDATE_SCHEMA(update_data) # make duration JSON serializeable if CONF_DURATION in update_data: data[CONF_DURATION] = _format_timedelta(data[CONF_DURATION]) diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index b6dd2418ad..217de09a53 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -6,6 +6,27 @@ "active": "[%key:common::state::active%]", "idle": "[%key:common::state::idle%]", "paused": "[%key:common::state::paused%]" + }, + "state_attributes": { + "duration": { + "name": "Duration" + }, + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "finishes_at": { + "name": "Finishes at" + }, + "remaining": { + "name": "Remaining" + }, + "restore": { + "name": "Restore" + } } } } diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index dd94b4c11b..7fe8630cc9 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -18,8 +18,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -ICON = "mdi:bus-clock" - CONF_APP_ID = "app_id" CONF_APP_KEY = "app_key" CONF_LINE = "line" @@ -74,6 +72,7 @@ class TMBSensor(SensorEntity): """Implementation of a TMB line/stop Sensor.""" _attr_attribution = "Data provided by Transport Metropolitans de Barcelona" + _attr_icon = "mdi:bus-clock" def __init__(self, ibus_client, stop, line, name): """Initialize the sensor.""" @@ -89,11 +88,6 @@ class TMBSensor(SensorEntity): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit of measurement.""" diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 8fdafee6cf..98910d7af3 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -1,9 +1,7 @@ """Support for Todoist task management (https://todoist.com).""" from __future__ import annotations -import asyncio from datetime import date, datetime, timedelta -from itertools import chain import logging from typing import Any import uuid @@ -11,7 +9,7 @@ import uuid from todoist_api_python.api_async import TodoistAPIAsync from todoist_api_python.endpoints import get_sync_url from todoist_api_python.headers import create_headers -from todoist_api_python.models import Label, Task +from todoist_api_python.models import Due, Label, Task import voluptuous as vol from homeassistant.components.calendar import ( @@ -19,12 +17,13 @@ from homeassistant.components.calendar import ( CalendarEntity, CalendarEvent, ) -from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt from .const import ( @@ -56,6 +55,7 @@ from .const import ( START, SUMMARY, ) +from .coordinator import TodoistCoordinator from .types import CalData, CustomProject, ProjectData, TodoistEvent _LOGGER = logging.getLogger(__name__) @@ -117,18 +117,20 @@ async def async_setup_platform( # Look up IDs based on (lowercase) names. project_id_lookup = {} - label_id_lookup = {} - collaborator_id_lookup = {} api = TodoistAPIAsync(token) + coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api) + await coordinator.async_config_entry_first_refresh() + + async def _shutdown_coordinator(_: Event) -> None: + await coordinator.async_shutdown() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_coordinator) # Setup devices: # Grab all projects. projects = await api.get_projects() - collaborator_tasks = (api.get_collaborators(project.id) for project in projects) - collaborators = list(chain.from_iterable(await asyncio.gather(*collaborator_tasks))) - # Grab all labels labels = await api.get_labels() @@ -138,17 +140,10 @@ async def async_setup_platform( # Project is an object, not a dict! # Because of that, we convert what we need to a dict. project_data: ProjectData = {CONF_NAME: project.name, CONF_ID: project.id} - project_devices.append(TodoistProjectEntity(project_data, labels, api)) + project_devices.append(TodoistProjectEntity(coordinator, project_data, labels)) # Cache the names so we can easily look up name->ID. project_id_lookup[project.name.lower()] = project.id - # Cache all label names - label_id_lookup = {label.name.lower(): label.id for label in labels} - - collaborator_id_lookup = { - collab.name.lower(): collab.id for collab in collaborators - } - # Check config for more projects. extra_projects: list[CustomProject] = config[CONF_EXTRA_PROJECTS] for extra_project in extra_projects: @@ -171,9 +166,9 @@ async def async_setup_platform( # Create the custom project and add it to the devices array. project_devices.append( TodoistProjectEntity( + coordinator, {"id": None, "name": extra_project["name"]}, labels, - api, due_date_days=project_due_date, whitelisted_labels=project_label_filter, whitelisted_projects=project_id_filter, @@ -194,14 +189,16 @@ async def async_setup_platform( data: dict[str, Any] = {"project_id": project_id} if task_labels := call.data.get(LABELS): - data["label_ids"] = [ - label_id_lookup[label.lower()] for label in task_labels - ] + data["labels"] = task_labels if ASSIGNEE in call.data: + collaborators = await api.get_collaborators(project_id) + collaborator_id_lookup = { + collab.name.lower(): collab.id for collab in collaborators + } task_assignee = call.data[ASSIGNEE].lower() if task_assignee in collaborator_id_lookup: - data["assignee"] = collaborator_id_lookup[task_assignee] + data["assignee_id"] = collaborator_id_lookup[task_assignee] else: raise ValueError( f"User is not part of the shared project. user: {task_assignee}" @@ -279,23 +276,24 @@ async def async_setup_platform( ) -class TodoistProjectEntity(CalendarEntity): +class TodoistProjectEntity(CoordinatorEntity[TodoistCoordinator], CalendarEntity): """A device for getting the next Task from a Todoist Project.""" def __init__( self, + coordinator: TodoistCoordinator, data: ProjectData, labels: list[Label], - api: TodoistAPIAsync, due_date_days: int | None = None, whitelisted_labels: list[str] | None = None, whitelisted_projects: list[str] | None = None, ) -> None: """Create the Todoist Calendar Entity.""" + super().__init__(coordinator=coordinator) self.data = TodoistProjectData( data, labels, - api, + coordinator, due_date_days=due_date_days, whitelisted_labels=whitelisted_labels, whitelisted_projects=whitelisted_projects, @@ -318,6 +316,7 @@ class TodoistProjectEntity(CalendarEntity): async def async_update(self) -> None: """Update all Todoist Calendars.""" + await super().async_update() await self.data.async_update() # Set Todoist-specific data that can't easily be grabbed self._cal_data["all_tasks"] = [ @@ -385,7 +384,7 @@ class TodoistProjectData: self, project_data: ProjectData, labels: list[Label], - api: TodoistAPIAsync, + coordinator: TodoistCoordinator, due_date_days: int | None = None, whitelisted_labels: list[str] | None = None, whitelisted_projects: list[str] | None = None, @@ -393,7 +392,7 @@ class TodoistProjectData: """Initialize a Todoist Project.""" self.event: TodoistEvent | None = None - self._api = api + self._coordinator = coordinator self._name = project_data[CONF_NAME] # If no ID is defined, fetch all tasks. self._id = project_data.get(CONF_ID) @@ -452,7 +451,7 @@ class TodoistProjectData: LABELS: [], OVERDUE: False, PRIORITY: data.priority, - START: dt.utcnow(), + START: dt.now(), SUMMARY: data.content, } @@ -581,8 +580,8 @@ class TodoistProjectData: self, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all tasks in a specific time frame.""" + tasks = self._coordinator.data if self._id is None: - tasks = await self._api.get_tasks() project_task_data = [ task for task in tasks @@ -590,37 +589,31 @@ class TodoistProjectData: or task.project_id in self._project_id_whitelist ] else: - project_task_data = await self._api.get_tasks(project_id=self._id) + project_task_data = [task for task in tasks if task.project_id == self._id] events = [] for task in project_task_data: if task.due is None: continue - due_date = dt.parse_datetime( - task.due.datetime if task.due.datetime else task.due.date - ) - if not due_date: + start = get_start(task.due) + if start is None: continue - due_date = dt.as_utc(due_date) - if start_date < due_date < end_date: - due_date_value: datetime | date = due_date - midnight = dt.start_of_local_day(due_date) - if due_date == midnight: - # If the due date has no time data, return just the date so that it - # will render correctly as an all day event on a calendar. - due_date_value = due_date.date() - event = CalendarEvent( - summary=task.content, - start=due_date_value, - end=due_date_value, - ) - events.append(event) + event = CalendarEvent( + summary=task.content, + start=start, + end=start + timedelta(days=1), + ) + if event.start_datetime_local >= end_date: + continue + if event.end_datetime_local < start_date: + continue + events.append(event) return events async def async_update(self) -> None: """Get the latest data.""" + tasks = self._coordinator.data if self._id is None: - tasks = await self._api.get_tasks() project_task_data = [ task for task in tasks @@ -628,7 +621,7 @@ class TodoistProjectData: or task.project_id in self._project_id_whitelist ] else: - project_task_data = await self._api.get_tasks(project_id=self._id) + project_task_data = [task for task in tasks if task.project_id == self._id] # If we have no data, we can just return right away. if not project_task_data: @@ -669,3 +662,15 @@ class TodoistProjectData: return self.event = event _LOGGER.debug("Updated %s", self._name) + + +def get_start(due: Due) -> datetime | date | None: + """Return the task due date as a start date or date time.""" + if due.datetime: + start = dt.parse_datetime(due.datetime) + if not start: + return None + return dt.as_local(start) + if due.date: + return dt.parse_date(due.date) + return None diff --git a/homeassistant/components/todoist/coordinator.py b/homeassistant/components/todoist/coordinator.py new file mode 100644 index 0000000000..b573d1d112 --- /dev/null +++ b/homeassistant/components/todoist/coordinator.py @@ -0,0 +1,31 @@ +"""DataUpdateCoordinator for the Todoist component.""" +from datetime import timedelta +import logging + +from todoist_api_python.api_async import TodoistAPIAsync +from todoist_api_python.models import Task + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + + +class TodoistCoordinator(DataUpdateCoordinator[list[Task]]): + """Coordinator for updating task data from Todoist.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + update_interval: timedelta, + api: TodoistAPIAsync, + ) -> None: + """Initialize the Todoist coordinator.""" + super().__init__(hass, logger, name="Todoist", update_interval=update_interval) + self.api = api + + async def _async_update_data(self) -> list[Task]: + """Fetch tasks from the Todoist API.""" + try: + return await self.api.get_tasks() + except Exception as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 9606dc29a4..48090d7570 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -71,7 +71,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_trigger_discovery(hass, discovered) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_discovery) - async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL) + async_track_time_interval( + hass, _async_discovery, DISCOVERY_INTERVAL, cancel_on_shutdown=True + ) return True diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 709ad52012..824ea8df42 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -44,7 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unexpected error connecting to Omada controller: {ex}" ) from ex - site_client = await client.get_site_client(OmadaSite(None, entry.data[CONF_SITE])) + site_client = await client.get_site_client(OmadaSite("", entry.data[CONF_SITE])) controller = OmadaSiteController(hass, site_client) hass.data[DOMAIN][entry.entry_id] = controller diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index b42cb37ff7..508a8b914d 100644 --- a/homeassistant/components/tplink_omada/controller.py +++ b/homeassistant/components/tplink_omada/controller.py @@ -1,7 +1,5 @@ """Controller for sharing Omada API coordinators between platforms.""" -from functools import partial - from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails from tplink_omada_client.omadasiteclient import OmadaSiteClient @@ -9,13 +7,28 @@ from homeassistant.core import HomeAssistant from .coordinator import OmadaCoordinator +POLL_SWITCH_PORT = 300 -async def _poll_switch_state( - client: OmadaSiteClient, network_switch: OmadaSwitch -) -> dict[str, OmadaSwitchPortDetails]: - """Poll a switch's current state.""" - ports = await client.get_switch_ports(network_switch) - return {p.port_id: p for p in ports} + +class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): + """Coordinator for getting details about ports on a switch.""" + + def __init__( + self, + hass: HomeAssistant, + omada_client: OmadaSiteClient, + network_switch: OmadaSwitch, + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, omada_client, f"{network_switch.name} Ports", POLL_SWITCH_PORT + ) + self._network_switch = network_switch + + async def poll_update(self) -> dict[str, OmadaSwitchPortDetails]: + """Poll a switch's current state.""" + ports = await self.omada_client.get_switch_ports(self._network_switch) + return {p.port_id: p for p in ports} class OmadaSiteController: @@ -26,9 +39,7 @@ class OmadaSiteController: self._hass = hass self._omada_client = omada_client - self._switch_port_coordinators: dict[ - str, OmadaCoordinator[OmadaSwitchPortDetails] - ] = {} + self._switch_port_coordinators: dict[str, OmadaSwitchPortCoordinator] = {} @property def omada_client(self) -> OmadaSiteClient: @@ -37,16 +48,11 @@ class OmadaSiteController: def get_switch_port_coordinator( self, switch: OmadaSwitch - ) -> OmadaCoordinator[OmadaSwitchPortDetails]: + ) -> OmadaSwitchPortCoordinator: """Get coordinator for network port information of a given switch.""" if switch.mac not in self._switch_port_coordinators: - self._switch_port_coordinators[switch.mac] = OmadaCoordinator[ - OmadaSwitchPortDetails - ]( - self._hass, - self._omada_client, - f"{switch.name} Ports", - partial(_poll_switch_state, network_switch=switch), + self._switch_port_coordinators[switch.mac] = OmadaSwitchPortCoordinator( + self._hass, self._omada_client, switch ) return self._switch_port_coordinators[switch.mac] diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index d73461dc78..3ff73501bd 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -1,5 +1,4 @@ """Generic Omada API coordinator.""" -from collections.abc import Awaitable, Callable from datetime import timedelta import logging from typing import Generic, TypeVar @@ -24,7 +23,6 @@ class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): hass: HomeAssistant, omada_client: OmadaSiteClient, name: str, - update_func: Callable[[OmadaSiteClient], Awaitable[dict[str, T]]], poll_delay: int = 300, ) -> None: """Initialize my coordinator.""" @@ -35,12 +33,15 @@ class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): update_interval=timedelta(seconds=poll_delay), ) self.omada_client = omada_client - self._update_func = update_func async def _async_update_data(self) -> dict[str, T]: """Fetch data from API endpoint.""" try: async with async_timeout.timeout(10): - return await self._update_func(self.omada_client) + return await self.poll_update() except OmadaClientException as err: raise UpdateFailed(f"Error communicating with API: {err}") from err + + async def poll_update(self) -> dict[str, T]: + """Poll the current data from the controller.""" + raise NotImplementedError("Update method not implemented") diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index a0fb58b3f6..280ae56bbd 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink-omada-client==1.1.3"] + "requirements": ["tplink-omada-client==1.2.4"] } diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index e85b1c181f..830f75b6a9 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -14,8 +14,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .controller import OmadaSiteController -from .coordinator import OmadaCoordinator +from .controller import OmadaSiteController, OmadaSwitchPortCoordinator from .entity import OmadaDeviceEntity POE_SWITCH_ICON = "mdi:ethernet" @@ -68,7 +67,7 @@ class OmadaNetworkSwitchPortPoEControl( def __init__( self, - coordinator: OmadaCoordinator[OmadaSwitchPortDetails], + coordinator: OmadaSwitchPortCoordinator, device: OmadaSwitch, port_id: str, ) -> None: diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index 5581f61d82..685ad9c576 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -1,24 +1,26 @@ -"""Support for TPLink Omada device toggle options.""" +"""Support for TPLink Omada device firmware updates.""" from __future__ import annotations -import logging +from datetime import timedelta from typing import Any, NamedTuple from tplink_omada_client.devices import OmadaFirmwareUpdate, OmadaListDevice +from tplink_omada_client.exceptions import OmadaClientException, RequestFailed from tplink_omada_client.omadasiteclient import OmadaSiteClient from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later from .const import DOMAIN from .controller import OmadaSiteController from .coordinator import OmadaCoordinator from .entity import OmadaDeviceEntity -_LOGGER = logging.getLogger(__name__) +POLL_DELAY_IDLE = 6 * 60 * 60 +POLL_DELAY_UPGRADE = 60 class FirmwareUpdateStatus(NamedTuple): @@ -28,24 +30,39 @@ class FirmwareUpdateStatus(NamedTuple): firmware: OmadaFirmwareUpdate | None -async def _get_firmware_updates(client: OmadaSiteClient) -> list[FirmwareUpdateStatus]: - devices = await client.get_devices() - return [ - FirmwareUpdateStatus( - device=d, - firmware=None - if not d.need_upgrade - else await client.get_firmware_details(d), +class OmadaFirmwareUpdateCoodinator(OmadaCoordinator[FirmwareUpdateStatus]): + """Coordinator for getting details about ports on a switch.""" + + def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: + """Initialize my coordinator.""" + super().__init__(hass, omada_client, "Firmware Updates", POLL_DELAY_IDLE) + + async def _get_firmware_updates(self) -> list[FirmwareUpdateStatus]: + devices = await self.omada_client.get_devices() + + updates = [ + FirmwareUpdateStatus( + device=d, + firmware=None + if not d.need_upgrade + else await self.omada_client.get_firmware_details(d), + ) + for d in devices + ] + + # During a firmware upgrade, poll more frequently + self.update_interval = timedelta( + seconds=( + POLL_DELAY_UPGRADE + if any(u.device.fw_download for u in updates) + else POLL_DELAY_IDLE + ) ) - for d in devices - ] + return updates - -async def _poll_firmware_updates( - client: OmadaSiteClient, -) -> dict[str, FirmwareUpdateStatus]: - """Poll the state of Omada Devices firmware update availability.""" - return {d.device.mac: d for d in await _get_firmware_updates(client)} + async def poll_update(self) -> dict[str, FirmwareUpdateStatus]: + """Poll the state of Omada Devices firmware update availability.""" + return {d.device.mac: d for d in await self._get_firmware_updates()} async def async_setup_entry( @@ -59,19 +76,9 @@ async def async_setup_entry( devices = await omada_client.get_devices() - coordinator = OmadaCoordinator[FirmwareUpdateStatus]( - hass, - omada_client, - "Firmware Updates", - _poll_firmware_updates, - poll_delay=6 * 60 * 60, - ) + coordinator = OmadaFirmwareUpdateCoodinator(hass, omada_client) - entities: list = [] - for device in devices: - entities.append(OmadaDeviceUpdate(coordinator, device)) - - async_add_entities(entities) + async_add_entities(OmadaDeviceUpdate(coordinator, device) for device in devices) await coordinator.async_request_refresh() @@ -86,64 +93,57 @@ class OmadaDeviceUpdate( | UpdateEntityFeature.PROGRESS | UpdateEntityFeature.RELEASE_NOTES ) - _firmware_update: OmadaFirmwareUpdate = None + _attr_has_entity_name = True + _attr_name = "Firmware update" def __init__( self, - coordinator: OmadaCoordinator[FirmwareUpdateStatus], + coordinator: OmadaFirmwareUpdateCoodinator, device: OmadaListDevice, ) -> None: """Initialize the update entity.""" super().__init__(coordinator, device) self._mac = device.mac - self._device = device self._omada_client = coordinator.omada_client self._attr_unique_id = f"{device.mac}_firmware" - self._attr_has_entity_name = True - self._attr_name = "Firmware Update" - self._refresh_state() - - def _refresh_state(self) -> None: - if self._firmware_update and self._device.need_upgrade: - self._attr_installed_version = self._firmware_update.current_version - self._attr_latest_version = self._firmware_update.latest_version - else: - self._attr_installed_version = self._device.firmware_version - self._attr_latest_version = self._device.firmware_version - self._attr_in_progress = self._device.fw_download - - if self._attr_in_progress: - # While firmware update is in progress, poll more frequently - async_call_later(self.hass, 60, self._request_refresh) - - async def _request_refresh(self, _now: Any) -> None: - await self.coordinator.async_request_refresh() def release_notes(self) -> str | None: """Get the release notes for the latest update.""" - if self._firmware_update: - return str(self._firmware_update.release_notes) - return "" + status = self.coordinator.data[self._mac] + if status.firmware: + return status.firmware.release_notes + return None async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install a firmware update.""" - if self._firmware_update and ( - version is None or self._firmware_update.latest_version == version - ): - await self._omada_client.start_firmware_upgrade(self._device) + try: + await self._omada_client.start_firmware_upgrade( + self.coordinator.data[self._mac].device + ) + except RequestFailed as ex: + raise HomeAssistantError("Firmware update request rejected") from ex + except OmadaClientException as ex: + raise HomeAssistantError( + "Unable to send Firmware update request. Check the controller is online." + ) from ex + finally: await self.coordinator.async_request_refresh() - else: - _LOGGER.error("Firmware upgrade is not available for %s", self._device.name) @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" status = self.coordinator.data[self._mac] - self._device = status.device - self._firmware_update = status.firmware - self._refresh_state() + + if status.firmware and status.device.need_upgrade: + self._attr_installed_version = status.firmware.current_version + self._attr_latest_version = status.firmware.latest_version + else: + self._attr_installed_version = status.device.firmware_version + self._attr_latest_version = status.device.firmware_version + self._attr_in_progress = status.device.fw_download + self.async_write_ha_state() diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 4581e28681..9ed7922fa1 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -243,7 +243,9 @@ class TraccarScanner: return False await self._async_update() - async_track_time_interval(self._hass, self._async_update, self._scan_interval) + async_track_time_interval( + self._hass, self._async_update, self._scan_interval, cancel_on_shutdown=True + ) return True async def _async_update(self, now=None): diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py index 3d7510b57b..5d0b188f72 100644 --- a/homeassistant/components/trace/__init__.py +++ b/homeassistant/components/trace/__init__.py @@ -14,6 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import ExtendedJSONEncoder from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType +from homeassistant.util.limited_size_dict import LimitedSizeDict from . import websocket_api from .const import ( @@ -24,7 +25,6 @@ from .const import ( DEFAULT_STORED_TRACES, ) from .models import ActionTrace, BaseTrace, RestoredTrace -from .utils import LimitedSizeDict _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py index c98bd0fa02..4b37694134 100644 --- a/homeassistant/components/tractive/binary_sensor.py +++ b/homeassistant/components/tractive/binary_sensor.py @@ -76,7 +76,7 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity): SENSOR_TYPE = BinarySensorEntityDescription( key=ATTR_BATTERY_CHARGING, - name="Tracker battery charging", + translation_key="tracker_battery_charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, entity_category=EntityCategory.DIAGNOSTIC, ) diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 0cb7372336..038461494d 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -38,7 +38,7 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): _attr_has_entity_name = True _attr_icon = "mdi:paw" - _attr_name = "Tracker" + _attr_translation_key = "tracker" def __init__(self, user_id: str, item: Trackables) -> None: """Initialize tracker entity.""" diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index d9b92386be..9c0f8f307e 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -138,7 +138,7 @@ class TractiveActivitySensor(TractiveSensor): SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( TractiveSensorEntityDescription( key=ATTR_BATTERY_LEVEL, - name="Tracker battery level", + translation_key="tracker_battery_level", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_class=TractiveHardwareSensor, @@ -148,20 +148,19 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( # Currently, only state operational and not_reporting are used # More states are available by polling the data key=ATTR_TRACKER_STATE, - name="Tracker state", translation_key="tracker_state", entity_class=TractiveHardwareSensor, ), TractiveSensorEntityDescription( key=ATTR_MINUTES_ACTIVE, - name="Minutes active", + translation_key="minutes_active", icon="mdi:clock-time-eight-outline", native_unit_of_measurement=UnitOfTime.MINUTES, entity_class=TractiveActivitySensor, ), TractiveSensorEntityDescription( key=ATTR_DAILY_GOAL, - name="Daily goal", + translation_key="daily_goal", icon="mdi:flag-checkered", native_unit_of_measurement=UnitOfTime.MINUTES, entity_class=TractiveActivitySensor, diff --git a/homeassistant/components/tractive/strings.json b/homeassistant/components/tractive/strings.json index f63f0cdf0a..d5aee51ed6 100644 --- a/homeassistant/components/tractive/strings.json +++ b/homeassistant/components/tractive/strings.json @@ -19,8 +19,28 @@ } }, "entity": { + "binary_sensor": { + "tracker_battery_charging": { + "name": "Tracker battery charging" + } + }, + "device_tracker": { + "tracker": { + "name": "Tracker" + } + }, "sensor": { + "daily_goal": { + "name": "Daily goal" + }, + "minutes_active": { + "name": "Minutes active" + }, + "tracker_battery_level": { + "name": "Tracker battery" + }, "tracker_state": { + "name": "Tracker state", "state": { "not_reporting": "Not reporting", "operational": "Operational", @@ -28,6 +48,17 @@ "system_startup": "System startup" } } + }, + "switch": { + "tracker_buzzer": { + "name": "Tracker buzzer" + }, + "tracker_led": { + "name": "Tracker LED" + }, + "live_tracking": { + "name": "Live tracking" + } } } } diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index a7f4a273bb..7ae480d4f9 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -47,21 +47,21 @@ class TractiveSwitchEntityDescription( SWITCH_TYPES: tuple[TractiveSwitchEntityDescription, ...] = ( TractiveSwitchEntityDescription( key=ATTR_BUZZER, - name="Tracker buzzer", + translation_key="tracker_buzzer", icon="mdi:volume-high", method="async_set_buzzer", entity_category=EntityCategory.CONFIG, ), TractiveSwitchEntityDescription( key=ATTR_LED, - name="Tracker LED", + translation_key="tracker_led", icon="mdi:led-on", method="async_set_led", entity_category=EntityCategory.CONFIG, ), TractiveSwitchEntityDescription( key=ATTR_LIVE_TRACKING, - name="Live tracking", + translation_key="live_tracking", icon="mdi:map-marker-path", method="async_set_live_tracking", entity_category=EntityCategory.CONFIG, diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index 517ef0a853..0e29bbbff5 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -10,9 +10,9 @@ ORDER_WORST_RATIO_FIRST = "worst_ratio_first" SUPPORTED_ORDER_MODES = { ORDER_NEWEST_FIRST: lambda torrents: sorted( - torrents, key=lambda t: t.addedDate, reverse=True + torrents, key=lambda t: t.date_added, reverse=True ), - ORDER_OLDEST_FIRST: lambda torrents: sorted(torrents, key=lambda t: t.addedDate), + ORDER_OLDEST_FIRST: lambda torrents: sorted(torrents, key=lambda t: t.date_added), ORDER_WORST_RATIO_FIRST: lambda torrents: sorted(torrents, key=lambda t: t.ratio), ORDER_BEST_RATIO_FIRST: lambda torrents: sorted( torrents, key=lambda t: t.ratio, reverse=True diff --git a/homeassistant/components/transmission/manifest.json b/homeassistant/components/transmission/manifest.json index 53441057a5..17b3bbbf49 100644 --- a/homeassistant/components/transmission/manifest.json +++ b/homeassistant/components/transmission/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/transmission", "iot_class": "local_polling", "loggers": ["transmissionrpc"], - "requirements": ["transmission-rpc==3.4.0"] + "requirements": ["transmission-rpc==4.1.5"] } diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 9147773131..184d05faeb 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -109,18 +109,19 @@ class TransmissionSpeedSensor(TransmissionSensor): """Representation of a Transmission speed sensor.""" _attr_device_class = SensorDeviceClass.DATA_RATE - _attr_native_unit_of_measurement = UnitOfDataRate.MEGABYTES_PER_SECOND + _attr_native_unit_of_measurement = UnitOfDataRate.BYTES_PER_SECOND + _attr_suggested_display_precision = 2 + _attr_suggested_unit_of_measurement = UnitOfDataRate.MEGABYTES_PER_SECOND def update(self) -> None: """Get the latest data from Transmission and updates the state.""" if data := self._tm_client.api.data: - mb_spd = ( - float(data.downloadSpeed) + b_spd = ( + float(data.download_speed) if self._sub_type == "download" - else float(data.uploadSpeed) + else float(data.upload_speed) ) - mb_spd = mb_spd / 1024 / 1024 - self._state = round(mb_spd, 2 if mb_spd < 0.1 else 1) + self._state = b_spd class TransmissionStatusSensor(TransmissionSensor): @@ -133,8 +134,8 @@ class TransmissionStatusSensor(TransmissionSensor): def update(self) -> None: """Get the latest data from Transmission and updates the state.""" if data := self._tm_client.api.data: - upload = data.uploadSpeed - download = data.downloadSpeed + upload = data.upload_speed + download = data.download_speed if upload > 0 and download > 0: self._state = STATE_UP_DOWN elif upload > 0 and download == 0: @@ -198,8 +199,8 @@ def _torrents_info(torrents, order, limit, statuses=None): torrents = SUPPORTED_ORDER_MODES[order](torrents) for torrent in torrents[:limit]: info = infos[torrent.name] = { - "added_date": torrent.addedDate, - "percent_done": f"{torrent.percentDone * 100:.2f}", + "added_date": torrent.date_added, + "percent_done": f"{torrent.percent_done * 100:.2f}", "status": torrent.status, "id": torrent.id, } diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index ed1b2f185a..e2c144d542 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -20,7 +20,7 @@ } }, "error": { - "name_exists": "[%key:common::config_flow::data::name%] already exists", + "name_exists": "Name already exists", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index ed771d2458..3af3fe57e3 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -118,7 +118,7 @@ class TransmissionSwitch(SwitchEntity): if self.type == "on_off": self._data = self._tm_client.api.data if self._data: - active = self._data.activeTorrentCount > 0 + active = self._data.active_torrent_count > 0 elif self.type == "turtle_mode": active = self._tm_client.api.get_alt_speed_enabled() diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 0d253d7d94..a90a69edcd 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -1,24 +1,26 @@ """Provide functionality for TTS.""" from __future__ import annotations +from abc import abstractmethod import asyncio -import functools as ft +from collections.abc import Mapping +from datetime import datetime +from functools import partial import hashlib from http import HTTPStatus import io import logging import mimetypes import os -from pathlib import Path import re -from typing import TYPE_CHECKING, Any, TypedDict, cast +from typing import Any, TypedDict, final from aiohttp import web import mutagen from mutagen.id3 import ID3, TextFrame as ID3Text import voluptuous as vol -import yarl +from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView from homeassistant.components.media_player import ( ATTR_MEDIA_ANNOUNCE, @@ -28,103 +30,81 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MediaType, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - CONF_DESCRIPTION, - CONF_NAME, - CONF_PLATFORM, PLATFORM_FORMAT, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url -from homeassistant.helpers.service import async_set_service_schema -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.setup import async_prepare_setup_platform -from homeassistant.util.network import normalize_url -from homeassistant.util.yaml import load_yaml +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util, language as language_util -from .const import DOMAIN +from .const import ( + ATTR_CACHE, + ATTR_LANGUAGE, + ATTR_MESSAGE, + ATTR_OPTIONS, + CONF_BASE_URL, + CONF_CACHE, + CONF_CACHE_DIR, + CONF_TIME_MEMORY, + DATA_TTS_MANAGER, + DEFAULT_CACHE, + DEFAULT_CACHE_DIR, + DEFAULT_TIME_MEMORY, + DOMAIN, + TtsAudioType, +) +from .helper import get_engine_instance +from .legacy import PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, Provider, async_setup_legacy from .media_source import generate_media_source_id, media_source_id_to_kwargs +from .models import Voice + +__all__ = [ + "async_default_engine", + "async_get_media_source_audio", + "async_support_options", + "ATTR_AUDIO_OUTPUT", + "CONF_LANG", + "DEFAULT_CACHE_DIR", + "generate_media_source_id", + "get_base_url", + "PLATFORM_SCHEMA_BASE", + "PLATFORM_SCHEMA", + "Provider", + "TtsAudioType", + "Voice", +] _LOGGER = logging.getLogger(__name__) -TtsAudioType = tuple[str | None, bytes | None] - -ATTR_CACHE = "cache" -ATTR_LANGUAGE = "language" -ATTR_MESSAGE = "message" -ATTR_OPTIONS = "options" ATTR_PLATFORM = "platform" +ATTR_AUDIO_OUTPUT = "audio_output" +ATTR_MEDIA_PLAYER_ENTITY_ID = "media_player_entity_id" +ATTR_VOICE = "voice" + +CONF_LANG = "language" BASE_URL_KEY = "tts_base_url" -CONF_BASE_URL = "base_url" -CONF_CACHE = "cache" -CONF_CACHE_DIR = "cache_dir" -CONF_LANG = "language" -CONF_SERVICE_NAME = "service_name" -CONF_TIME_MEMORY = "time_memory" - -CONF_FIELDS = "fields" - -DEFAULT_CACHE = True -DEFAULT_CACHE_DIR = "tts" -DEFAULT_TIME_MEMORY = 300 - SERVICE_CLEAR_CACHE = "clear_cache" -SERVICE_SAY = "say" -_RE_VOICE_FILE = re.compile(r"([a-f0-9]{40})_([^_]+)_([^_]+)_([a-z_]+)\.[a-z0-9]{3,4}") +_RE_LEGACY_VOICE_FILE = re.compile( + r"([a-f0-9]{40})_([^_]+)_([^_]+)_([a-z_]+)\.[a-z0-9]{3,4}" +) +_RE_VOICE_FILE = re.compile( + r"([a-f0-9]{40})_([^_]+)_([^_]+)_(tts\.[a-z0-9_]+)\.[a-z0-9]{3,4}" +) KEY_PATTERN = "{0}_{1}_{2}_{3}" - -def _deprecated_platform(value: str) -> str: - """Validate if platform is deprecated.""" - if value == "google": - raise vol.Invalid( - "google tts service has been renamed to google_translate," - " please update your configuration." - ) - return value - - -def valid_base_url(value: str) -> str: - """Validate base url, return value.""" - url = yarl.URL(cv.url(value)) - - if url.path != "/": - raise vol.Invalid("Path should be empty") - - return normalize_url(value) - - -PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_PLATFORM): vol.All(cv.string, _deprecated_platform), - vol.Optional(CONF_CACHE, default=DEFAULT_CACHE): cv.boolean, - vol.Optional(CONF_CACHE_DIR, default=DEFAULT_CACHE_DIR): cv.string, - vol.Optional(CONF_TIME_MEMORY, default=DEFAULT_TIME_MEMORY): vol.All( - vol.Coerce(int), vol.Range(min=60, max=57600) - ), - vol.Optional(CONF_BASE_URL): valid_base_url, - vol.Optional(CONF_SERVICE_NAME): cv.string, - } -) -PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE.extend(PLATFORM_SCHEMA.schema) - -SCHEMA_SERVICE_SAY = vol.Schema( - { - vol.Required(ATTR_MESSAGE): cv.string, - vol.Optional(ATTR_CACHE): cv.boolean, - vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, - vol.Optional(ATTR_LANGUAGE): cv.string, - vol.Optional(ATTR_OPTIONS): dict, - } -) - SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({}) @@ -133,6 +113,64 @@ class TTSCache(TypedDict): filename: str voice: bytes + pending: asyncio.Task | None + + +@callback +def async_default_engine(hass: HomeAssistant) -> str | None: + """Return the domain or entity id of the default engine. + + Returns None if no engines found. + """ + component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] + + if "cloud" in manager.providers: + return "cloud" + + entity = next(iter(component.entities), None) + + if entity is not None: + return entity.entity_id + + return next(iter(manager.providers), None) + + +@callback +def async_resolve_engine(hass: HomeAssistant, engine: str | None) -> str | None: + """Resolve engine. + + Returns None if no engines found or invalid engine passed in. + """ + component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] + + if engine is not None: + if not component.get_entity(engine) and engine not in manager.providers: + return None + return engine + + return async_default_engine(hass) + + +async def async_support_options( + hass: HomeAssistant, + engine: str, + language: str | None = None, + options: dict | None = None, +) -> bool: + """Return if an engine supports options.""" + if (engine_instance := get_engine_instance(hass, engine)) is None: + raise HomeAssistantError(f"Provider {engine} not found") + + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] + + try: + manager.process_options(engine_instance, language, options) + except HomeAssistantError: + return False + + return True async def async_get_media_source_audio( @@ -140,132 +178,84 @@ async def async_get_media_source_audio( media_source_id: str, ) -> tuple[str, bytes]: """Get TTS audio as extension, data.""" - manager: SpeechManager = hass.data[DOMAIN] + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] return await manager.async_get_tts_audio( **media_source_id_to_kwargs(media_source_id), ) +@callback +def async_get_text_to_speech_languages(hass: HomeAssistant) -> set[str]: + """Return a set with the union of languages supported by tts engines.""" + languages = set() + + component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] + + for entity in component.entities: + for language_tag in entity.supported_languages: + languages.add(language_tag) + + for tts_engine in manager.providers.values(): + for language_tag in tts_engine.supported_languages: + languages.add(language_tag) + + return languages + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up TTS.""" - tts = SpeechManager(hass) + websocket_api.async_register_command(hass, websocket_list_engines) + websocket_api.async_register_command(hass, websocket_list_engine_voices) + + # Legacy config options + conf = config[DOMAIN][0] if config.get(DOMAIN) else {} + use_cache: bool = conf.get(CONF_CACHE, DEFAULT_CACHE) + cache_dir: str = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR) + time_memory: int = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) + base_url: str | None = conf.get(CONF_BASE_URL) + if base_url is not None: + _LOGGER.warning( + "TTS base_url option is deprecated. Configure internal/external URL" + " instead" + ) + hass.data[BASE_URL_KEY] = base_url + + tts = SpeechManager(hass, use_cache, cache_dir, time_memory, base_url) try: - conf = config[DOMAIN][0] if config.get(DOMAIN, []) else {} - use_cache = conf.get(CONF_CACHE, DEFAULT_CACHE) - cache_dir = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR) - time_memory = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) - base_url = conf.get(CONF_BASE_URL) - if base_url is not None: - _LOGGER.warning( - "TTS base_url option is deprecated. Configure internal/external URL" - " instead" - ) - hass.data[BASE_URL_KEY] = base_url - - await tts.async_init_cache(use_cache, cache_dir, time_memory, base_url) + await tts.async_init_cache() except (HomeAssistantError, KeyError): _LOGGER.exception("Error on cache init") return False - hass.data[DOMAIN] = tts + hass.data[DATA_TTS_MANAGER] = tts + component = hass.data[DOMAIN] = EntityComponent[TextToSpeechEntity]( + _LOGGER, DOMAIN, hass + ) + + component.register_shutdown() + hass.http.register_view(TextToSpeechView(tts)) hass.http.register_view(TextToSpeechUrlView(tts)) - # Load service descriptions from tts/services.yaml - services_yaml = Path(__file__).parent / "services.yaml" - services_dict = cast( - dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + platform_setups = await async_setup_legacy(hass, config) + + if platform_setups: + await asyncio.wait([asyncio.create_task(setup) for setup in platform_setups]) + + component.async_register_entity_service( + "speak", + { + vol.Required(ATTR_MEDIA_PLAYER_ENTITY_ID): cv.comp_entity_ids, + vol.Required(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_CACHE, default=DEFAULT_CACHE): cv.boolean, + vol.Optional(ATTR_LANGUAGE): cv.string, + vol.Optional(ATTR_OPTIONS): dict, + }, + "async_speak", ) - async def async_setup_platform( - p_type: str, - p_config: ConfigType | None = None, - discovery_info: DiscoveryInfoType | None = None, - ) -> None: - """Set up a TTS platform.""" - if p_config is None: - p_config = {} - - platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) - if platform is None: - return - - try: - if hasattr(platform, "async_get_engine"): - provider = await platform.async_get_engine( - hass, p_config, discovery_info - ) - else: - provider = await hass.async_add_executor_job( - platform.get_engine, hass, p_config, discovery_info - ) - - if provider is None: - _LOGGER.error("Error setting up platform %s", p_type) - return - - tts.async_register_engine(p_type, provider, p_config) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error setting up platform: %s", p_type) - return - - async def async_say_handle(service: ServiceCall) -> None: - """Service handle for say.""" - entity_ids = service.data[ATTR_ENTITY_ID] - - await hass.services.async_call( - DOMAIN_MP, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: entity_ids, - ATTR_MEDIA_CONTENT_ID: generate_media_source_id( - hass, - engine=p_type, - message=service.data[ATTR_MESSAGE], - language=service.data.get(ATTR_LANGUAGE), - options=service.data.get(ATTR_OPTIONS), - cache=service.data.get(ATTR_CACHE), - ), - ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - ATTR_MEDIA_ANNOUNCE: True, - }, - blocking=True, - context=service.context, - ) - - service_name = p_config.get(CONF_SERVICE_NAME, f"{p_type}_{SERVICE_SAY}") - hass.services.async_register( - DOMAIN, service_name, async_say_handle, schema=SCHEMA_SERVICE_SAY - ) - - # Register the service description - service_desc = { - CONF_NAME: f"Say a TTS message with {p_type}", - CONF_DESCRIPTION: ( - f"Say something using text-to-speech on a media player with {p_type}." - ), - CONF_FIELDS: services_dict[SERVICE_SAY][CONF_FIELDS], - } - async_set_service_schema(hass, DOMAIN, service_name, service_desc) - - setup_tasks = [ - asyncio.create_task(async_setup_platform(p_type, p_config)) - for p_type, p_config in config_per_platform(config, DOMAIN) - if p_type is not None - ] - - if setup_tasks: - await asyncio.wait(setup_tasks) - - async def async_platform_discovered( - platform: str, info: dict[str, Any] | None - ) -> None: - """Handle for discovered platform.""" - await async_setup_platform(platform, discovery_info=info) - - discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) - async def async_clear_cache_handle(service: ServiceCall) -> None: """Handle clear cache service call.""" await tts.async_clear_cache() @@ -280,6 +270,129 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +class TextToSpeechEntity(RestoreEntity): + """Represent a single TTS engine.""" + + _attr_should_poll = False + __last_tts_loaded: str | None = None + + @property + @final + def state(self) -> str | None: + """Return the state of the entity.""" + if self.__last_tts_loaded is None: + return None + return self.__last_tts_loaded + + @property + @abstractmethod + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + + @property + @abstractmethod + def default_language(self) -> str: + """Return the default language.""" + + @property + def supported_options(self) -> list[str] | None: + """Return a list of supported options like voice, emotions.""" + return None + + @property + def default_options(self) -> Mapping[str, Any] | None: + """Return a mapping with the default options.""" + return None + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + return None + + async def async_internal_added_to_hass(self) -> None: + """Call when the entity is added to hass.""" + await super().async_internal_added_to_hass() + state = await self.async_get_last_state() + if ( + state is not None + and state.state is not None + and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + ): + self.__last_tts_loaded = state.state + + async def async_speak( + self, + media_player_entity_id: list[str], + message: str, + cache: bool, + language: str | None = None, + options: dict | None = None, + ) -> None: + """Speak via a Media Player.""" + await self.hass.services.async_call( + DOMAIN_MP, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player_entity_id, + ATTR_MEDIA_CONTENT_ID: generate_media_source_id( + self.hass, + message=message, + engine=self.entity_id, + language=language, + options=options, + cache=cache, + ), + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + context=self._context, + ) + + @final + async def internal_async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> TtsAudioType: + """Process an audio stream to TTS service. + + Only streaming content is allowed! + """ + self.__last_tts_loaded = dt_util.utcnow().isoformat() + self.async_write_ha_state() + return await self.async_get_tts_audio( + message=message, language=language, options=options + ) + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> TtsAudioType: + """Load tts audio file from the engine.""" + raise NotImplementedError() + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> TtsAudioType: + """Load tts audio file from the engine. + + Return a tuple of file extension and data as bytes. + """ + return await self.hass.async_add_executor_job( + partial(self.get_tts_audio, message, language, options=options) + ) + + def _hash_options(options: dict) -> str: """Hashes an options dictionary.""" opts_hash = hashlib.blake2s(digest_size=5) @@ -293,29 +406,30 @@ def _hash_options(options: dict) -> str: class SpeechManager: """Representation of a speech store.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__( + self, + hass: HomeAssistant, + use_cache: bool, + cache_dir: str, + time_memory: int, + base_url: str | None, + ) -> None: """Initialize a speech store.""" self.hass = hass self.providers: dict[str, Provider] = {} - self.use_cache = DEFAULT_CACHE - self.cache_dir = DEFAULT_CACHE_DIR - self.time_memory = DEFAULT_TIME_MEMORY - self.base_url: str | None = None + self.use_cache = use_cache + self.cache_dir = cache_dir + self.time_memory = time_memory + self.base_url = base_url self.file_cache: dict[str, str] = {} self.mem_cache: dict[str, TTSCache] = {} - async def async_init_cache( - self, use_cache: bool, cache_dir: str, time_memory: int, base_url: str | None - ) -> None: + async def async_init_cache(self) -> None: """Init config folder and load file cache.""" - self.use_cache = use_cache - self.time_memory = time_memory - self.base_url = base_url - try: self.cache_dir = await self.hass.async_add_executor_job( - _init_tts_cache_dir, self.hass, cache_dir + _init_tts_cache_dir, self.hass, self.cache_dir ) except OSError as err: raise HomeAssistantError(f"Can't init cache dir {err}") from err @@ -346,10 +460,10 @@ class SpeechManager: self.file_cache = {} @callback - def async_register_engine( + def async_register_legacy_engine( self, engine: str, provider: Provider, config: ConfigType ) -> None: - """Register a TTS provider.""" + """Register a legacy TTS engine.""" provider.hass = self.hass if provider.name is None: provider.name = engine @@ -362,32 +476,30 @@ class SpeechManager: @callback def process_options( self, - engine: str, + engine_instance: TextToSpeechEntity | Provider, language: str | None = None, options: dict | None = None, ) -> tuple[str, dict | None]: """Validate and process options.""" - if (provider := self.providers.get(engine)) is None: - raise HomeAssistantError(f"Provider {engine} not found") - # Languages - language = language or provider.default_language + language = language or engine_instance.default_language if ( language is None - or provider.supported_languages is None - or language not in provider.supported_languages + or engine_instance.supported_languages is None + or language not in engine_instance.supported_languages ): - raise HomeAssistantError(f"Not supported language {language}") + raise HomeAssistantError(f"Language '{language}' not supported") # Options - if provider.default_options and options: - merged_options = provider.default_options.copy() + if (default_options := engine_instance.default_options) and options: + merged_options = dict(default_options) merged_options.update(options) options = merged_options - options = options or provider.default_options + if not options: + options = None if default_options is None else dict(default_options) if options is not None: - supported_options = provider.supported_options or [] + supported_options = engine_instance.supported_options or [] invalid_opts = [ opt_name for opt_name in options if opt_name not in supported_options ] @@ -408,7 +520,10 @@ class SpeechManager: This method is a coroutine. """ - language, options = self.process_options(engine, language, options) + if (engine_instance := get_engine_instance(self.hass, engine)) is None: + raise HomeAssistantError(f"Provider {engine} not found") + + language, options = self.process_options(engine_instance, language, options) cache_key = self._generate_cache_key(message, language, options, engine) use_cache = cache if cache is not None else self.use_cache @@ -419,10 +534,10 @@ class SpeechManager: elif use_cache and cache_key in self.file_cache: filename = self.file_cache[cache_key] self.hass.async_create_task(self._async_file_to_mem(cache_key)) - # Load speech from provider into memory + # Load speech from engine into memory else: filename = await self._async_get_tts_audio( - engine, + engine_instance, cache_key, message, use_cache, @@ -441,7 +556,10 @@ class SpeechManager: options: dict | None = None, ) -> tuple[str, bytes]: """Fetch TTS audio.""" - language, options = self.process_options(engine, language, options) + if (engine_instance := get_engine_instance(self.hass, engine)) is None: + raise HomeAssistantError(f"Provider {engine} not found") + + language, options = self.process_options(engine_instance, language, options) cache_key = self._generate_cache_key(message, language, options, engine) use_cache = cache if cache is not None else self.use_cache @@ -451,12 +569,15 @@ class SpeechManager: await self._async_file_to_mem(cache_key) else: await self._async_get_tts_audio( - engine, cache_key, message, use_cache, language, options + engine_instance, cache_key, message, use_cache, language, options ) extension = os.path.splitext(self.mem_cache[cache_key]["filename"])[1][1:] - data = self.mem_cache[cache_key]["voice"] - return extension, data + cached = self.mem_cache[cache_key] + if pending := cached.get("pending"): + await pending + cached = self.mem_cache[cache_key] + return extension, cached["voice"] @callback def _generate_cache_key( @@ -475,7 +596,7 @@ class SpeechManager: async def _async_get_tts_audio( self, - engine: str, + engine_instance: TextToSpeechEntity | Provider, cache_key: str, message: str, cache: bool, @@ -486,30 +607,73 @@ class SpeechManager: This method is a coroutine. """ - provider = self.providers[engine] - extension, data = await provider.async_get_tts_audio(message, language, options) + if options is not None and ATTR_AUDIO_OUTPUT in options: + expected_extension = options[ATTR_AUDIO_OUTPUT] + else: + expected_extension = None - if data is None or extension is None: - raise HomeAssistantError(f"No TTS from {engine} for '{message}'") + async def get_tts_data() -> str: + """Handle data available.""" + if engine_instance.name is None: + raise HomeAssistantError("TTS engine name is not set.") - # Create file infos - filename = f"{cache_key}.{extension}".lower() + if isinstance(engine_instance, Provider): + extension, data = await engine_instance.async_get_tts_audio( + message, language, options + ) + else: + extension, data = await engine_instance.internal_async_get_tts_audio( + message, language, options + ) - # Validate filename - if not _RE_VOICE_FILE.match(filename): - raise HomeAssistantError( - f"TTS filename '{filename}' from {engine} is invalid!" - ) + if data is None or extension is None: + raise HomeAssistantError( + f"No TTS from {engine_instance.name} for '{message}'" + ) - # Save to memory - data = self.write_tags(filename, data, provider, message, language, options) - self._async_store_to_memcache(cache_key, filename, data) + # Create file infos + filename = f"{cache_key}.{extension}".lower() - if cache: - self.hass.async_create_task( - self._async_save_tts_audio(cache_key, filename, data) - ) + # Validate filename + if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( + filename + ): + raise HomeAssistantError( + f"TTS filename '{filename}' from {engine_instance.name} is invalid!" + ) + # Save to memory + if extension == "mp3": + data = self.write_tags( + filename, data, engine_instance.name, message, language, options + ) + self._async_store_to_memcache(cache_key, filename, data) + + if cache: + self.hass.async_create_task( + self._async_save_tts_audio(cache_key, filename, data) + ) + + return filename + + audio_task = self.hass.async_create_task(get_tts_data()) + + if expected_extension is None: + return await audio_task + + def handle_error(_future: asyncio.Future) -> None: + """Handle error.""" + if audio_task.exception(): + self.mem_cache.pop(cache_key, None) + + audio_task.add_done_callback(handle_error) + + filename = f"{cache_key}.{expected_extension}".lower() + self.mem_cache[cache_key] = { + "filename": filename, + "voice": b"", + "pending": audio_task, + } return filename async def _async_save_tts_audio( @@ -560,21 +724,35 @@ class SpeechManager: self, cache_key: str, filename: str, data: bytes ) -> None: """Store data to memcache and set timer to remove it.""" - self.mem_cache[cache_key] = {"filename": filename, "voice": data} + self.mem_cache[cache_key] = { + "filename": filename, + "voice": data, + "pending": None, + } @callback - def async_remove_from_mem() -> None: + def async_remove_from_mem(_: datetime) -> None: """Cleanup memcache.""" self.mem_cache.pop(cache_key, None) - self.hass.loop.call_later(self.time_memory, async_remove_from_mem) + async_call_later( + self.hass, + self.time_memory, + HassJob( + async_remove_from_mem, + name="tts remove_from_mem", + cancel_on_shutdown=True, + ), + ) async def async_read_tts(self, filename: str) -> tuple[str | None, bytes]: """Read a voice file and return binary. This method is a coroutine. """ - if not (record := _RE_VOICE_FILE.match(filename.lower())): + if not (record := _RE_VOICE_FILE.match(filename.lower())) and not ( + record := _RE_LEGACY_VOICE_FILE.match(filename.lower()) + ): raise HomeAssistantError("Wrong tts file format!") cache_key = KEY_PATTERN.format( @@ -587,13 +765,17 @@ class SpeechManager: await self._async_file_to_mem(cache_key) content, _ = mimetypes.guess_type(filename) - return content, self.mem_cache[cache_key]["voice"] + cached = self.mem_cache[cache_key] + if pending := cached.get("pending"): + await pending + cached = self.mem_cache[cache_key] + return content, cached["voice"] @staticmethod def write_tags( filename: str, data: bytes, - provider: Provider, + engine_name: str, message: str, language: str, options: dict | None, @@ -607,7 +789,7 @@ class SpeechManager: data_bytes.name = filename data_bytes.seek(0) - album = provider.name + album = engine_name artist = language if options is not None and (voice := options.get("voice")) is not None: @@ -642,52 +824,6 @@ class SpeechManager: return data_bytes.getvalue() -class Provider: - """Represent a single TTS provider.""" - - hass: HomeAssistant | None = None - name: str | None = None - - @property - def default_language(self) -> str | None: - """Return the default language.""" - return None - - @property - def supported_languages(self) -> list[str] | None: - """Return a list of supported languages.""" - return None - - @property - def supported_options(self) -> list[str] | None: - """Return a list of supported options like voice, emotions.""" - return None - - @property - def default_options(self) -> dict[str, Any] | None: - """Return a dict include default options.""" - return None - - def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None - ) -> TtsAudioType: - """Load tts audio file from provider.""" - raise NotImplementedError() - - async def async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None - ) -> TtsAudioType: - """Load tts audio file from provider. - - Return a tuple of file extension and data as bytes. - """ - if TYPE_CHECKING: - assert self.hass - return await self.hass.async_add_executor_job( - ft.partial(self.get_tts_audio, message, language, options=options) - ) - - def _init_tts_cache_dir(hass: HomeAssistant, cache_dir: str) -> str: """Init cache folder.""" if not os.path.isabs(cache_dir): @@ -704,7 +840,9 @@ def _get_cache_files(cache_dir: str) -> dict[str, str]: folder_data = os.listdir(cache_dir) for file_data in folder_data: - if record := _RE_VOICE_FILE.match(file_data): + if (record := _RE_VOICE_FILE.match(file_data)) or ( + record := _RE_LEGACY_VOICE_FILE.match(file_data) + ): key = KEY_PATTERN.format( record.group(1), record.group(2), record.group(3), record.group(4) ) @@ -729,12 +867,16 @@ class TextToSpeechUrlView(HomeAssistantView): data = await request.json() except ValueError: return self.json_message("Invalid JSON specified", HTTPStatus.BAD_REQUEST) - if not data.get(ATTR_PLATFORM) and data.get(ATTR_MESSAGE): + if ( + not data.get("engine_id") + and not data.get(ATTR_PLATFORM) + or not data.get(ATTR_MESSAGE) + ): return self.json_message( "Must specify platform and message", HTTPStatus.BAD_REQUEST ) - p_type = data[ATTR_PLATFORM] + engine = data.get("engine_id") or data[ATTR_PLATFORM] message = data[ATTR_MESSAGE] cache = data.get(ATTR_CACHE) language = data.get(ATTR_LANGUAGE) @@ -742,7 +884,7 @@ class TextToSpeechUrlView(HomeAssistantView): try: path = await self.tts.async_get_url_path( - p_type, message, cache=cache, language=language, options=options + engine, message, cache=cache, language=language, options=options ) except HomeAssistantError as err: _LOGGER.error("Error on init tts: %s", err) @@ -779,3 +921,79 @@ class TextToSpeechView(HomeAssistantView): def get_base_url(hass: HomeAssistant) -> str: """Get base URL.""" return hass.data[BASE_URL_KEY] or get_url(hass) + + +@websocket_api.websocket_command( + { + "type": "tts/engine/list", + vol.Optional("country"): str, + vol.Optional("language"): str, + } +) +@callback +def websocket_list_engines( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """List text to speech engines and, optionally, if they support a given language.""" + component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] + + country = msg.get("country") + language = msg.get("language") + providers = [] + provider_info: dict[str, Any] + + for entity in component.entities: + provider_info = { + "engine_id": entity.entity_id, + "supported_languages": entity.supported_languages, + } + if language: + provider_info["supported_languages"] = language_util.matches( + language, entity.supported_languages, country + ) + providers.append(provider_info) + for engine_id, provider in manager.providers.items(): + provider_info = { + "engine_id": engine_id, + "supported_languages": provider.supported_languages, + } + if language: + provider_info["supported_languages"] = language_util.matches( + language, provider.supported_languages, country + ) + providers.append(provider_info) + + connection.send_message( + websocket_api.result_message(msg["id"], {"providers": providers}) + ) + + +@websocket_api.websocket_command( + { + "type": "tts/engine/voices", + vol.Required("engine_id"): str, + vol.Required("language"): str, + } +) +@callback +def websocket_list_engine_voices( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """List voices for a given language.""" + engine_id = msg["engine_id"] + language = msg["language"] + + engine_instance = get_engine_instance(hass, engine_id) + + if not engine_instance: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"tts engine {engine_id} not found", + ) + return + + voices = {"voices": engine_instance.async_get_supported_voices(language)} + + connection.send_message(websocket_api.result_message(msg["id"], voices)) diff --git a/homeassistant/components/tts/const.py b/homeassistant/components/tts/const.py index 492e995b87..3427b761fa 100644 --- a/homeassistant/components/tts/const.py +++ b/homeassistant/components/tts/const.py @@ -1,3 +1,21 @@ """Text-to-speech constants.""" +ATTR_CACHE = "cache" +ATTR_LANGUAGE = "language" +ATTR_MESSAGE = "message" +ATTR_OPTIONS = "options" + +CONF_BASE_URL = "base_url" +CONF_CACHE = "cache" +CONF_CACHE_DIR = "cache_dir" +CONF_FIELDS = "fields" +CONF_TIME_MEMORY = "time_memory" + +DEFAULT_CACHE = True +DEFAULT_CACHE_DIR = "tts" +DEFAULT_TIME_MEMORY = 300 DOMAIN = "tts" + +DATA_TTS_MANAGER = "tts_manager" + +TtsAudioType = tuple[str | None, bytes | None] diff --git a/homeassistant/components/tts/helper.py b/homeassistant/components/tts/helper.py new file mode 100644 index 0000000000..8cbfcbd893 --- /dev/null +++ b/homeassistant/components/tts/helper.py @@ -0,0 +1,26 @@ +"""Provide helper functions for the TTS.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_component import EntityComponent + +from .const import DATA_TTS_MANAGER, DOMAIN + +if TYPE_CHECKING: + from . import SpeechManager, TextToSpeechEntity + from .legacy import Provider + + +def get_engine_instance( + hass: HomeAssistant, engine: str +) -> TextToSpeechEntity | Provider | None: + """Get engine instance.""" + component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] + + if entity := component.get_entity(engine): + return entity + + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] + return manager.providers.get(engine) diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py new file mode 100644 index 0000000000..138c0bf84c --- /dev/null +++ b/homeassistant/components/tts/legacy.py @@ -0,0 +1,259 @@ +"""Provide the legacy TTS service provider interface.""" +from __future__ import annotations + +from abc import abstractmethod +from collections.abc import Coroutine, Mapping +from functools import partial +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +import voluptuous as vol +import yarl + +from homeassistant.components.media_player import ( + ATTR_MEDIA_ANNOUNCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, + MediaType, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DESCRIPTION, + CONF_NAME, + CONF_PLATFORM, +) +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_per_platform, discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service import async_set_service_schema +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.setup import async_prepare_setup_platform +from homeassistant.util.network import normalize_url +from homeassistant.util.yaml import load_yaml + +from .const import ( + ATTR_CACHE, + ATTR_LANGUAGE, + ATTR_MESSAGE, + ATTR_OPTIONS, + CONF_BASE_URL, + CONF_CACHE, + CONF_CACHE_DIR, + CONF_FIELDS, + CONF_TIME_MEMORY, + DATA_TTS_MANAGER, + DEFAULT_CACHE, + DEFAULT_CACHE_DIR, + DEFAULT_TIME_MEMORY, + DOMAIN, + TtsAudioType, +) +from .media_source import generate_media_source_id +from .models import Voice + +if TYPE_CHECKING: + from . import SpeechManager + +_LOGGER = logging.getLogger(__name__) + +CONF_SERVICE_NAME = "service_name" + + +def _deprecated_platform(value: str) -> str: + """Validate if platform is deprecated.""" + if value == "google": + raise vol.Invalid( + "google tts service has been renamed to google_translate," + " please update your configuration." + ) + return value + + +def _valid_base_url(value: str) -> str: + """Validate base url, return value.""" + url = yarl.URL(cv.url(value)) + + if url.path != "/": + raise vol.Invalid("Path should be empty") + + return normalize_url(value) + + +PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): vol.All(cv.string, _deprecated_platform), + vol.Optional(CONF_CACHE, default=DEFAULT_CACHE): cv.boolean, + vol.Optional(CONF_CACHE_DIR, default=DEFAULT_CACHE_DIR): cv.string, + vol.Optional(CONF_TIME_MEMORY, default=DEFAULT_TIME_MEMORY): vol.All( + vol.Coerce(int), vol.Range(min=60, max=57600) + ), + vol.Optional(CONF_BASE_URL): _valid_base_url, + vol.Optional(CONF_SERVICE_NAME): cv.string, + } +) +PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE.extend(PLATFORM_SCHEMA.schema) + +SERVICE_SAY = "say" + +SCHEMA_SERVICE_SAY = vol.Schema( + { + vol.Required(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_CACHE): cv.boolean, + vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Optional(ATTR_LANGUAGE): cv.string, + vol.Optional(ATTR_OPTIONS): dict, + } +) + + +async def async_setup_legacy( + hass: HomeAssistant, config: ConfigType +) -> list[Coroutine[Any, Any, None]]: + """Set up legacy text to speech providers.""" + tts: SpeechManager = hass.data[DATA_TTS_MANAGER] + + # Load service descriptions from tts/services.yaml + services_yaml = Path(__file__).parent / "services.yaml" + services_dict = cast( + dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + ) + + async def async_setup_platform( + p_type: str, + p_config: ConfigType | None = None, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: + """Set up a TTS platform.""" + if p_config is None: + p_config = {} + + platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) + if platform is None: + _LOGGER.error("Unknown text to speech platform specified") + return + + try: + if hasattr(platform, "async_get_engine"): + provider = await platform.async_get_engine( + hass, p_config, discovery_info + ) + else: + provider = await hass.async_add_executor_job( + platform.get_engine, hass, p_config, discovery_info + ) + + if provider is None: + _LOGGER.error("Error setting up platform: %s", p_type) + return + + tts.async_register_legacy_engine(p_type, provider, p_config) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error setting up platform: %s", p_type) + return + + async def async_say_handle(service: ServiceCall) -> None: + """Service handle for say.""" + entity_ids = service.data[ATTR_ENTITY_ID] + + await hass.services.async_call( + DOMAIN_MP, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: entity_ids, + ATTR_MEDIA_CONTENT_ID: generate_media_source_id( + hass, + engine=p_type, + message=service.data[ATTR_MESSAGE], + language=service.data.get(ATTR_LANGUAGE), + options=service.data.get(ATTR_OPTIONS), + cache=service.data.get(ATTR_CACHE), + ), + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + context=service.context, + ) + + service_name = p_config.get(CONF_SERVICE_NAME, f"{p_type}_{SERVICE_SAY}") + hass.services.async_register( + DOMAIN, service_name, async_say_handle, schema=SCHEMA_SERVICE_SAY + ) + + # Register the service description + service_desc = { + CONF_NAME: f"Say a TTS message with {p_type}", + CONF_DESCRIPTION: ( + f"Say something using text-to-speech on a media player with {p_type}." + ), + CONF_FIELDS: services_dict[SERVICE_SAY][CONF_FIELDS], + } + async_set_service_schema(hass, DOMAIN, service_name, service_desc) + + async def async_platform_discovered( + platform: str, info: dict[str, Any] | None + ) -> None: + """Handle for discovered platform.""" + await async_setup_platform(platform, discovery_info=info) + + discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) + + return [ + async_setup_platform(p_type, p_config) + for p_type, p_config in config_per_platform(config, DOMAIN) + if p_type is not None + ] + + +class Provider: + """Represent a single TTS provider.""" + + hass: HomeAssistant | None = None + name: str | None = None + + @property + def default_language(self) -> str | None: + """Return the default language.""" + return None + + @property + @abstractmethod + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + + @property + def supported_options(self) -> list[str] | None: + """Return a list of supported options like voice, emotions.""" + return None + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + return None + + @property + def default_options(self) -> Mapping[str, Any] | None: + """Return a mapping with the default options.""" + return None + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> TtsAudioType: + """Load tts audio file from provider.""" + raise NotImplementedError() + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> TtsAudioType: + """Load tts audio file from provider. + + Return a tuple of file extension and data as bytes. + """ + if TYPE_CHECKING: + assert self.hass + return await self.hass.async_add_executor_job( + partial(self.get_tts_audio, message, language, options=options) + ) diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json index 75f78df1f4..741edbc4ce 100644 --- a/homeassistant/components/tts/manifest.json +++ b/homeassistant/components/tts/manifest.json @@ -2,7 +2,7 @@ "domain": "tts", "name": "Text-to-Speech (TTS)", "after_dependencies": ["media_player"], - "codeowners": ["@pvizeli"], + "codeowners": ["@home-assistant/core", "@pvizeli"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/tts", "integration_type": "entity", diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index c197632c11..34dc3822e9 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -17,12 +17,14 @@ from homeassistant.components.media_source import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.network import get_url -from .const import DOMAIN +from .const import DATA_TTS_MANAGER, DOMAIN +from .helper import get_engine_instance if TYPE_CHECKING: - from . import SpeechManager + from . import SpeechManager, TextToSpeechEntity async def async_get_media_source(hass: HomeAssistant) -> TTSMediaSource: @@ -40,18 +42,18 @@ def generate_media_source_id( cache: bool | None = None, ) -> str: """Generate a media source ID for text-to-speech.""" - manager: SpeechManager = hass.data[DOMAIN] + from . import async_resolve_engine # pylint: disable=import-outside-toplevel - if engine is not None: - pass - elif not manager.providers: - raise HomeAssistantError("No TTS providers available") - elif "cloud" in manager.providers: - engine = "cloud" - else: - engine = next(iter(manager.providers)) + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] - manager.process_options(engine, language, options) + if (engine := async_resolve_engine(hass, engine)) is None: + raise HomeAssistantError("Invalid TTS provider selected") + + engine_instance = get_engine_instance(hass, engine) + # We raise above if the engine is not resolved, so engine_instance can't be None + assert engine_instance is not None + + manager.process_options(engine_instance, language, options) params = { "message": message, } @@ -111,7 +113,7 @@ class TTSMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" - manager: SpeechManager = self.hass.data[DOMAIN] + manager: SpeechManager = self.hass.data[DATA_TTS_MANAGER] try: url = await manager.async_get_url_path( @@ -133,12 +135,15 @@ class TTSMediaSource(MediaSource): ) -> BrowseMediaSource: """Return media.""" if item.identifier: - provider, _, params = item.identifier.partition("?") - return self._provider_item(provider, params) + engine, _, params = item.identifier.partition("?") + return self._engine_item(engine, params) # Root. List providers. - manager: SpeechManager = self.hass.data[DOMAIN] - children = [self._provider_item(provider) for provider in manager.providers] + manager: SpeechManager = self.hass.data[DATA_TTS_MANAGER] + component: EntityComponent[TextToSpeechEntity] = self.hass.data[DOMAIN] + children = [self._engine_item(engine) for engine in manager.providers] + [ + self._engine_item(entity.entity_id) for entity in component.entities + ] return BrowseMediaSource( domain=DOMAIN, identifier=None, @@ -152,14 +157,19 @@ class TTSMediaSource(MediaSource): ) @callback - def _provider_item( - self, provider_domain: str, params: str | None = None - ) -> BrowseMediaSource: + def _engine_item(self, engine: str, params: str | None = None) -> BrowseMediaSource: """Return provider item.""" - manager: SpeechManager = self.hass.data[DOMAIN] - if (provider := manager.providers.get(provider_domain)) is None: + from . import TextToSpeechEntity # pylint: disable=import-outside-toplevel + + if (engine_instance := get_engine_instance(self.hass, engine)) is None: raise BrowseError("Unknown provider") + if isinstance(engine_instance, TextToSpeechEntity): + assert engine_instance.platform is not None + engine_domain = engine_instance.platform.domain + else: + engine_domain = engine + if params: params = f"?{params}" else: @@ -167,11 +177,11 @@ class TTSMediaSource(MediaSource): return BrowseMediaSource( domain=DOMAIN, - identifier=f"{provider_domain}{params}", + identifier=f"{engine}{params}", media_class=MediaClass.APP, media_content_type="provider", - title=provider.name, - thumbnail=f"https://brands.home-assistant.io/_/{provider_domain}/logo.png", + title=engine_instance.name, + thumbnail=f"https://brands.home-assistant.io/_/{engine_domain}/logo.png", can_play=False, can_expand=True, ) diff --git a/homeassistant/components/tts/models.py b/homeassistant/components/tts/models.py new file mode 100644 index 0000000000..1ea49b1e9e --- /dev/null +++ b/homeassistant/components/tts/models.py @@ -0,0 +1,10 @@ +"""Text-to-speech data models.""" +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Voice: + """A TTS voice.""" + + voice_id: str + name: str diff --git a/homeassistant/components/tts/notify.py b/homeassistant/components/tts/notify.py index 041638f830..92244fc41f 100644 --- a/homeassistant/components/tts/notify.py +++ b/homeassistant/components/tts/notify.py @@ -7,22 +7,26 @@ from typing import Any import voluptuous as vol from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService -from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME +from homeassistant.const import ATTR_ENTITY_ID, CONF_ENTITY_ID, CONF_NAME from homeassistant.core import HomeAssistant, split_entity_id import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ATTR_LANGUAGE, ATTR_MESSAGE, DOMAIN +from . import ATTR_LANGUAGE, ATTR_MEDIA_PLAYER_ENTITY_ID, ATTR_MESSAGE, DOMAIN CONF_MEDIA_PLAYER = "media_player" CONF_TTS_SERVICE = "tts_service" +ENTITY_LEGACY_PROVIDER_GROUP = "entity_or_legacy_provider" _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_TTS_SERVICE): cv.entity_id, + vol.Exclusive(CONF_TTS_SERVICE, ENTITY_LEGACY_PROVIDER_GROUP): cv.entity_id, + vol.Exclusive(CONF_ENTITY_ID, ENTITY_LEGACY_PROVIDER_GROUP): cv.entities_domain( + DOMAIN + ), vol.Required(CONF_MEDIA_PLAYER): cv.entity_id, vol.Optional(ATTR_LANGUAGE): cv.string, } @@ -44,7 +48,12 @@ class TTSNotificationService(BaseNotificationService): def __init__(self, config: ConfigType) -> None: """Initialize the service.""" - _, self._tts_service = split_entity_id(config[CONF_TTS_SERVICE]) + self._target: str | None = None + self._tts_service: str | None = None + if entity_id := config.get(CONF_ENTITY_ID): + self._target = entity_id + else: + _, self._tts_service = split_entity_id(config[CONF_TTS_SERVICE]) self._media_player = config[CONF_MEDIA_PLAYER] self._language = config.get(ATTR_LANGUAGE) @@ -54,13 +63,21 @@ class TTSNotificationService(BaseNotificationService): data = { ATTR_MESSAGE: message, - ATTR_ENTITY_ID: self._media_player, } + service_name = "" + + if self._tts_service: + data[ATTR_ENTITY_ID] = self._media_player + service_name = self._tts_service + elif self._target: + data[ATTR_ENTITY_ID] = self._target + data[ATTR_MEDIA_PLAYER_ENTITY_ID] = self._media_player + service_name = "speak" if self._language: data[ATTR_LANGUAGE] = self._language await self.hass.services.async_call( DOMAIN, - self._tts_service, + service_name, data, ) diff --git a/homeassistant/components/tts/services.yaml b/homeassistant/components/tts/services.yaml index 7dcbe1287c..99e0bcca4d 100644 --- a/homeassistant/components/tts/services.yaml +++ b/homeassistant/components/tts/services.yaml @@ -40,6 +40,49 @@ say: selector: object: +speak: + name: Speak + description: Speak something using text-to-speech on a media player. + target: + entity: + domain: tts + fields: + media_player_entity_id: + name: Media Player Entity + description: Name(s) of media player entities. + required: true + selector: + entity: + domain: media_player + message: + name: Message + description: Text to speak on devices. + example: "My name is hanna" + required: true + selector: + text: + cache: + name: Cache + description: Control file cache of this message. + default: true + selector: + boolean: + language: + name: Language + description: Language to use for speech generation. + example: "ru" + selector: + text: + options: + name: Options + description: + A dictionary containing platform-specific options. Optional depending on + the platform. + advanced: true + example: platform specific + selector: + object: + clear_cache: name: Clear TTS cache description: Remove all text-to-speech cache files and RAM cache. diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 3546e45451..959a1834f8 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -499,9 +499,14 @@ class TuyaLightEntity(TuyaEntity, LightEntity): ), }, ] - elif self._color_data_type and ( + + if self._color_data_type and ( ATTR_HS_COLOR in kwargs - or (ATTR_BRIGHTNESS in kwargs and self.color_mode == ColorMode.HS) + or ( + ATTR_BRIGHTNESS in kwargs + and self.color_mode == ColorMode.HS + and ATTR_COLOR_TEMP not in kwargs + ) ): if self._color_mode_dpcode: commands += [ @@ -542,11 +547,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): }, ] - if ( - ATTR_BRIGHTNESS in kwargs - and self.color_mode != ColorMode.HS - and self._brightness - ): + elif ATTR_BRIGHTNESS in kwargs and self._brightness: brightness = kwargs[ATTR_BRIGHTNESS] # If there is a min/max value, the brightness is actually limited. diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index adaa7c977f..a7e8aede36 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -1,5 +1,7 @@ """Integration to UniFi Network and its various features.""" +from aiounifi.models.client import Client + from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback @@ -62,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" - controller = hass.data[UNIFI_DOMAIN].pop(config_entry.entry_id) + controller: UniFiController = hass.data[UNIFI_DOMAIN].pop(config_entry.entry_id) if not hass.data[UNIFI_DOMAIN]: async_unload_services(hass) @@ -91,33 +93,55 @@ def async_remove_poe_client_entities( class UnifiWirelessClients: """Class to store clients known to be wireless. - This is needed since wireless devices going offline might get marked as wired by UniFi. + This is needed since wireless devices going offline + might get marked as wired by UniFi. """ def __init__(self, hass: HomeAssistant) -> None: """Set up client storage.""" self.hass = hass - self.data: dict[str, dict[str, list[str]]] = {} + self.data: dict[str, dict[str, list[str]] | list[str]] = {} + self.wireless_clients: set[str] = set() self._store: Store = Store(hass, STORAGE_VERSION, STORAGE_KEY) async def async_load(self) -> None: """Load data from file.""" if (data := await self._store.async_load()) is not None: self.data = data + if "wireless_clients" not in data: + data["wireless_clients"] = [ + obj_id + for config_entry in data + for obj_id in data[config_entry]["wireless_devices"] + ] + self.wireless_clients.update(data["wireless_clients"]) @callback - def get_data(self, config_entry: ConfigEntry) -> set[str]: - """Get data related to a specific controller.""" - data = self.data.get(config_entry.entry_id, {"wireless_devices": []}) - return set(data["wireless_devices"]) + def is_wireless(self, client: Client) -> bool: + """Is client known to be wireless. + + Store if client is wireless and not known. + """ + if not client.is_wired and client.mac not in self.wireless_clients: + self.wireless_clients.add(client.mac) + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + return client.mac in self.wireless_clients @callback - def update_data(self, data: set[str], config_entry: ConfigEntry) -> None: + def update_clients(self, clients: set[Client]) -> None: """Update data and schedule to save to file.""" - self.data[config_entry.entry_id] = {"wireless_devices": list(data)} + self.wireless_clients.update( + {client.mac for client in clients if not client.is_wired} + ) self._store.async_delay_save(self._data_to_save, SAVE_DELAY) @callback - def _data_to_save(self) -> dict[str, dict[str, list[str]]]: + def _data_to_save(self) -> dict[str, dict[str, list[str]] | list[str]]: """Return data of UniFi wireless clients to store in a file.""" + self.data["wireless_clients"] = list(self.wireless_clients) return self.data + + def __contains__(self, obj_id: int | str) -> bool: + """Validate membership of item ID.""" + return obj_id in self.wireless_clients diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index d4e885b295..d283b66899 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -313,6 +313,7 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): f"{wlan.name}{wlan.name_combine_suffix}" for wlan in self.controller.api.wlans.values() if not wlan.name_combine_enabled + and wlan.name_combine_suffix is not None } | { wlan["name"] diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 8a047606c6..60507d5a8c 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -10,9 +10,7 @@ from typing import Any from aiohttp import CookieJar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent -from aiounifi.interfaces.messages import DATA_EVENT -from aiounifi.models.event import EventKey -from aiounifi.websocket import WebsocketSignal, WebsocketState +from aiounifi.websocket import WebsocketState import async_timeout from homeassistant.config_entries import ConfigEntry @@ -24,7 +22,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import ( aiohttp_client, device_registry as dr, @@ -77,32 +75,32 @@ CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) class UniFiController: """Manages a single UniFi Network instance.""" - def __init__(self, hass, config_entry, api): + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: aiounifi.Controller + ) -> None: """Initialize the system.""" self.hass = hass self.config_entry = config_entry self.api = api - api.callback = self.async_unifi_signalling_callback + api.ws_state_callback = self.async_unifi_ws_state_callback self.available = True - self.progress = None - self.wireless_clients = None + self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] self.site_id: str = "" - self._site_name = None - self._site_role = None + self._site_name: str | None = None + self._site_role: str | None = None - self._cancel_heartbeat_check = None - self._heartbeat_dispatch = {} - self._heartbeat_time = {} + self._cancel_heartbeat_check: CALLBACK_TYPE | None = None + self._heartbeat_time: dict[str, datetime] = {} self.load_config_entry_options() - self.entities = {} + self.entities: dict[str, str] = {} self.known_objects: set[tuple[str, str]] = set() - def load_config_entry_options(self): + def load_config_entry_options(self) -> None: """Store attributes to avoid property call overhead since they are called frequently.""" options = self.config_entry.options @@ -117,7 +115,7 @@ class UniFiController: CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS ) # Config entry option to not track devices. - self.option_track_devices = options.get( + self.option_track_devices: bool = options.get( CONF_TRACK_DEVICES, DEFAULT_TRACK_DEVICES ) # Config entry option listing what SSIDs are being used to track clients. @@ -136,43 +134,45 @@ class UniFiController: # Config entry option with list of clients to control network access. self.option_block_clients = options.get(CONF_BLOCK_CLIENT, []) # Config entry option to control DPI restriction groups. - self.option_dpi_restrictions = options.get( + self.option_dpi_restrictions: bool = options.get( CONF_DPI_RESTRICTIONS, DEFAULT_DPI_RESTRICTIONS ) # Statistics sensor options # Config entry option to allow bandwidth sensors. - self.option_allow_bandwidth_sensors = options.get( + self.option_allow_bandwidth_sensors: bool = options.get( CONF_ALLOW_BANDWIDTH_SENSORS, DEFAULT_ALLOW_BANDWIDTH_SENSORS ) # Config entry option to allow uptime sensors. - self.option_allow_uptime_sensors = options.get( + self.option_allow_uptime_sensors: bool = options.get( CONF_ALLOW_UPTIME_SENSORS, DEFAULT_ALLOW_UPTIME_SENSORS ) @property - def host(self): + def host(self) -> str: """Return the host of this controller.""" - return self.config_entry.data[CONF_HOST] + host: str = self.config_entry.data[CONF_HOST] + return host @property - def site(self): + def site(self) -> str: """Return the site of this config entry.""" - return self.config_entry.data[CONF_SITE_ID] + site_id: str = self.config_entry.data[CONF_SITE_ID] + return site_id @property - def site_name(self): + def site_name(self) -> str | None: """Return the nice name of site.""" return self._site_name @property - def site_role(self): + def site_role(self) -> str | None: """Return the site user role of this controller.""" return self._site_role @property - def mac(self): + def mac(self) -> str | None: """Return the mac address of this controller.""" for client in self.api.clients.values(): if self.host == client.ip: @@ -230,31 +230,21 @@ class UniFiController: async_load_entities(description) @callback - def async_unifi_signalling_callback(self, signal, data): + def async_unifi_ws_state_callback(self, state: WebsocketState) -> None: """Handle messages back from UniFi library.""" - if signal == WebsocketSignal.CONNECTION_STATE: - if data == WebsocketState.DISCONNECTED and self.available: - LOGGER.warning("Lost connection to UniFi Network") + if state == WebsocketState.DISCONNECTED and self.available: + LOGGER.warning("Lost connection to UniFi Network") - if (data == WebsocketState.RUNNING and not self.available) or ( - data == WebsocketState.DISCONNECTED and self.available - ): - self.available = data == WebsocketState.RUNNING - async_dispatcher_send(self.hass, self.signal_reachable) + if (state == WebsocketState.RUNNING and not self.available) or ( + state == WebsocketState.DISCONNECTED and self.available + ): + self.available = state == WebsocketState.RUNNING + async_dispatcher_send(self.hass, self.signal_reachable) - if not self.available: - self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) - else: - LOGGER.info("Connected to UniFi Network") - - elif signal == WebsocketSignal.DATA and DATA_EVENT in data: - for event in data[DATA_EVENT]: - if event.key in ( - EventKey.WIRELESS_CLIENT_CONNECTED, - EventKey.WIRELESS_GUEST_CONNECTED, - ): - self.update_wireless_clients() - break + if not self.available: + self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) + else: + LOGGER.info("Connected to UniFi Network") @property def signal_reachable(self) -> str: @@ -271,23 +261,7 @@ class UniFiController: """Event specific per UniFi device tracker to signal new heartbeat missed.""" return "unifi-heartbeat-missed" - def update_wireless_clients(self): - """Update set of known to be wireless clients.""" - new_wireless_clients = set() - - for client_id in self.api.clients: - if ( - client_id not in self.wireless_clients - and not self.api.clients[client_id].is_wired - ): - new_wireless_clients.add(client_id) - - if new_wireless_clients: - self.wireless_clients |= new_wireless_clients - unifi_wireless_clients = self.hass.data[UNIFI_WIRELESS_CLIENTS] - unifi_wireless_clients.update_data(self.wireless_clients, self.config_entry) - - async def initialize(self): + async def initialize(self) -> None: """Set up a UniFi Network instance.""" await self.api.initialize() @@ -319,16 +293,14 @@ class UniFiController: continue client = self.api.clients_all[mac] - self.api.clients.process_raw([client.raw]) + self.api.clients.process_raw([dict(client.raw)]) LOGGER.debug( "Restore disconnected client %s (%s)", entry.entity_id, client.mac, ) - wireless_clients = self.hass.data[UNIFI_WIRELESS_CLIENTS] - self.wireless_clients = wireless_clients.get_data(self.config_entry) - self.update_wireless_clients() + self.wireless_clients.update_clients(set(self.api.clients.values())) self.config_entry.add_update_listener(self.async_config_entry_updated) @@ -349,7 +321,7 @@ class UniFiController: del self._heartbeat_time[unique_id] @callback - def _async_check_for_stale(self, *_) -> None: + def _async_check_for_stale(self, *_: datetime) -> None: """Check for any devices scheduled to be marked disconnected.""" now = dt_util.utcnow() @@ -395,7 +367,7 @@ class UniFiController: async_dispatcher_send(hass, controller.signal_options_update) @callback - def reconnect(self, log=False) -> None: + def reconnect(self, log: bool = False) -> None: """Prepare to reconnect UniFi session.""" if log: LOGGER.info("Will try to reconnect to UniFi Network") @@ -417,14 +389,14 @@ class UniFiController: self.hass.loop.call_later(RETRY_TIMER, self.reconnect) @callback - def shutdown(self, event) -> None: + def shutdown(self, event: Event) -> None: """Wrap the call to unifi.close. Used as an argument to EventBus.async_listen_once. """ self.api.stop_websocket() - async def async_reset(self): + async def async_reset(self) -> bool: """Reset this controller to default state. Will cancel any scheduled setup retry and will unload @@ -451,15 +423,15 @@ async def get_unifi_controller( config: MappingProxyType[str, Any], ) -> aiounifi.Controller: """Create a controller object and verify authentication.""" - ssl_context = False + ssl_context: ssl.SSLContext | bool = False - if verify_ssl := bool(config.get(CONF_VERIFY_SSL)): + if verify_ssl := config.get(CONF_VERIFY_SSL): session = aiohttp_client.async_get_clientsession(hass) if isinstance(verify_ssl, str): ssl_context = ssl.create_default_context(cafile=verify_ssl) else: session = aiohttp_client.async_create_clientsession( - hass, verify_ssl=verify_ssl, cookie_jar=CookieJar(unsafe=True) + hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) ) controller = aiounifi.Controller( diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index a5b153d7f3..296857e1cf 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -12,13 +12,14 @@ import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.devices import Devices +from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client from aiounifi.models.device import Device from aiounifi.models.event import Event, EventKey from homeassistant.components.device_tracker import ScannerEntity, SourceType from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -26,7 +27,6 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( - DataT, HandlerT, UnifiEntity, UnifiEntityDescription, @@ -54,7 +54,6 @@ CLIENT_CONNECTED_ATTRIBUTES = [ ] CLIENT_STATIC_ATTRIBUTES = [ - "hostname", "mac", "name", "oui", @@ -105,7 +104,7 @@ def async_client_is_connected_fn(controller: UniFiController, obj_id: str) -> bo """Check if device object is disabled.""" client = controller.api.clients[obj_id] - if client.is_wired != (obj_id not in controller.wireless_clients): + if controller.wireless_clients.is_wireless(client) and client.is_wired: if not controller.option_ignore_wired_bug: return False # Wired bug in action @@ -136,7 +135,7 @@ def async_device_heartbeat_timedelta_fn( @dataclass -class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, DataT]): +class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, ApiItemT]): """Device tracker local functions.""" heartbeat_timedelta_fn: Callable[[UniFiController, str], timedelta] @@ -147,8 +146,8 @@ class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, DataT]): @dataclass class UnifiTrackerEntityDescription( - UnifiEntityDescription[HandlerT, DataT], - UnifiEntityTrackerDescriptionMixin[HandlerT, DataT], + UnifiEntityDescription[HandlerT, ApiItemT], + UnifiEntityTrackerDescriptionMixin[HandlerT, ApiItemT], ): """Class describing UniFi device tracker entity.""" @@ -175,7 +174,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiTrackerEntityDescription, ...] = ( supported_fn=lambda controller, obj_id: True, unique_id_fn=lambda controller, obj_id: f"{obj_id}-{controller.site}", ip_address_fn=lambda api, obj_id: api.clients[obj_id].ip, - hostname_fn=lambda api, obj_id: None, + hostname_fn=lambda api, obj_id: api.clients[obj_id].hostname, ), UnifiTrackerEntityDescription[Devices, Device]( key="Device scanner", @@ -211,7 +210,7 @@ async def async_setup_entry( ) -class UnifiScannerEntity(UnifiEntity[HandlerT, DataT], ScannerEntity): +class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): """Representation of a UniFi scanner.""" entity_description: UnifiTrackerEntityDescription @@ -268,7 +267,7 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, DataT], ScannerEntity): return self._attr_unique_id @callback - def _make_disconnected(self, *_) -> None: + def _make_disconnected(self, *_: core_Event) -> None: """No heart beat by device.""" self._is_connected = False self.async_write_ha_state() diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 5d763ecfe8..18a132be6a 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -13,12 +13,8 @@ from aiounifi.interfaces.api_handlers import ( ItemEvent, UnsubscribeType, ) -from aiounifi.interfaces.outlets import Outlets -from aiounifi.interfaces.ports import Ports -from aiounifi.models.api import APIItem +from aiounifi.models.api import ApiItemT from aiounifi.models.event import Event, EventKey -from aiounifi.models.outlet import Outlet -from aiounifi.models.port import Port from homeassistant.core import callback from homeassistant.helpers import entity_registry as er @@ -31,8 +27,7 @@ from .const import ATTR_MANUFACTURER if TYPE_CHECKING: from .controller import UniFiController -DataT = TypeVar("DataT", bound=APIItem | Outlet | Port) -HandlerT = TypeVar("HandlerT", bound=APIHandler | Outlets | Ports) +HandlerT = TypeVar("HandlerT", bound=APIHandler) SubscriptionT = Callable[[CallbackType, ItemEvent], UnsubscribeType] @@ -64,7 +59,7 @@ def async_device_device_info_fn(api: aiounifi.Controller, obj_id: str) -> Device @dataclass -class UnifiDescription(Generic[HandlerT, DataT]): +class UnifiDescription(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" allowed_fn: Callable[[UniFiController, str], bool] @@ -73,21 +68,21 @@ class UnifiDescription(Generic[HandlerT, DataT]): device_info_fn: Callable[[aiounifi.Controller, str], DeviceInfo | None] event_is_on: tuple[EventKey, ...] | None event_to_subscribe: tuple[EventKey, ...] | None - name_fn: Callable[[DataT], str | None] - object_fn: Callable[[aiounifi.Controller, str], DataT] + name_fn: Callable[[ApiItemT], str | None] + object_fn: Callable[[aiounifi.Controller, str], ApiItemT] supported_fn: Callable[[UniFiController, str], bool | None] unique_id_fn: Callable[[UniFiController, str], str] @dataclass -class UnifiEntityDescription(EntityDescription, UnifiDescription[HandlerT, DataT]): +class UnifiEntityDescription(EntityDescription, UnifiDescription[HandlerT, ApiItemT]): """UniFi Entity Description.""" -class UnifiEntity(Entity, Generic[HandlerT, DataT]): +class UnifiEntity(Entity, Generic[HandlerT, ApiItemT]): """Representation of a UniFi entity.""" - entity_description: UnifiEntityDescription[HandlerT, DataT] + entity_description: UnifiEntityDescription[HandlerT, ApiItemT] _attr_should_poll = False _attr_unique_id: str @@ -96,7 +91,7 @@ class UnifiEntity(Entity, Generic[HandlerT, DataT]): self, obj_id: str, controller: UniFiController, - description: UnifiEntityDescription[HandlerT, DataT], + description: UnifiEntityDescription[HandlerT, ApiItemT], ) -> None: """Set up UniFi switch entity.""" self._obj_id = obj_id diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 92f879c104..f43e303091 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==44"], + "requirements": ["aiounifi==47"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 05598589fe..3682fa0bf6 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -14,6 +14,7 @@ import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.ports import Ports +from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client from aiounifi.models.port import Port @@ -33,7 +34,6 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( - DataT, HandlerT, UnifiEntity, UnifiEntityDescription, @@ -45,17 +45,17 @@ from .entity import ( @callback def async_client_rx_value_fn(controller: UniFiController, client: Client) -> float: """Calculate receiving data transfer value.""" - if client.mac not in controller.wireless_clients: - return client.wired_rx_bytes_r / 1000000 - return client.rx_bytes_r / 1000000 + if controller.wireless_clients.is_wireless(client): + return client.rx_bytes_r / 1000000 + return client.wired_rx_bytes_r / 1000000 @callback def async_client_tx_value_fn(controller: UniFiController, client: Client) -> float: """Calculate transmission data transfer value.""" - if client.mac not in controller.wireless_clients: - return client.wired_tx_bytes_r / 1000000 - return client.tx_bytes_r / 1000000 + if controller.wireless_clients.is_wireless(client): + return client.tx_bytes_r / 1000000 + return client.wired_tx_bytes_r / 1000000 @callback @@ -80,17 +80,17 @@ def async_client_device_info_fn(api: aiounifi.Controller, obj_id: str) -> Device @dataclass -class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, DataT]): +class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" - value_fn: Callable[[UniFiController, DataT], datetime | float | str | None] + value_fn: Callable[[UniFiController, ApiItemT], datetime | float | str | None] @dataclass class UnifiSensorEntityDescription( SensorEntityDescription, - UnifiEntityDescription[HandlerT, DataT], - UnifiSensorEntityDescriptionMixin[HandlerT, DataT], + UnifiEntityDescription[HandlerT, ApiItemT], + UnifiSensorEntityDescriptionMixin[HandlerT, ApiItemT], ): """Class describing UniFi sensor entity.""" @@ -182,10 +182,10 @@ async def async_setup_entry( ) -class UnifiSensorEntity(UnifiEntity[HandlerT, DataT], SensorEntity): +class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity): """Base representation of a UniFi sensor.""" - entity_description: UnifiSensorEntityDescription[HandlerT, DataT] + entity_description: UnifiSensorEntityDescription[HandlerT, ApiItemT] @callback def async_update_state(self, event: ItemEvent, obj_id: str) -> None: diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index bf724cec1f..846c6d1223 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -17,6 +17,7 @@ from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.ports import Ports +from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client, ClientBlockRequest from aiounifi.models.device import ( DeviceSetOutletRelayRequest, @@ -47,7 +48,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( - DataT, HandlerT, SubscriptionT, UnifiEntity, @@ -131,23 +131,25 @@ async def async_poe_port_control_fn( """Control poe state.""" mac, _, index = obj_id.partition("_") device = api.devices[mac] - state = "auto" if target else "off" + port = api.ports[obj_id] + on_state = "auto" if port.raw["poe_caps"] != 8 else "passthrough" + state = on_state if target else "off" await api.request(DeviceSetPoePortModeRequest.create(device, int(index), state)) @dataclass -class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, DataT]): +class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" control_fn: Callable[[aiounifi.Controller, str, bool], Coroutine[Any, Any, None]] - is_on_fn: Callable[[UniFiController, DataT], bool] + is_on_fn: Callable[[UniFiController, ApiItemT], bool] @dataclass class UnifiSwitchEntityDescription( SwitchEntityDescription, - UnifiEntityDescription[HandlerT, DataT], - UnifiSwitchEntityDescriptionMixin[HandlerT, DataT], + UnifiEntityDescription[HandlerT, ApiItemT], + UnifiSwitchEntityDescriptionMixin[HandlerT, ApiItemT], ): """Class describing UniFi switch entity.""" @@ -247,18 +249,19 @@ async def async_setup_entry( for mac in controller.option_block_clients: if mac not in controller.api.clients and mac in controller.api.clients_all: - client = controller.api.clients_all[mac] - controller.api.clients.process_raw([client.raw]) + controller.api.clients.process_raw( + [dict(controller.api.clients_all[mac].raw)] + ) controller.register_platform_add_entities( UnifiSwitchEntity, ENTITY_DESCRIPTIONS, async_add_entities ) -class UnifiSwitchEntity(UnifiEntity[HandlerT, DataT], SwitchEntity): +class UnifiSwitchEntity(UnifiEntity[HandlerT, ApiItemT], SwitchEntity): """Base representation of a UniFi switch.""" - entity_description: UnifiSwitchEntityDescription[HandlerT, DataT] + entity_description: UnifiSwitchEntityDescription[HandlerT, ApiItemT] only_event_for_state_change = False @callback diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 571922d865..1ca030ce48 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping import logging +from pathlib import Path from typing import Any from aiohttp import CookieJar @@ -28,6 +29,7 @@ from homeassistant.helpers.aiohttp_client import ( async_create_clientsession, async_get_clientsession, ) +from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.loader import async_get_integration from homeassistant.util.network import is_ip_address @@ -248,6 +250,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD], verify_ssl=verify_ssl, + cache_dir=Path(self.hass.config.path(STORAGE_DIR, "unifiprotect_cache")), ) errors = {} diff --git a/homeassistant/components/unifiprotect/discovery.py b/homeassistant/components/unifiprotect/discovery.py index ea3730fa3e..885781c655 100644 --- a/homeassistant/components/unifiprotect/discovery.py +++ b/homeassistant/components/unifiprotect/discovery.py @@ -40,7 +40,10 @@ def async_start_discovery(hass: HomeAssistant) -> None: # Do not block startup since discovery takes 31s or more _async_start_background_discovery() async_track_time_interval( - hass, _async_start_background_discovery, DISCOVERY_INTERVAL + hass, + _async_start_background_discovery, + DISCOVERY_INTERVAL, + cancel_on_shutdown=True, ) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 77570a1cb3..b0741c4440 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -1,7 +1,7 @@ { "domain": "unifiprotect", "name": "UniFi Protect", - "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], + "codeowners": ["@AngellusMortis", "@bdraco"], "config_flow": true, "dependencies": ["http", "repairs"], "dhcp": [ @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.7.0", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.8.2", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 36870bf9c3..753563023f 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import timedelta from enum import Enum import logging from typing import Any, Final @@ -25,22 +24,15 @@ from pyunifiprotect.data import ( Sensor, Viewer, ) -import voluptuous as vol from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, EntityCategory +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, - async_get_current_platform, -) -from homeassistant.util.dt import utcnow +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_DURATION, ATTR_MESSAGE, DISPATCH_ADOPT, DOMAIN, TYPE_EMPTY_VALUE +from .const import DISPATCH_ADOPT, DOMAIN, TYPE_EMPTY_VALUE from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities from .models import PermRequired, ProtectSetableKeysMixin, T @@ -99,16 +91,6 @@ DEVICE_RECORDING_MODES = [ DEVICE_CLASS_LCD_MESSAGE: Final = "unifiprotect__lcd_message" -SERVICE_SET_DOORBELL_MESSAGE = "set_doorbell_message" - -SET_DOORBELL_LCD_MESSAGE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_MESSAGE): cv.string, - vol.Optional(ATTR_DURATION, default=""): cv.string, - } -) - @dataclass class ProtectSelectEntityDescription( @@ -352,12 +334,6 @@ async def async_setup_entry( ) async_add_entities(entities) - platform = async_get_current_platform() - platform.async_register_entity_service( - SERVICE_SET_DOORBELL_MESSAGE, - SET_DOORBELL_LCD_MESSAGE_SCHEMA, - "async_set_doorbell_message", - ) class ProtectSelects(ProtectDeviceEntity, SelectEntity): @@ -428,43 +404,3 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): if self.entity_description.ufp_enum_type is not None: unifi_value = self.entity_description.ufp_enum_type(unifi_value) await self.entity_description.ufp_set(self.device, unifi_value) - - async def async_set_doorbell_message(self, message: str, duration: str) -> None: - """Set LCD Message on Doorbell display.""" - - ir.async_create_issue( - self.hass, - DOMAIN, - "deprecated_service_set_doorbell_message", - breaks_in_ha_version="2023.3.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_placeholders={ - "link": ( - "https://www.home-assistant.io/integrations" - "/text#service-textset_value" - ) - }, - translation_key="deprecated_service_set_doorbell_message", - ) - - if self.entity_description.device_class != DEVICE_CLASS_LCD_MESSAGE: - raise HomeAssistantError("Not a doorbell text select entity") - - assert isinstance(self.device, Camera) - reset_at = None - timeout_msg = "" - if duration.isnumeric(): - reset_at = utcnow() + timedelta(minutes=int(duration)) - timeout_msg = f" with timeout of {duration} minute(s)" - - _LOGGER.debug( - 'Setting message for %s to "%s"%s', - self.device.display_name, - message, - timeout_msg, - ) - await self.device.set_lcd_text( - DoorbellMessageType.CUSTOM_MESSAGE, message, reset_at=reset_at - ) diff --git a/homeassistant/components/unifiprotect/services.yaml b/homeassistant/components/unifiprotect/services.yaml index 037c10627a..9f9031d654 100644 --- a/homeassistant/components/unifiprotect/services.yaml +++ b/homeassistant/components/unifiprotect/services.yaml @@ -52,38 +52,6 @@ set_default_doorbell_text: required: true selector: text: -set_doorbell_message: - name: Set Doorbell message - description: > - Use to dynamically set the message on a Doorbell LCD screen. This service should only be used to set dynamic messages (i.e. setting the current outdoor temperature on your Doorbell). Static messages should still be set using the Select entity and can be added/removed using the add_doorbell_text/remove_doorbell_text services. - fields: - entity_id: - name: Doorbell Text - description: The Doorbell Text select entity for your Doorbell. - example: "select.front_doorbell_camera_doorbell_text" - required: true - selector: - entity: - integration: unifiprotect - domain: select - message: - name: Message to display - description: The message you would like to display on the LCD screen of your Doorbell. Must be less than 30 characters. - example: "Welcome | 09:23 | 25°C" - required: true - selector: - text: - duration: - name: Duration - description: Number of minutes to display the message for before returning to the default message. The default is to not expire. - example: 5 - selector: - number: - min: 1 - max: 120 - step: 1 - mode: slider - unit_of_measurement: minutes set_chime_paired_doorbells: name: Set Chime Paired Doorbells description: > diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 2c0b894746..f8d578e1ca 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -24,7 +24,7 @@ }, "discovery_confirm": { "title": "UniFi Protect Discovered", - "description": "Do you want to set up {name} ({ip_address})? [%key:component::unifiprotect::config::step::user::description%]", + "description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud Users will not work. For more information: {local_user_documentation_url}", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index f58bb14eb4..3152213cce 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Generator, Iterable import contextlib from enum import Enum +from pathlib import Path import socket from typing import Any @@ -27,6 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.storage import STORAGE_DIR from .const import ( CONF_ALL_UPDATES, @@ -142,4 +144,5 @@ def async_create_api_client( override_connection_host=entry.options.get(CONF_OVERRIDE_CHOST, False), ignore_stats=not entry.options.get(CONF_ALL_UPDATES, False), ignore_unadopted=False, + cache_dir=Path(hass.config.path(STORAGE_DIR, "unifiprotect_cache")), ) diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 21d741d345..fd73ad33e1 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -630,7 +630,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Return a BrowseMedia instance.""" diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index eb4a9b7afe..1ffb8cfd94 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -15,6 +15,12 @@ }, { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:2" + }, + { + "nt": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" + }, + { + "nt": "urn:schemas-upnp-org:device:InternetGatewayDevice:2" } ] } diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 17d6f679cf..c6a18a27b4 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -101,7 +101,7 @@ def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> boo ) -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class UsbServiceInfo(BaseServiceInfo): """Prepared info from usb entries.""" @@ -205,11 +205,17 @@ class USBDiscovery: """Set up USB Discovery.""" await self._async_start_monitor() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) async def async_start(self, event: Event) -> None: """Start USB Discovery and run a manual scan.""" await self._async_scan_serial() + async def async_stop(self, event: Event) -> None: + """Stop USB Discovery.""" + if self._request_debouncer: + await self._request_debouncer.async_shutdown() + async def _async_start_monitor(self) -> None: """Start monitoring hardware with pyudev.""" if not sys.platform.startswith("linux"): diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index c436ea757a..11e58fca77 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -21,6 +21,7 @@ from .const import ( CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, + CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, CONF_SOURCE_SENSOR, CONF_TARIFF, @@ -83,6 +84,7 @@ METER_CONFIG_SCHEMA = vol.Schema( ), vol.Optional(CONF_METER_DELTA_VALUES, default=False): cv.boolean, vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean, + vol.Optional(CONF_METER_PERIODICALLY_RESETTING, default=True): cv.boolean, vol.Optional(CONF_TARIFFS, default=[]): vol.All( cv.ensure_list, vol.Unique(), [cv.string] ), @@ -221,13 +223,29 @@ async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + platforms_to_unload = [Platform.SENSOR] + if entry.options.get(CONF_TARIFFS): + platforms_to_unload.append(Platform.SELECT) + if unload_ok := await hass.config_entries.async_unload_platforms( entry, - ( - Platform.SELECT, - Platform.SENSOR, - ), + platforms_to_unload, ): hass.data[DATA_UTILITY].pop(entry.entry_id) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + new = {**config_entry.options} + new[CONF_METER_PERIODICALLY_RESETTING] = True + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, options=new) + + _LOGGER.info("Migration to version %s successful", config_entry.version) + + return True diff --git a/homeassistant/components/utility_meter/config_flow.py b/homeassistant/components/utility_meter/config_flow.py index c1f82e902d..eb5c19941d 100644 --- a/homeassistant/components/utility_meter/config_flow.py +++ b/homeassistant/components/utility_meter/config_flow.py @@ -21,6 +21,7 @@ from .const import ( CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, + CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, CONF_SOURCE_SENSOR, CONF_TARIFFS, @@ -64,6 +65,9 @@ OPTIONS_SCHEMA = vol.Schema( vol.Required(CONF_SOURCE_SENSOR): selector.EntitySelector( selector.EntitySelectorConfig(domain=SENSOR_DOMAIN), ), + vol.Required( + CONF_METER_PERIODICALLY_RESETTING, + ): selector.BooleanSelector(), } ) @@ -95,6 +99,10 @@ CONFIG_SCHEMA = vol.Schema( vol.Required( CONF_METER_DELTA_VALUES, default=False ): selector.BooleanSelector(), + vol.Required( + CONF_METER_PERIODICALLY_RESETTING, + default=True, + ): selector.BooleanSelector(), } ) @@ -110,6 +118,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Utility Meter.""" + VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 9b85e9e3ae..f8a4c2d4b7 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -32,6 +32,7 @@ CONF_METER_TYPE = "cycle" CONF_METER_OFFSET = "offset" CONF_METER_DELTA_VALUES = "delta_values" CONF_METER_NET_CONSUMPTION = "net_consumption" +CONF_METER_PERIODICALLY_RESETTING = "periodically_resetting" CONF_PAUSED = "paused" CONF_TARIFFS = "tariffs" CONF_TARIFF = "tariff" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 066a3cd6e1..050e801f05 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfEnergy, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -35,7 +35,7 @@ from homeassistant.helpers.event import ( async_track_point_in_time, async_track_state_change_event, ) -from homeassistant.helpers.start import async_at_start +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.template import is_number from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify @@ -50,6 +50,7 @@ from .const import ( CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, + CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, CONF_SOURCE_SENSOR, CONF_TARIFF, @@ -85,6 +86,7 @@ ATTR_SOURCE_ID = "source" ATTR_STATUS = "status" ATTR_PERIOD = "meter_period" ATTR_LAST_PERIOD = "last_period" +ATTR_LAST_VALID_STATE = "last_valid_state" ATTR_TARIFF = "tariff" DEVICE_CLASS_MAP = { @@ -92,7 +94,6 @@ DEVICE_CLASS_MAP = { UnitOfEnergy.KILO_WATT_HOUR: SensorDeviceClass.ENERGY, } -ICON = "mdi:counter" PRECISION = 3 PAUSED = "paused" @@ -127,6 +128,7 @@ async def async_setup_entry( meter_type = None name = config_entry.title net_consumption = config_entry.options[CONF_METER_NET_CONSUMPTION] + periodically_resetting = config_entry.options[CONF_METER_PERIODICALLY_RESETTING] tariff_entity = hass.data[DATA_UTILITY][entry_id][CONF_TARIFF_ENTITY] meters = [] @@ -142,6 +144,7 @@ async def async_setup_entry( name=name, net_consumption=net_consumption, parent_meter=entry_id, + periodically_resetting=periodically_resetting, source_entity=source_entity_id, tariff_entity=tariff_entity, tariff=None, @@ -160,6 +163,7 @@ async def async_setup_entry( name=f"{name} {tariff}", net_consumption=net_consumption, parent_meter=entry_id, + periodically_resetting=periodically_resetting, source_entity=source_entity_id, tariff_entity=tariff_entity, tariff=tariff, @@ -223,6 +227,9 @@ async def async_setup_platform( conf_meter_net_consumption = hass.data[DATA_UTILITY][meter][ CONF_METER_NET_CONSUMPTION ] + conf_meter_periodically_resetting = hass.data[DATA_UTILITY][meter][ + CONF_METER_PERIODICALLY_RESETTING + ] conf_meter_tariff_entity = hass.data[DATA_UTILITY][meter].get( CONF_TARIFF_ENTITY ) @@ -235,6 +242,7 @@ async def async_setup_platform( name=conf_sensor_name, net_consumption=conf_meter_net_consumption, parent_meter=meter, + periodically_resetting=conf_meter_periodically_resetting, source_entity=conf_meter_source, tariff_entity=conf_meter_tariff_entity, tariff=conf_sensor_tariff, @@ -262,6 +270,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): last_period: Decimal last_reset: datetime | None + last_valid_state: Decimal | None status: str def as_dict(self) -> dict[str, Any]: @@ -270,6 +279,9 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): data["last_period"] = str(self.last_period) if isinstance(self.last_reset, (datetime)): data["last_reset"] = self.last_reset.isoformat() + data["last_valid_state"] = ( + str(self.last_valid_state) if self.last_valid_state else None + ) data["status"] = self.status return data @@ -284,6 +296,11 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): try: last_period: Decimal = Decimal(restored["last_period"]) last_reset: datetime | None = dt_util.parse_datetime(restored["last_reset"]) + last_valid_state: Decimal | None = ( + Decimal(restored["last_valid_state"]) + if restored.get("last_valid_state") + else None + ) status: str = restored["status"] except KeyError: # restored is a dict, but does not have all values @@ -297,6 +314,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): extra.native_unit_of_measurement, last_period, last_reset, + last_valid_state, status, ) @@ -304,6 +322,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): class UtilityMeterSensor(RestoreSensor): """Representation of an utility meter sensor.""" + _attr_icon = "mdi:counter" _attr_should_poll = False def __init__( @@ -316,6 +335,7 @@ class UtilityMeterSensor(RestoreSensor): name, net_consumption, parent_meter, + periodically_resetting, source_entity, tariff_entity, tariff, @@ -330,6 +350,7 @@ class UtilityMeterSensor(RestoreSensor): self._state = None self._last_period = Decimal(0) self._last_reset = dt_util.utcnow() + self._last_valid_state = None self._collecting = None self._name = name self._unit_of_measurement = None @@ -346,6 +367,7 @@ class UtilityMeterSensor(RestoreSensor): self._cron_pattern = cron_pattern self._sensor_delta_values = delta_values self._sensor_net_consumption = net_consumption + self._sensor_periodically_resetting = periodically_resetting self._tariff = tariff self._tariff_entity = tariff_entity @@ -355,53 +377,88 @@ class UtilityMeterSensor(RestoreSensor): self._state = 0 self.async_write_ha_state() - @callback - def async_reading(self, event): - """Handle the sensor state changes.""" - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + @staticmethod + def _validate_state(state: State | None) -> Decimal | None: + """Parse the state as a Decimal if available. Throws DecimalException if the state is not a number.""" + try: + return ( + None + if state is None or state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN] + else Decimal(state.state) + ) + except DecimalException: + return None - if self._state is None and new_state.state: + def calculate_adjustment( + self, old_state: State | None, new_state: State + ) -> Decimal | None: + """Calculate the adjustment based on the old and new state.""" + + # First check if the new_state is valid (see discussion in PR #88446) + if (new_state_val := self._validate_state(new_state)) is None: + _LOGGER.warning("Invalid state %s", new_state.state) + return None + + if self._sensor_delta_values: + return new_state_val + + if ( + not self._sensor_periodically_resetting + and self._last_valid_state is not None + ): # Fallback to old_state if sensor is periodically resetting but last_valid_state is None + return new_state_val - self._last_valid_state + + if (old_state_val := self._validate_state(old_state)) is not None: + return new_state_val - old_state_val + + _LOGGER.warning( + "%s received an invalid state change coming from %s (%s > %s)", + self.name, + self._sensor_source_id, + old_state.state if old_state else None, + new_state_val, + ) + return None + + @callback + def async_reading(self, event: Event): + """Handle the sensor state changes.""" + if ( + source_state := self.hass.states.get(self._sensor_source_id) + ) is None or source_state.state == STATE_UNAVAILABLE: + self._attr_available = False + self.async_write_ha_state() + return + + self._attr_available = True + + old_state: State | None = event.data.get("old_state") + new_state: State = event.data.get("new_state") # type: ignore[assignment] # a state change event always has a new state + + # First check if the new_state is valid (see discussion in PR #88446) + if (new_state_val := self._validate_state(new_state)) is None: + _LOGGER.warning( + "%s received an invalid new state from %s : %s", + self.name, + self._sensor_source_id, + new_state.state, + ) + return + + if self._state is None: # First state update initializes the utility_meter sensors - source_state = self.hass.states.get(self._sensor_source_id) for sensor in self.hass.data[DATA_UTILITY][self._parent_meter][ DATA_TARIFF_SENSORS ]: - sensor.start(source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) + sensor.start(new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) if ( - new_state is None - or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - or ( - not self._sensor_delta_values - and ( - old_state is None - or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - ) - ) - ): - return + adjustment := self.calculate_adjustment(old_state, new_state) + ) is not None and (self._sensor_net_consumption or adjustment >= 0): + # If net_consumption is off, the adjustment must be non-negative + self._state += adjustment # type: ignore[operator] # self._state will be set to by the start function if it is None, therefore it always has a valid Decimal value at this line - self._unit_of_measurement = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - - try: - if self._sensor_delta_values: - adjustment = Decimal(new_state.state) - else: - adjustment = Decimal(new_state.state) - Decimal(old_state.state) - - if (not self._sensor_net_consumption) and adjustment < 0: - # Source sensor just rolled over for unknown reasons, - return - self._state += adjustment - - except DecimalException as err: - if self._sensor_delta_values: - _LOGGER.warning("Invalid adjustment of %s: %s", new_state.state, err) - else: - _LOGGER.warning( - "Invalid state (%s > %s): %s", old_state.state, new_state.state, err - ) + self._last_valid_state = new_state_val self.async_write_ha_state() @callback @@ -422,6 +479,11 @@ class UtilityMeterSensor(RestoreSensor): self._collecting() self._collecting = None + # Reset the last_valid_state during state change because if the last state before the tariff change was invalid, + # there is no way to know how much "adjustment" counts for which tariff. Therefore, we set the last_valid_state + # to None and let the fallback mechanism handle the case that the old state was valid + self._last_valid_state = None + _LOGGER.debug( "%s - %s - source <%s>", self._name, @@ -484,6 +546,7 @@ class UtilityMeterSensor(RestoreSensor): self._unit_of_measurement = last_sensor_data.native_unit_of_measurement self._last_period = last_sensor_data.last_period self._last_reset = last_sensor_data.last_reset + self._last_valid_state = last_sensor_data.last_valid_state if last_sensor_data.status == COLLECTING: # Null lambda to allow cancelling the collection on tariff change self._collecting = lambda: None @@ -508,6 +571,12 @@ class UtilityMeterSensor(RestoreSensor): and is_number(state.attributes[ATTR_LAST_PERIOD]) else Decimal(0) ) + self._last_valid_state = ( + Decimal(state.attributes[ATTR_LAST_VALID_STATE]) + if state.attributes.get(ATTR_LAST_VALID_STATE) + and is_number(state.attributes[ATTR_LAST_VALID_STATE]) + else None + ) self._last_reset = dt_util.as_utc( dt_util.parse_datetime(state.attributes.get(ATTR_LAST_RESET)) ) @@ -546,7 +615,7 @@ class UtilityMeterSensor(RestoreSensor): self.hass, [self._sensor_source_id], self.async_reading ) - self.async_on_remove(async_at_start(self.hass, async_source_tracking)) + self.async_on_remove(async_at_started(self.hass, async_source_tracking)) async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" @@ -590,6 +659,7 @@ class UtilityMeterSensor(RestoreSensor): ATTR_SOURCE_ID: self._sensor_source_id, ATTR_STATUS: PAUSED if self._collecting is None else COLLECTING, ATTR_LAST_PERIOD: str(self._last_period), + ATTR_LAST_VALID_STATE: str(self._last_valid_state), } if self._period is not None: state_attr[ATTR_PERIOD] = self._period @@ -607,11 +677,6 @@ class UtilityMeterSensor(RestoreSensor): return state_attr - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def extra_restore_state_data(self) -> UtilitySensorExtraStoredData: """Return sensor specific state data to be restored.""" @@ -620,6 +685,7 @@ class UtilityMeterSensor(RestoreSensor): self.native_unit_of_measurement, self._last_period, self._last_reset, + self._last_valid_state, PAUSED if self._collecting is None else COLLECTING, ) diff --git a/homeassistant/components/utility_meter/strings.json b/homeassistant/components/utility_meter/strings.json index e9f8e7f250..1eeacbae80 100644 --- a/homeassistant/components/utility_meter/strings.json +++ b/homeassistant/components/utility_meter/strings.json @@ -9,6 +9,7 @@ "cycle": "Meter reset cycle", "delta_values": "Delta values", "name": "Name", + "periodically_resetting": "Periodically resetting", "net_consumption": "Net consumption", "offset": "Meter reset offset", "source": "Input sensor", @@ -17,6 +18,7 @@ "data_description": { "delta_values": "Enable if the source values are delta values since the last reading instead of absolute values.", "net_consumption": "Enable if the source is a net meter, meaning it can both increase and decrease.", + "periodically_resetting": "Enable if the source may periodically reset to 0, for example at boot of the measuring device. If disabled, new readings are directly recorded after data inavailability.", "offset": "Offset the day of a monthly meter reset.", "tariffs": "A list of supported tariffs, leave empty if only a single tariff is needed." } @@ -27,7 +29,11 @@ "step": { "init": { "data": { - "source": "[%key:component::utility_meter::config::step::user::data::source%]" + "source": "[%key:component::utility_meter::config::step::user::data::source%]", + "periodically_resetting": "[%key:component::utility_meter::config::step::user::data::periodically_resetting%]" + }, + "data_description": { + "periodically_resetting": "[%key:component::utility_meter::config::step::user::data_description::periodically_resetting%]" } } } diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json index 924eb921a7..4f3fcbf9c8 100644 --- a/homeassistant/components/vallox/manifest.json +++ b/homeassistant/components/vallox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vallox", "iot_class": "local_polling", "loggers": ["vallox_websocket_api"], - "requirements": ["vallox-websocket-api==3.0.0"] + "requirements": ["vallox-websocket-api==3.2.1"] } diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 118d04d3c1..711f66ea03 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -32,7 +32,6 @@ CONF_SECRET = "secret" DEFAULT_DELAY = 0 -ICON = "mdi:train" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) @@ -83,6 +82,7 @@ class VasttrafikDepartureSensor(SensorEntity): """Implementation of a Vasttrafik Departure Sensor.""" _attr_attribution = "Data provided by Västtrafik" + _attr_icon = "mdi:train" def __init__(self, planner, name, departure, heading, lines, delay): """Initialize the sensor.""" @@ -110,11 +110,6 @@ class VasttrafikDepartureSensor(SensorEntity): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index a51cef0a56..b2b1cb3162 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -34,6 +34,7 @@ PLATFORMS = [ Platform.CLIMATE, Platform.COVER, Platform.LIGHT, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] @@ -209,8 +210,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> await hass.async_add_executor_job(shutil.rmtree, cache_path) # set the new version config_entry.version = 2 - # update the entry - hass.config_entries.async_update_entry(config_entry) _LOGGER.debug("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/velbus/select.py b/homeassistant/components/velbus/select.py new file mode 100644 index 0000000000..af79b5d127 --- /dev/null +++ b/homeassistant/components/velbus/select.py @@ -0,0 +1,47 @@ +"""Support for Velbus select.""" +from velbusaio.channels import SelectedProgram + +from homeassistant.components.select import SelectEntity +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 .entity import VelbusEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Velbus select based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] + cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] + async_add_entities(VelbusSelect(channel) for channel in cntrl.get_all("select")) + + +class VelbusSelect(VelbusEntity, SelectEntity): + """Representation of a select option for velbus.""" + + _channel: SelectedProgram + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + channel: SelectedProgram, + ) -> None: + """Initialize a select Velbus entity.""" + super().__init__(channel) + self._attr_options = self._channel.get_options() + self._attr_unique_id = f"{self._attr_unique_id}-program_select" + + async def async_select_option(self, option: str) -> None: + """Update the program on the module.""" + await self._channel.set_selected_program(option) + + @property + def current_option(self) -> str: + """Return the selected option.""" + return self._channel.get_selected_program() diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py index 9ad8db08d5..94e8d667d7 100644 --- a/homeassistant/components/verisure/__init__.py +++ b/homeassistant/components/verisure/__init__.py @@ -6,9 +6,9 @@ import os from pathlib import Path from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import CONF_EMAIL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.storage import STORAGE_DIR @@ -34,11 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = VerisureDataUpdateCoordinator(hass, entry=entry) if not await coordinator.async_login(): - raise ConfigEntryAuthFailed - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.async_logout) - ) + raise ConfigEntryNotReady("Could not log in to verisure.") await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 5030e01c8b..9615404a9a 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -9,6 +9,7 @@ from homeassistant.components.alarm_control_panel import ( CodeFormat, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ALARM_ARMING, STATE_ALARM_DISARMING from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -55,33 +56,55 @@ class VerisureAlarm( """Return the unique ID for this entity.""" return self.coordinator.entry.data[CONF_GIID] - async def _async_set_arm_state(self, state: str, code: str | None = None) -> None: + async def _async_set_arm_state( + self, state: str, command_data: dict[str, str | dict[str, str]] + ) -> None: """Send set arm state command.""" arm_state = await self.hass.async_add_executor_job( - self.coordinator.verisure.set_arm_state, code, state + self.coordinator.verisure.request, command_data ) LOGGER.debug("Verisure set arm state %s", state) - transaction = {} - while "result" not in transaction: + result = None + while result is None: await asyncio.sleep(0.5) transaction = await self.hass.async_add_executor_job( - self.coordinator.verisure.get_arm_state_transaction, - arm_state["armStateChangeTransactionId"], + self.coordinator.verisure.request, + self.coordinator.verisure.poll_arm_state( + list(arm_state["data"].values())[0], state + ), + ) + result = ( + transaction.get("data", {}) + .get("installation", {}) + .get("armStateChangePollResult", {}) + .get("result") ) await self.coordinator.async_refresh() async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - await self._async_set_arm_state("DISARMED", code) + self._attr_state = STATE_ALARM_DISARMING + self.async_write_ha_state() + await self._async_set_arm_state( + "DISARMED", self.coordinator.verisure.disarm(code) + ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - await self._async_set_arm_state("ARMED_HOME", code) + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() + await self._async_set_arm_state( + "ARMED_HOME", self.coordinator.verisure.arm_home(code) + ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - await self._async_set_arm_state("ARMED_AWAY", code) + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() + await self._async_set_arm_state( + "ARMED_AWAY", self.coordinator.verisure.arm_away(code) + ) @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index 8283480a14..68d549eaa5 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -6,11 +6,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import ATTR_LAST_TRIP_TIME, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import CONF_GIID, DOMAIN from .coordinator import VerisureDataUpdateCoordinator @@ -79,6 +80,15 @@ class VerisureDoorWindowSensor( and self.serial_number in self.coordinator.data["door_window"] ) + @property + def extra_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_LAST_TRIP_TIME: dt_util.parse_datetime( + self.coordinator.data["door_window"][self.serial_number]["reportTime"] + ) + } + class VerisureEthernetStatus( CoordinatorEntity[VerisureDataUpdateCoordinator], BinarySensorEntity @@ -88,7 +98,7 @@ class VerisureEthernetStatus( _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_has_entity_name = True - _attr_name = "Ethernet status" + _attr_translation_key = "ethernet" @property def unique_id(self) -> str: @@ -109,9 +119,9 @@ class VerisureEthernetStatus( @property def is_on(self) -> bool: """Return the state of the sensor.""" - return self.coordinator.data["ethernet"] + return self.coordinator.data["broadband"]["isBroadbandConnected"] @property def available(self) -> bool: """Return True if entity is available.""" - return super().available and self.coordinator.data["ethernet"] is not None + return super().available and self.coordinator.data["broadband"] is not None diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index 98ed41c5b9..1f890a22a6 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -63,12 +63,12 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) self.serial_number = serial_number self._directory_path = directory_path self._image: str | None = None - self._image_id = None + self._image_id: str | None = None @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - area = self.coordinator.data["cameras"][self.serial_number]["area"] + area = self.coordinator.data["cameras"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -95,16 +95,16 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) """Check the contents of the image list.""" self.coordinator.update_smartcam_imageseries() - images = self.coordinator.imageseries.get("imageSeries", []) - new_image_id = None - for image in images: + new_image = None + for image in self.coordinator.imageseries: if image["deviceLabel"] == self.serial_number: - new_image_id = image["image"][0]["imageId"] + new_image = image break - if not new_image_id: + if not new_image: return + new_image_id = new_image["mediaId"] if new_image_id in ("-1", self._image_id): LOGGER.debug("The image is the same, or loading image_id") return @@ -113,9 +113,8 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) new_image_path = os.path.join( self._directory_path, "{}{}".format(new_image_id, ".jpg") ) - self.coordinator.verisure.download_image( - self.serial_number, new_image_id, new_image_path - ) + new_image_url = new_image["contentUrl"] + self.coordinator.verisure.download_image(new_image_url, new_image_path) LOGGER.debug("Old image_id=%s", self._image_id) self.delete_image() diff --git a/homeassistant/components/verisure/config_flow.py b/homeassistant/components/verisure/config_flow.py index d53c7c9ed6..9392cdd9bc 100644 --- a/homeassistant/components/verisure/config_flow.py +++ b/homeassistant/components/verisure/config_flow.py @@ -56,7 +56,7 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): self.verisure = Verisure( username=self.email, password=self.password, - cookieFileName=self.hass.config.path( + cookie_file_name=self.hass.config.path( STORAGE_DIR, f"verisure_{user_input[CONF_EMAIL]}" ), ) @@ -66,7 +66,9 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): except VerisureLoginError as ex: if "Multifactor authentication enabled" in str(ex): try: - await self.hass.async_add_executor_job(self.verisure.login_mfa) + await self.hass.async_add_executor_job( + self.verisure.request_mfa + ) except ( VerisureLoginError, VerisureError, @@ -108,9 +110,8 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: await self.hass.async_add_executor_job( - self.verisure.mfa_validate, user_input[CONF_CODE], True + self.verisure.validate_mfa, user_input[CONF_CODE] ) - await self.hass.async_add_executor_job(self.verisure.login) except VerisureLoginError as ex: LOGGER.debug("Could not log in to Verisure, %s", ex) errors["base"] = "invalid_auth" @@ -136,9 +137,16 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Select Verisure installation to add.""" + installations_data = await self.hass.async_add_executor_job( + self.verisure.get_installations + ) installations = { - inst["giid"]: f"{inst['alias']} ({inst['street']})" - for inst in self.verisure.installations or [] + inst["giid"]: f"{inst['alias']} ({inst['address']['street']})" + for inst in ( + installations_data.get("data", {}) + .get("account", {}) + .get("installations", []) + ) } if user_input is None: @@ -184,8 +192,8 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): self.verisure = Verisure( username=self.email, password=self.password, - cookieFileName=self.hass.config.path( - STORAGE_DIR, f"verisure-{user_input[CONF_EMAIL]}" + cookie_file_name=self.hass.config.path( + STORAGE_DIR, f"verisure_{user_input[CONF_EMAIL]}" ), ) @@ -194,7 +202,9 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): except VerisureLoginError as ex: if "Multifactor authentication enabled" in str(ex): try: - await self.hass.async_add_executor_job(self.verisure.login_mfa) + await self.hass.async_add_executor_job( + self.verisure.request_mfa + ) except ( VerisureLoginError, VerisureError, @@ -248,7 +258,7 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: await self.hass.async_add_executor_job( - self.verisure.mfa_validate, user_input[CONF_CODE], True + self.verisure.validate_mfa, user_input[CONF_CODE] ) await self.hass.async_add_executor_job(self.verisure.login) except VerisureLoginError as ex: diff --git a/homeassistant/components/verisure/const.py b/homeassistant/components/verisure/const.py index e8720baa1d..ac30c58fde 100644 --- a/homeassistant/components/verisure/const.py +++ b/homeassistant/components/verisure/const.py @@ -36,6 +36,9 @@ DEVICE_TYPE_NAME = { "SMOKE3": "Smoke detector", "VOICEBOX1": "VoiceBox", "WATER1": "Water detector", + "SMOKE": "Smoke detector", + "SIREN": "Siren", + "VOICEBOX": "VoiceBox", } ALARM_STATE_TO_HA = { diff --git a/homeassistant/components/verisure/coordinator.py b/homeassistant/components/verisure/coordinator.py index 17cadb9598..47fbde3ef2 100644 --- a/homeassistant/components/verisure/coordinator.py +++ b/homeassistant/components/verisure/coordinator.py @@ -2,19 +2,21 @@ from __future__ import annotations from datetime import timedelta -from http import HTTPStatus +from time import sleep from verisure import ( Error as VerisureError, + LoginError as VerisureLoginError, ResponseError as VerisureResponseError, Session as Verisure, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.storage import STORAGE_DIR -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import Throttle from .const import CONF_GIID, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER @@ -25,13 +27,14 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the Verisure hub.""" - self.imageseries: dict[str, list] = {} + self.imageseries: list[dict[str, str]] = [] self.entry = entry + self._overview: list[dict] = [] self.verisure = Verisure( username=entry.data[CONF_EMAIL], password=entry.data[CONF_PASSWORD], - cookieFileName=hass.config.path( + cookie_file_name=hass.config.path( STORAGE_DIR, f"verisure_{entry.data[CONF_EMAIL]}" ), ) @@ -43,8 +46,11 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): async def async_login(self) -> bool: """Login to Verisure.""" try: - await self.hass.async_add_executor_job(self.verisure.login) - except VerisureError as ex: + await self.hass.async_add_executor_job(self.verisure.login_cookie) + except VerisureLoginError as ex: + LOGGER.error("Could not log in to verisure, %s", ex) + raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex + except VerisureResponseError as ex: LOGGER.error("Could not log in to verisure, %s", ex) return False @@ -54,62 +60,116 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): return True - async def async_logout(self, _event: Event) -> None: - """Logout from Verisure.""" - try: - await self.hass.async_add_executor_job(self.verisure.logout) - except VerisureError as ex: - LOGGER.error("Could not log out from verisure, %s", ex) - async def _async_update_data(self) -> dict: """Fetch data from Verisure.""" try: - overview = await self.hass.async_add_executor_job( - self.verisure.get_overview - ) + await self.hass.async_add_executor_job(self.verisure.update_cookie) + except VerisureLoginError as ex: + LOGGER.error("Credentials expired for Verisure, %s", ex) + raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex except VerisureResponseError as ex: - LOGGER.error("Could not read overview, %s", ex) - if ex.status_code == HTTPStatus.SERVICE_UNAVAILABLE: - LOGGER.info("Trying to log in again") - await self.async_login() - return {} - raise + LOGGER.error("Could not log in to verisure, %s", ex) + raise ConfigEntryAuthFailed("Could not log in to verisure") from ex + try: + overview = await self.hass.async_add_executor_job( + self.verisure.request, + self.verisure.arm_state(), + self.verisure.broadband(), + self.verisure.cameras(), + self.verisure.climate(), + self.verisure.door_window(), + self.verisure.smart_lock(), + self.verisure.smartplugs(), + ) + except VerisureResponseError as err: + LOGGER.debug("Cookie expired or service unavailable, %s", err) + overview = self._overview + try: + await self.hass.async_add_executor_job(self.verisure.update_cookie) + except VerisureResponseError as ex: + raise ConfigEntryAuthFailed("Credentials for Verisure expired.") from ex + except VerisureError as err: + LOGGER.error("Could not read overview, %s", err) + raise UpdateFailed("Could not read overview") from err + + def unpack(overview: list, value: str) -> dict | list: + return next( + ( + item["data"]["installation"][value] + for item in overview + if value in item.get("data", {}).get("installation", {}) + ), + [], + ) # Store data in a way Home Assistant can easily consume it + self._overview = overview return { - "alarm": overview["armState"], - "ethernet": overview.get("ethernetConnectedNow"), + "alarm": unpack(overview, "armState"), + "broadband": unpack(overview, "broadband"), "cameras": { - device["deviceLabel"]: device - for device in overview["customerImageCameras"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "cameras") }, "climate": { - device["deviceLabel"]: device for device in overview["climateValues"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "climates") }, "door_window": { - device["deviceLabel"]: device - for device in overview["doorWindow"]["doorWindowDevice"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "doorWindows") }, "locks": { - device["deviceLabel"]: device - for device in overview["doorLockStatusList"] - }, - "mice": { - device["deviceLabel"]: device - for device in overview["eventCounts"] - if device["deviceType"] == "MOUSE1" + device["device"]["deviceLabel"]: device + for device in unpack(overview, "smartLocks") }, "smart_plugs": { - device["deviceLabel"]: device for device in overview["smartPlugs"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "smartplugs") }, } @Throttle(timedelta(seconds=60)) def update_smartcam_imageseries(self) -> None: """Update the image series.""" - self.imageseries = self.verisure.get_camera_imageseries() + image_data = self.verisure.request(self.verisure.cameras_image_series()) + self.imageseries = [ + content + for series in ( + image_data.get("data", {}) + .get("ContentProviderMediaSearch", {}) + .get("mediaSeriesList", []) + ) + for content in series.get("deviceMediaList", []) + if content.get("contentType") == "IMAGE_JPEG" + ] @Throttle(timedelta(seconds=30)) def smartcam_capture(self, device_id: str) -> None: """Capture a new image from a smartcam.""" - self.verisure.capture_image(device_id) + capture_request = self.verisure.request( + self.verisure.camera_get_request_id(device_id) + ) + request_id = ( + capture_request.get("data", {}) + .get("ContentProviderCaptureImageRequest", {}) + .get("requestId") + ) + capture_status = None + attempts = 0 + while capture_status != "AVAILABLE": + if attempts == 30: + break + if attempts > 1: + sleep(0.5) + attempts += 1 + capture_data = self.verisure.request( + self.verisure.camera_capture(device_id, request_id) + ) + capture_status = ( + capture_data.get("data", {}) + .get("installation", {}) + .get("cameraContentProvider", {}) + .get("captureImageRequestStatus", {}) + .get("mediaRequestStatus") + ) diff --git a/homeassistant/components/verisure/diagnostics.py b/homeassistant/components/verisure/diagnostics.py index 740aff0b90..8dbffe6eee 100644 --- a/homeassistant/components/verisure/diagnostics.py +++ b/homeassistant/components/verisure/diagnostics.py @@ -16,6 +16,7 @@ TO_REDACT = { "deviceArea", "name", "time", + "reportTime", "userString", } diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 02cdad158c..53646c1e43 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -77,7 +77,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - area = self.coordinator.data["locks"][self.serial_number]["area"] + area = self.coordinator.data["locks"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -98,12 +98,16 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt @property def changed_by(self) -> str | None: """Last change triggered by.""" - return self.coordinator.data["locks"][self.serial_number].get("userString") + return ( + self.coordinator.data["locks"][self.serial_number] + .get("user", {}) + .get("name") + ) @property def changed_method(self) -> str: """Last change method.""" - return self.coordinator.data["locks"][self.serial_number]["method"] + return self.coordinator.data["locks"][self.serial_number]["lockMethod"] @property def code_format(self) -> str: @@ -114,8 +118,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt def is_locked(self) -> bool: """Return true if lock is locked.""" return ( - self.coordinator.data["locks"][self.serial_number]["lockedState"] - == "LOCKED" + self.coordinator.data["locks"][self.serial_number]["lockStatus"] == "LOCKED" ) @property @@ -147,36 +150,48 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt async def async_set_lock_state(self, code: str, state: str) -> None: """Send set lock state command.""" - target_state = "lock" if state == STATE_LOCKED else "unlock" - lock_state = await self.hass.async_add_executor_job( - self.coordinator.verisure.set_lock_state, - code, - self.serial_number, - target_state, + command = ( + self.coordinator.verisure.door_lock(self.serial_number, code) + if state == STATE_LOCKED + else self.coordinator.verisure.door_unlock(self.serial_number, code) + ) + lock_request = await self.hass.async_add_executor_job( + self.coordinator.verisure.request, + command, ) - LOGGER.debug("Verisure doorlock %s", state) - transaction = {} + transaction_id = lock_request.get("data", {}).get(command["operationName"]) + target_state = "LOCKED" if state == STATE_LOCKED else "UNLOCKED" + lock_status = None attempts = 0 - while "result" not in transaction: - transaction = await self.hass.async_add_executor_job( - self.coordinator.verisure.get_lock_state_transaction, - lock_state["doorLockStateChangeTransactionId"], - ) - attempts += 1 + while lock_status != "OK": if attempts == 30: break if attempts > 1: await asyncio.sleep(0.5) - if transaction["result"] == "OK": + attempts += 1 + poll_data = await self.hass.async_add_executor_job( + self.coordinator.verisure.request, + self.coordinator.verisure.poll_lock_state( + transaction_id, self.serial_number, target_state + ), + ) + lock_status = ( + poll_data.get("data", {}) + .get("installation", {}) + .get("doorLockStateChangePollResult", {}) + .get("result") + ) + if lock_status == "OK": self._state = state def disable_autolock(self) -> None: """Disable autolock on a doorlock.""" try: - self.coordinator.verisure.set_lock_config( + command = self.coordinator.verisure.set_autolock_enabled( self.serial_number, auto_lock_enabled=False ) + self.coordinator.verisure.request(command) LOGGER.debug("Disabling autolock on %s", self.serial_number) except VerisureError as ex: LOGGER.error("Could not disable autolock, %s", ex) @@ -184,9 +199,10 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt def enable_autolock(self) -> None: """Enable autolock on a doorlock.""" try: - self.coordinator.verisure.set_lock_config( + command = self.coordinator.verisure.set_autolock_enabled( self.serial_number, auto_lock_enabled=True ) + self.coordinator.verisure.request(command) LOGGER.debug("Enabling autolock on %s", self.serial_number) except VerisureError as ex: LOGGER.error("Could not enable autolock, %s", ex) diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 9e177a514a..66dccdc07d 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -1,7 +1,7 @@ { "domain": "verisure", "name": "Verisure", - "codeowners": ["@frenck"], + "codeowners": ["@frenck", "@niro1987"], "config_flow": true, "dhcp": [ { @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["verisure"], - "requirements": ["vsure==1.8.1"] + "requirements": ["vsure==2.6.1"] } diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index bbc1c15159..7c9639b654 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -28,18 +28,13 @@ async def async_setup_entry( sensors: list[Entity] = [ VerisureThermometer(coordinator, serial_number) for serial_number, values in coordinator.data["climate"].items() - if "temperature" in values + if "temperatureValue" in values ] sensors.extend( VerisureHygrometer(coordinator, serial_number) for serial_number, values in coordinator.data["climate"].items() - if "humidity" in values - ) - - sensors.extend( - VerisureMouseDetection(coordinator, serial_number) - for serial_number in coordinator.data["mice"] + if values.get("humidityEnabled") ) async_add_entities(sensors) @@ -52,7 +47,7 @@ class VerisureThermometer( _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_has_entity_name = True - _attr_name = "Temperature" + _attr_translation_key = "temperature" _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS _attr_state_class = SensorStateClass.MEASUREMENT @@ -67,10 +62,10 @@ class VerisureThermometer( @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - device_type = self.coordinator.data["climate"][self.serial_number].get( - "deviceType" - ) - area = self.coordinator.data["climate"][self.serial_number]["deviceArea"] + device_type = self.coordinator.data["climate"][self.serial_number]["device"][ + "gui" + ]["label"] + area = self.coordinator.data["climate"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -84,7 +79,7 @@ class VerisureThermometer( @property def native_value(self) -> str | None: """Return the state of the entity.""" - return self.coordinator.data["climate"][self.serial_number]["temperature"] + return self.coordinator.data["climate"][self.serial_number]["temperatureValue"] @property def available(self) -> bool: @@ -92,7 +87,8 @@ class VerisureThermometer( return ( super().available and self.serial_number in self.coordinator.data["climate"] - and "temperature" in self.coordinator.data["climate"][self.serial_number] + and "temperatureValue" + in self.coordinator.data["climate"][self.serial_number] ) @@ -103,7 +99,7 @@ class VerisureHygrometer( _attr_device_class = SensorDeviceClass.HUMIDITY _attr_has_entity_name = True - _attr_name = "Humidity" + _attr_translation_key = "humidity" _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = SensorStateClass.MEASUREMENT @@ -118,10 +114,10 @@ class VerisureHygrometer( @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - device_type = self.coordinator.data["climate"][self.serial_number].get( - "deviceType" - ) - area = self.coordinator.data["climate"][self.serial_number]["deviceArea"] + device_type = self.coordinator.data["climate"][self.serial_number]["device"][ + "gui" + ]["label"] + area = self.coordinator.data["climate"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -135,7 +131,7 @@ class VerisureHygrometer( @property def native_value(self) -> str | None: """Return the state of the entity.""" - return self.coordinator.data["climate"][self.serial_number]["humidity"] + return self.coordinator.data["climate"][self.serial_number]["humidityValue"] @property def available(self) -> bool: @@ -143,51 +139,5 @@ class VerisureHygrometer( return ( super().available and self.serial_number in self.coordinator.data["climate"] - and "humidity" in self.coordinator.data["climate"][self.serial_number] - ) - - -class VerisureMouseDetection( - CoordinatorEntity[VerisureDataUpdateCoordinator], SensorEntity -): - """Representation of a Verisure mouse detector.""" - - _attr_name = "Mouse" - _attr_has_entity_name = True - _attr_native_unit_of_measurement = "Mice" - - def __init__( - self, coordinator: VerisureDataUpdateCoordinator, serial_number: str - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self._attr_unique_id = f"{serial_number}_mice" - self.serial_number = serial_number - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this entity.""" - area = self.coordinator.data["mice"][self.serial_number]["area"] - return DeviceInfo( - name=area, - suggested_area=area, - manufacturer="Verisure", - model="Mouse detector", - identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), - configuration_url="https://mypages.verisure.com", - ) - - @property - def native_value(self) -> str | None: - """Return the state of the entity.""" - return self.coordinator.data["mice"][self.serial_number]["detections"] - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return ( - super().available - and self.serial_number in self.coordinator.data["mice"] - and "detections" in self.coordinator.data["mice"][self.serial_number] + and "humidityValue" in self.coordinator.data["climate"][self.serial_number] ) diff --git a/homeassistant/components/verisure/strings.json b/homeassistant/components/verisure/strings.json index c8326d7375..17feb4a7fe 100644 --- a/homeassistant/components/verisure/strings.json +++ b/homeassistant/components/verisure/strings.json @@ -56,5 +56,20 @@ "error": { "code_format_mismatch": "The default PIN code does not match the required number of digits" } + }, + "entity": { + "binary_sensor": { + "ethernet": { + "name": "Ethernet status" + } + }, + "sensor": { + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + } + } } } diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index ffb6e434fe..62e9bdf6cf 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -47,7 +47,9 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - area = self.coordinator.data["smart_plugs"][self.serial_number]["area"] + area = self.coordinator.data["smart_plugs"][self.serial_number]["device"][ + "area" + ] return DeviceInfo( name=area, suggested_area=area, @@ -77,16 +79,23 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch and self.serial_number in self.coordinator.data["smart_plugs"] ) - def turn_on(self, **kwargs: Any) -> None: - """Set smartplug status on.""" - self.coordinator.verisure.set_smartplug_state(self.serial_number, True) - self._state = True - self._change_timestamp = monotonic() - self.schedule_update_ha_state() + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the smartplug on.""" + await self.async_set_plug_state(True) - def turn_off(self, **kwargs: Any) -> None: - """Set smartplug status off.""" - self.coordinator.verisure.set_smartplug_state(self.serial_number, False) - self._state = False + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the smartplug off.""" + await self.async_set_plug_state(False) + + async def async_set_plug_state(self, state: bool) -> None: + """Set smartplug state.""" + command: dict[ + str, str | dict[str, str] + ] = self.coordinator.verisure.set_smartplug(self.serial_number, state) + await self.hass.async_add_executor_job( + self.coordinator.verisure.request, + command, + ) + self._state = state self._change_timestamp = monotonic() - self.schedule_update_ha_state() + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index f031e7a131..418172975d 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -1,7 +1,7 @@ { "domain": "vicare", "name": "Viessmann ViCare", - "codeowners": ["@oischinger"], + "codeowners": [], "config_flow": true, "dhcp": [ { @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.21.0"] + "requirements": ["PyViCare==2.25.0"] } diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 7be10b80b0..665e03b531 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "VIZIO SmartCast Device", - "description": "An [%key:common::config_flow::data::access_token%] is only needed for TVs. If you are configuring a TV and do not have an [%key:common::config_flow::data::access_token%] yet, leave it blank to go through a pairing process.", + "description": "An access token is only needed for TVs. If you are configuring a TV and do not have an access token yet, leave it blank to go through a pairing process.", "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", @@ -20,17 +20,17 @@ }, "pairing_complete": { "title": "Pairing Complete", - "description": "Your [%key:component::vizio::config::step::user::title%] is now connected to Home Assistant." + "description": "Your VIZIO SmartCast Device is now connected to Home Assistant." }, "pairing_complete_import": { "title": "Pairing Complete", - "description": "Your [%key:component::vizio::config::step::user::title%] is now connected to Home Assistant.\n\nYour [%key:common::config_flow::data::access_token%] is '**{access_token}**'." + "description": "Your VIZIO SmartCast Device is now connected to Home Assistant.\n\nYour access token is '**{access_token}**'." } }, "error": { "complete_pairing_failed": "Unable to complete pairing. Ensure the PIN you provided is correct and the TV is still powered and connected to the network before resubmitting.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "existing_config_entry_found": "An existing [%key:component::vizio::config::step::user::title%] config entry with the same serial number has already been configured. You must delete the existing entry in order to configure this one." + "existing_config_entry_found": "An existing VIZIO SmartCast Device config entry with the same serial number has already been configured. You must delete the existing entry in order to configure this one." }, "abort": { "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", @@ -41,7 +41,7 @@ "options": { "step": { "init": { - "title": "Update [%key:component::vizio::config::step::user::title%] Options", + "title": "Update VIZIO SmartCast Device Options", "description": "If you have a Smart TV, you can optionally filter your source list by choosing which apps to include or exclude in your source list.", "data": { "volume_step": "Volume Step Size", diff --git a/homeassistant/components/voice_assistant/__init__.py b/homeassistant/components/voice_assistant/__init__.py deleted file mode 100644 index 2ae169a28e..0000000000 --- a/homeassistant/components/voice_assistant/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""The Voice Assistant integration.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType - -from .const import DOMAIN -from .websocket_api import async_register_websocket_api - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Voice Assistant integration.""" - hass.data[DOMAIN] = {} - async_register_websocket_api(hass) - - return True diff --git a/homeassistant/components/voice_assistant/const.py b/homeassistant/components/voice_assistant/const.py deleted file mode 100644 index 86572fb459..0000000000 --- a/homeassistant/components/voice_assistant/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the Voice Assistant integration.""" -DOMAIN = "voice_assistant" -DEFAULT_PIPELINE = "default" diff --git a/homeassistant/components/voice_assistant/manifest.json b/homeassistant/components/voice_assistant/manifest.json deleted file mode 100644 index 6d353660b3..0000000000 --- a/homeassistant/components/voice_assistant/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "voice_assistant", - "name": "Voice Assistant", - "codeowners": ["@balloob", "@synesthesiam"], - "dependencies": ["conversation"], - "documentation": "https://www.home-assistant.io/integrations/voice_assistant", - "iot_class": "local_push", - "quality_scale": "internal" -} diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py deleted file mode 100644 index 0b55d72455..0000000000 --- a/homeassistant/components/voice_assistant/pipeline.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Classes for voice assistant pipelines.""" -from __future__ import annotations - -from abc import ABC, abstractmethod -import asyncio -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import Any - -from homeassistant.backports.enum import StrEnum -from homeassistant.components import conversation -from homeassistant.components.media_source import async_resolve_media -from homeassistant.components.tts.media_source import ( - generate_media_source_id as tts_generate_media_source_id, -) -from homeassistant.core import Context, HomeAssistant -from homeassistant.util.dt import utcnow - -DEFAULT_TIMEOUT = 30 # seconds - - -class PipelineEventType(StrEnum): - """Event types emitted during a pipeline run.""" - - RUN_START = "run-start" - RUN_FINISH = "run-finish" - INTENT_START = "intent-start" - INTENT_FINISH = "intent-finish" - TTS_START = "tts-start" - TTS_FINISH = "tts-finish" - ERROR = "error" - - -@dataclass -class PipelineEvent: - """Events emitted during a pipeline run.""" - - type: PipelineEventType - data: dict[str, Any] | None = None - timestamp: str = field(default_factory=lambda: utcnow().isoformat()) - - def as_dict(self) -> dict[str, Any]: - """Return a dict representation of the event.""" - return { - "type": self.type, - "timestamp": self.timestamp, - "data": self.data or {}, - } - - -@dataclass -class Pipeline: - """A voice assistant pipeline.""" - - name: str - language: str | None - conversation_engine: str | None - tts_engine: str | None - - -@dataclass -class PipelineRun: - """Running context for a pipeline.""" - - hass: HomeAssistant - context: Context - pipeline: Pipeline - event_callback: Callable[[PipelineEvent], None] - language: str = None # type: ignore[assignment] - - def __post_init__(self): - """Set language for pipeline.""" - self.language = self.pipeline.language or self.hass.config.language - - def start(self): - """Emit run start event.""" - self.event_callback( - PipelineEvent( - PipelineEventType.RUN_START, - { - "pipeline": self.pipeline.name, - "language": self.language, - }, - ) - ) - - def finish(self): - """Emit run finish event.""" - self.event_callback( - PipelineEvent( - PipelineEventType.RUN_FINISH, - ) - ) - - async def recognize_intent( - self, intent_input: str, conversation_id: str | None - ) -> conversation.ConversationResult: - """Run intent recognition portion of pipeline.""" - self.event_callback( - PipelineEvent( - PipelineEventType.INTENT_START, - { - "engine": self.pipeline.conversation_engine or "default", - "intent_input": intent_input, - }, - ) - ) - - conversation_result = await conversation.async_converse( - hass=self.hass, - text=intent_input, - conversation_id=conversation_id, - context=self.context, - language=self.language, - agent_id=self.pipeline.conversation_engine, - ) - - self.event_callback( - PipelineEvent( - PipelineEventType.INTENT_FINISH, - {"intent_output": conversation_result.as_dict()}, - ) - ) - - return conversation_result - - async def text_to_speech(self, tts_input: str) -> str: - """Run text to speech portion of pipeline. Returns URL of TTS audio.""" - self.event_callback( - PipelineEvent( - PipelineEventType.TTS_START, - { - "engine": self.pipeline.tts_engine or "default", - "tts_input": tts_input, - }, - ) - ) - - tts_media = await async_resolve_media( - self.hass, - tts_generate_media_source_id( - self.hass, - tts_input, - engine=self.pipeline.tts_engine, - ), - ) - tts_url = tts_media.url - - self.event_callback( - PipelineEvent( - PipelineEventType.TTS_FINISH, - {"tts_output": tts_url}, - ) - ) - - return tts_url - - -@dataclass -class PipelineRequest(ABC): - """Request to for a pipeline run.""" - - async def execute( - self, run: PipelineRun, timeout: int | float | None = DEFAULT_TIMEOUT - ): - """Run pipeline with optional timeout.""" - await asyncio.wait_for( - self._execute(run), - timeout=timeout, - ) - - @abstractmethod - async def _execute(self, run: PipelineRun): - """Run pipeline with request info and context.""" - - -@dataclass -class TextPipelineRequest(PipelineRequest): - """Request to run the text portion only of a pipeline.""" - - intent_input: str - conversation_id: str | None = None - - async def _execute( - self, - run: PipelineRun, - ): - run.start() - await run.recognize_intent(self.intent_input, self.conversation_id) - run.finish() - - -@dataclass -class AudioPipelineRequest(PipelineRequest): - """Request to full pipeline from audio input (stt) to audio output (tts).""" - - intent_input: str # this will be changed to stt audio - conversation_id: str | None = None - - async def _execute(self, run: PipelineRun): - run.start() - - # stt will go here - - conversation_result = await run.recognize_intent( - self.intent_input, self.conversation_id - ) - - tts_input = conversation_result.response.speech.get("plain", {}).get( - "speech", "" - ) - - await run.text_to_speech(tts_input) - - run.finish() diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py deleted file mode 100644 index 54e87e292a..0000000000 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Voice Assistant Websocket API.""" -from typing import Any - -import voluptuous as vol - -from homeassistant.components import websocket_api -from homeassistant.core import HomeAssistant, callback - -from .const import DOMAIN -from .pipeline import DEFAULT_TIMEOUT, Pipeline, PipelineRun, TextPipelineRequest - - -@callback -def async_register_websocket_api(hass: HomeAssistant) -> None: - """Register the websocket API.""" - websocket_api.async_register_command(hass, websocket_run) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "voice_assistant/run", - vol.Optional("language"): str, - vol.Optional("pipeline"): str, - vol.Required("intent_input"): str, - vol.Optional("conversation_id"): vol.Any(str, None), - vol.Optional("timeout"): vol.Any(float, int), - } -) -@websocket_api.async_response -async def websocket_run( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Run a pipeline.""" - pipeline_id = msg.get("pipeline") - if pipeline_id is not None: - pipeline = hass.data[DOMAIN].get(pipeline_id) - if pipeline is None: - connection.send_error( - msg["id"], - "pipeline_not_found", - f"Pipeline not found: {pipeline_id}", - ) - return - - else: - # Construct a pipeline for the required/configured language - language = msg.get("language", hass.config.language) - pipeline = Pipeline( - name=language, - language=language, - conversation_engine=None, - tts_engine=None, - ) - - # Run pipeline with a timeout. - # Events are sent over the websocket connection. - timeout = msg.get("timeout", DEFAULT_TIMEOUT) - run_task = hass.async_create_task( - TextPipelineRequest( - intent_input=msg["intent_input"], - conversation_id=msg.get("conversation_id"), - ).execute( - PipelineRun( - hass, - connection.context(msg), - pipeline, - event_callback=lambda event: connection.send_event( - msg["id"], event.as_dict() - ), - ), - timeout=timeout, - ) - ) - - # Cancel pipeline if user unsubscribes - connection.subscriptions[msg["id"]] = run_task.cancel - - connection.send_result(msg["id"]) - - # Task contains a timeout - await run_task diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py new file mode 100644 index 0000000000..9ea202e3b5 --- /dev/null +++ b/homeassistant/components/voip/__init__.py @@ -0,0 +1,110 @@ +"""The Voice over IP integration.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from voip_utils import SIP_PORT + +from homeassistant.auth.const import GROUP_ID_USER +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN +from .devices import VoIPDevices +from .voip import HassVoipDatagramProtocol + +PLATFORMS = ( + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SWITCH, +) +_LOGGER = logging.getLogger(__name__) +_IP_WILDCARD = "0.0.0.0" + +__all__ = [ + "DOMAIN", + "async_setup_entry", + "async_unload_entry", + "async_remove_config_entry_device", +] + + +@dataclass +class DomainData: + """Domain data.""" + + transport: asyncio.DatagramTransport + devices: VoIPDevices + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up VoIP integration from a config entry.""" + # Make sure there is a valid user ID for VoIP in the config entry + if ( + "user" not in entry.data + or (await hass.auth.async_get_user(entry.data["user"])) is None + ): + voip_user = await hass.auth.async_create_system_user( + "Voice over IP", group_ids=[GROUP_ID_USER] + ) + hass.config_entries.async_update_entry( + entry, data={**entry.data, "user": voip_user.id} + ) + + devices = VoIPDevices(hass, entry) + devices.async_setup() + transport = await _create_sip_server( + hass, + lambda: HassVoipDatagramProtocol(hass, devices), + ) + _LOGGER.debug("Listening for VoIP calls on port %s", SIP_PORT) + + hass.data[DOMAIN] = DomainData(transport, devices) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def _create_sip_server( + hass: HomeAssistant, + protocol_factory: Callable[ + [], + asyncio.DatagramProtocol, + ], +) -> asyncio.DatagramTransport: + transport, _protocol = await hass.loop.create_datagram_endpoint( + protocol_factory, + local_addr=(_IP_WILDCARD, SIP_PORT), + ) + + return transport + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload VoIP.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + _LOGGER.debug("Shut down VoIP server") + hass.data.pop(DOMAIN).transport.close() + + return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove device from a config entry.""" + return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove VoIP entry.""" + if "user" in entry.data and ( + user := await hass.auth.async_get_user(entry.data["user"]) + ): + await hass.auth.async_remove_user(user) diff --git a/homeassistant/components/voip/binary_sensor.py b/homeassistant/components/voip/binary_sensor.py new file mode 100644 index 0000000000..70ecb87098 --- /dev/null +++ b/homeassistant/components/voip/binary_sensor.py @@ -0,0 +1,60 @@ +"""Binary sensor for VoIP.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .devices import VoIPDevice +from .entity import VoIPEntity + +if TYPE_CHECKING: + from . import DomainData + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP binary sensor entities.""" + domain_data: DomainData = hass.data[DOMAIN] + + @callback + def async_add_device(device: VoIPDevice) -> None: + """Add device.""" + async_add_entities([VoIPCallActive(device)]) + + domain_data.devices.async_add_new_device_listener(async_add_device) + + async_add_entities([VoIPCallActive(device) for device in domain_data.devices]) + + +class VoIPCallActive(VoIPEntity, BinarySensorEntity): + """Entity to represent voip is allowed.""" + + entity_description = BinarySensorEntityDescription( + key="call_active", + translation_key="call_active", + ) + _attr_is_on = False + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + self.async_on_remove(self._device.async_listen_update(self._is_active_changed)) + + @callback + def _is_active_changed(self, device: VoIPDevice) -> None: + """Call when active state changed.""" + self._attr_is_on = self._device.is_active + self.async_write_ha_state() diff --git a/homeassistant/components/voip/config_flow.py b/homeassistant/components/voip/config_flow.py new file mode 100644 index 0000000000..2c9649d911 --- /dev/null +++ b/homeassistant/components/voip/config_flow.py @@ -0,0 +1,30 @@ +"""Config flow for VoIP integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for VoIP integration.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is None: + return self.async_show_form(step_id="user") + + return self.async_create_entry( + title="Voice over IP", + data=user_input, + ) diff --git a/homeassistant/components/voip/const.py b/homeassistant/components/voip/const.py new file mode 100644 index 0000000000..8288297d8e --- /dev/null +++ b/homeassistant/components/voip/const.py @@ -0,0 +1,13 @@ +"""Constants for the Voice over IP integration.""" + +DOMAIN = "voip" + +RATE = 16000 +WIDTH = 2 +CHANNELS = 1 +RTP_AUDIO_SETTINGS = { + "rate": RATE, + "width": WIDTH, + "channels": CHANNELS, + "sleep_ratio": 0.99, +} diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py new file mode 100644 index 0000000000..8b691e855e --- /dev/null +++ b/homeassistant/components/voip/devices.py @@ -0,0 +1,154 @@ +"""Class to manage devices.""" +from __future__ import annotations + +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field + +from voip_utils import CallInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import DOMAIN + + +@dataclass +class VoIPDevice: + """Class to store device.""" + + voip_id: str + device_id: str + is_active: bool = False + update_listeners: list[Callable[[VoIPDevice], None]] = field(default_factory=list) + + @callback + def set_is_active(self, active: bool) -> None: + """Set active state.""" + self.is_active = active + for listener in self.update_listeners: + listener(self) + + @callback + def async_listen_update( + self, listener: Callable[[VoIPDevice], None] + ) -> Callable[[], None]: + """Listen for updates.""" + self.update_listeners.append(listener) + return lambda: self.update_listeners.remove(listener) + + @callback + def async_allow_call(self, hass: HomeAssistant) -> bool: + """Return if call is allowed.""" + ent_reg = er.async_get(hass) + + allowed_call_entity_id = ent_reg.async_get_entity_id( + "switch", DOMAIN, f"{self.voip_id}-allow_call" + ) + # If 2 requests come in fast, the device registry entry has been created + # but entity might not exist yet. + if allowed_call_entity_id is None: + return False + + if state := hass.states.get(allowed_call_entity_id): + return state.state == "on" + + return False + + +class VoIPDevices: + """Class to store devices.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize VoIP devices.""" + self.hass = hass + self.config_entry = config_entry + self._new_device_listeners: list[Callable[[VoIPDevice], None]] = [] + self.devices: dict[str, VoIPDevice] = {} + + @callback + def async_setup(self) -> None: + """Set up devices.""" + for device in dr.async_entries_for_config_entry( + dr.async_get(self.hass), self.config_entry.entry_id + ): + voip_id = next( + (item[1] for item in device.identifiers if item[0] == DOMAIN), None + ) + if voip_id is None: + continue + self.devices[voip_id] = VoIPDevice( + voip_id=voip_id, + device_id=device.id, + ) + + @callback + def async_device_removed(ev: Event) -> None: + """Handle device removed.""" + removed_id = ev.data["device_id"] + self.devices = { + voip_id: voip_device + for voip_id, voip_device in self.devices.items() + if voip_device.device_id != removed_id + } + + self.config_entry.async_on_unload( + self.hass.bus.async_listen( + dr.EVENT_DEVICE_REGISTRY_UPDATED, + async_device_removed, + callback(lambda ev: ev.data.get("action") == "remove"), + ) + ) + + @callback + def async_add_new_device_listener( + self, listener: Callable[[VoIPDevice], None] + ) -> None: + """Add a new device listener.""" + self._new_device_listeners.append(listener) + + @callback + def async_get_or_create(self, call_info: CallInfo) -> VoIPDevice: + """Get or create a device.""" + user_agent = call_info.headers.get("user-agent", "") + user_agent_parts = user_agent.split() + if len(user_agent_parts) == 3 and user_agent_parts[0] == "Grandstream": + manuf = user_agent_parts[0] + model = user_agent_parts[1] + fw_version = user_agent_parts[2] + else: + manuf = None + model = user_agent if user_agent else None + fw_version = None + + dev_reg = dr.async_get(self.hass) + voip_id = call_info.caller_ip + voip_device = self.devices.get(voip_id) + + if voip_device is not None: + device = dev_reg.async_get(voip_device.device_id) + if device and fw_version and device.sw_version != fw_version: + dev_reg.async_update_device(device.id, sw_version=fw_version) + + return voip_device + + device = dev_reg.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, voip_id)}, + name=voip_id, + manufacturer=manuf, + model=model, + sw_version=fw_version, + ) + voip_device = self.devices[voip_id] = VoIPDevice( + voip_id=voip_id, + device_id=device.id, + ) + for listener in self._new_device_listeners: + listener(voip_device) + + return voip_device + + def __iter__(self) -> Iterator[VoIPDevice]: + """Iterate over devices.""" + return iter(self.devices.values()) diff --git a/homeassistant/components/voip/entity.py b/homeassistant/components/voip/entity.py new file mode 100644 index 0000000000..9b3cc641a6 --- /dev/null +++ b/homeassistant/components/voip/entity.py @@ -0,0 +1,23 @@ +"""VoIP entities.""" + +from __future__ import annotations + +from homeassistant.helpers import entity + +from .const import DOMAIN +from .devices import VoIPDevice + + +class VoIPEntity(entity.Entity): + """VoIP entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, device: VoIPDevice) -> None: + """Initialize VoIP entity.""" + self._device = device + self._attr_unique_id = f"{device.voip_id}-{self.entity_description.key}" + self._attr_device_info = entity.DeviceInfo( + identifiers={(DOMAIN, device.voip_id)}, + ) diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json new file mode 100644 index 0000000000..b9439ee682 --- /dev/null +++ b/homeassistant/components/voip/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "voip", + "name": "Voice over IP", + "codeowners": ["@balloob", "@synesthesiam"], + "config_flow": true, + "dependencies": ["assist_pipeline"], + "documentation": "https://www.home-assistant.io/integrations/voip", + "iot_class": "local_push", + "quality_scale": "internal", + "requirements": ["voip-utils==0.0.5"] +} diff --git a/homeassistant/components/voip/not_configured.pcm b/homeassistant/components/voip/not_configured.pcm new file mode 100644 index 0000000000..22b43e6019 Binary files /dev/null and b/homeassistant/components/voip/not_configured.pcm differ diff --git a/homeassistant/components/voip/problem.pcm b/homeassistant/components/voip/problem.pcm new file mode 100644 index 0000000000..887376687f Binary files /dev/null and b/homeassistant/components/voip/problem.pcm differ diff --git a/homeassistant/components/voip/processing.pcm b/homeassistant/components/voip/processing.pcm new file mode 100644 index 0000000000..c76c8787e5 Binary files /dev/null and b/homeassistant/components/voip/processing.pcm differ diff --git a/homeassistant/components/voip/select.py b/homeassistant/components/voip/select.py new file mode 100644 index 0000000000..7383e1b886 --- /dev/null +++ b/homeassistant/components/voip/select.py @@ -0,0 +1,46 @@ +"""Select entities for VoIP integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .devices import VoIPDevice +from .entity import VoIPEntity + +if TYPE_CHECKING: + from . import DomainData + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + domain_data: DomainData = hass.data[DOMAIN] + + @callback + def async_add_device(device: VoIPDevice) -> None: + """Add device.""" + async_add_entities([VoipPipelineSelect(hass, device)]) + + domain_data.devices.async_add_new_device_listener(async_add_device) + + async_add_entities( + [VoipPipelineSelect(hass, device) for device in domain_data.devices] + ) + + +class VoipPipelineSelect(VoIPEntity, AssistPipelineSelect): + """Pipeline selector for VoIP devices.""" + + def __init__(self, hass: HomeAssistant, device: VoIPDevice) -> None: + """Initialize a pipeline selector.""" + VoIPEntity.__init__(self, device) + AssistPipelineSelect.__init__(self, hass, device.voip_id) diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json new file mode 100644 index 0000000000..6eb9d36df7 --- /dev/null +++ b/homeassistant/components/voip/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "description": "Receive Voice over IP calls to interact with Assist." + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "entity": { + "binary_sensor": { + "call_active": { + "name": "Call Active" + } + }, + "switch": { + "allow_call": { + "name": "Allow Calls" + } + }, + "select": { + "pipeline": { + "name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + } + } + } +} diff --git a/homeassistant/components/voip/switch.py b/homeassistant/components/voip/switch.py new file mode 100644 index 0000000000..f8484241fc --- /dev/null +++ b/homeassistant/components/voip/switch.py @@ -0,0 +1,66 @@ +"""VoIP switch entities.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import restore_state +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .devices import VoIPDevice +from .entity import VoIPEntity + +if TYPE_CHECKING: + from . import DomainData + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + domain_data: DomainData = hass.data[DOMAIN] + + @callback + def async_add_device(device: VoIPDevice) -> None: + """Add device.""" + async_add_entities([VoIPCallAllowedSwitch(device)]) + + domain_data.devices.async_add_new_device_listener(async_add_device) + + async_add_entities( + [VoIPCallAllowedSwitch(device) for device in domain_data.devices] + ) + + +class VoIPCallAllowedSwitch(VoIPEntity, restore_state.RestoreEntity, SwitchEntity): + """Entity to represent voip is allowed.""" + + entity_description = SwitchEntityDescription( + key="allow_call", + translation_key="allow_call", + entity_category=EntityCategory.CONFIG, + ) + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + self._attr_is_on = state is not None and state.state == STATE_ON + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on.""" + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off.""" + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/voip/tone.pcm b/homeassistant/components/voip/tone.pcm new file mode 100644 index 0000000000..175e072a27 Binary files /dev/null and b/homeassistant/components/voip/tone.pcm differ diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py new file mode 100644 index 0000000000..2eedfcdcf9 --- /dev/null +++ b/homeassistant/components/voip/voip.py @@ -0,0 +1,428 @@ +"""Voice over IP (VoIP) implementation.""" +from __future__ import annotations + +import asyncio +from collections import deque +from collections.abc import AsyncIterable, MutableSequence, Sequence +from functools import partial +import logging +from pathlib import Path +import time +from typing import TYPE_CHECKING + +import async_timeout +from voip_utils import CallInfo, RtpDatagramProtocol, SdpInfo, VoipDatagramProtocol + +from homeassistant.components import stt, tts +from homeassistant.components.assist_pipeline import ( + Pipeline, + PipelineEvent, + PipelineEventType, + async_get_pipeline, + async_pipeline_from_audio_stream, + select as pipeline_select, +) +from homeassistant.components.assist_pipeline.vad import VoiceCommandSegmenter +from homeassistant.const import __version__ +from homeassistant.core import Context, HomeAssistant +from homeassistant.util.ulid import ulid + +from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH + +if TYPE_CHECKING: + from .devices import VoIPDevice, VoIPDevices + +_LOGGER = logging.getLogger(__name__) + + +def make_protocol( + hass: HomeAssistant, devices: VoIPDevices, call_info: CallInfo +) -> VoipDatagramProtocol: + """Plays a pre-recorded message if pipeline is misconfigured.""" + voip_device = devices.async_get_or_create(call_info) + pipeline_id = pipeline_select.get_chosen_pipeline( + hass, + DOMAIN, + voip_device.voip_id, + ) + pipeline = async_get_pipeline(hass, pipeline_id) + if ( + (pipeline is None) + or (pipeline.stt_engine is None) + or (pipeline.tts_engine is None) + ): + # Play pre-recorded message instead of failing + return PreRecordMessageProtocol(hass, "problem.pcm") + + # Pipeline is properly configured + return PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(user_id=devices.config_entry.data["user"]), + ) + + +class HassVoipDatagramProtocol(VoipDatagramProtocol): + """HA UDP server for Voice over IP (VoIP).""" + + def __init__(self, hass: HomeAssistant, devices: VoIPDevices) -> None: + """Set up VoIP call handler.""" + super().__init__( + sdp_info=SdpInfo( + username="homeassistant", + id=time.monotonic_ns(), + session_name="voip_hass", + version=__version__, + ), + valid_protocol_factory=lambda call_info: make_protocol( + hass, devices, call_info + ), + invalid_protocol_factory=lambda call_info: PreRecordMessageProtocol( + hass, "not_configured.pcm" + ), + ) + self.hass = hass + self.devices = devices + + def is_valid_call(self, call_info: CallInfo) -> bool: + """Filter calls.""" + device = self.devices.async_get_or_create(call_info) + return device.async_allow_call(self.hass) + + +class PipelineRtpDatagramProtocol(RtpDatagramProtocol): + """Run a voice assistant pipeline in a loop for a VoIP call.""" + + def __init__( + self, + hass: HomeAssistant, + language: str, + voip_device: VoIPDevice, + context: Context, + pipeline_timeout: float = 30.0, + audio_timeout: float = 2.0, + buffered_chunks_before_speech: int = 100, + listening_tone_enabled: bool = True, + processing_tone_enabled: bool = True, + tone_delay: float = 0.2, + tts_extra_timeout: float = 1.0, + ) -> None: + """Set up pipeline RTP server.""" + super().__init__(rate=RATE, width=WIDTH, channels=CHANNELS) + + self.hass = hass + self.language = language + self.voip_device = voip_device + self.pipeline: Pipeline | None = None + self.pipeline_timeout = pipeline_timeout + self.audio_timeout = audio_timeout + self.buffered_chunks_before_speech = buffered_chunks_before_speech + self.listening_tone_enabled = listening_tone_enabled + self.processing_tone_enabled = processing_tone_enabled + self.tone_delay = tone_delay + self.tts_extra_timeout = tts_extra_timeout + + self._audio_queue: asyncio.Queue[bytes] = asyncio.Queue() + self._context = context + self._conversation_id: str | None = None + self._pipeline_task: asyncio.Task | None = None + self._tts_done = asyncio.Event() + self._session_id: str | None = None + self._tone_bytes: bytes | None = None + self._processing_bytes: bytes | None = None + + def connection_made(self, transport): + """Server is ready.""" + super().connection_made(transport) + self.voip_device.set_is_active(True) + + def connection_lost(self, exc): + """Handle connection is lost or closed.""" + super().connection_lost(exc) + self.voip_device.set_is_active(False) + + def on_chunk(self, audio_bytes: bytes) -> None: + """Handle raw audio chunk.""" + if self._pipeline_task is None: + self._clear_audio_queue() + + # Run pipeline until voice command finishes, then start over + self._pipeline_task = self.hass.async_create_background_task( + self._run_pipeline(), + "voip_pipeline_run", + ) + + self._audio_queue.put_nowait(audio_bytes) + + async def _run_pipeline( + self, + ) -> None: + """Forward audio to pipeline STT and handle TTS.""" + if self._session_id is None: + self._session_id = ulid() + if self.listening_tone_enabled: + await self._play_listening_tone() + + try: + # Wait for speech before starting pipeline + segmenter = VoiceCommandSegmenter() + chunk_buffer: deque[bytes] = deque( + maxlen=self.buffered_chunks_before_speech, + ) + speech_detected = await self._wait_for_speech( + segmenter, + chunk_buffer, + ) + if not speech_detected: + _LOGGER.debug("No speech detected") + return + + _LOGGER.debug("Starting pipeline") + self._tts_done.clear() + + async def stt_stream(): + try: + async for chunk in self._segment_audio( + segmenter, + chunk_buffer, + ): + yield chunk + + if self.processing_tone_enabled: + await self._play_processing_tone() + except asyncio.TimeoutError: + # Expected after caller hangs up + _LOGGER.debug("Audio timeout") + self._session_id = None + self.disconnect() + finally: + self._clear_audio_queue() + + # Run pipeline with a timeout + async with async_timeout.timeout(self.pipeline_timeout): + await async_pipeline_from_audio_stream( + self.hass, + context=self._context, + event_callback=self._event_callback, + stt_metadata=stt.SpeechMetadata( + language="", # set in async_pipeline_from_audio_stream + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=stt_stream(), + pipeline_id=pipeline_select.get_chosen_pipeline( + self.hass, DOMAIN, self.voip_device.voip_id + ), + conversation_id=self._conversation_id, + tts_audio_output="raw", + ) + + # Block until TTS is done speaking. + # + # This is set in _send_tts and has a timeout that's based on the + # length of the TTS audio. + await self._tts_done.wait() + + _LOGGER.debug("Pipeline finished") + except asyncio.TimeoutError: + # Expected after caller hangs up + _LOGGER.debug("Pipeline timeout") + self._session_id = None + self.disconnect() + finally: + # Allow pipeline to run again + self._pipeline_task = None + + async def _wait_for_speech( + self, + segmenter: VoiceCommandSegmenter, + chunk_buffer: MutableSequence[bytes], + ): + """Buffer audio chunks until speech is detected. + + Returns True if speech was detected, False otherwise. + """ + # Timeout if no audio comes in for a while. + # This means the caller hung up. + async with async_timeout.timeout(self.audio_timeout): + chunk = await self._audio_queue.get() + + while chunk: + segmenter.process(chunk) + if segmenter.in_command: + return True + + # Buffer until command starts + chunk_buffer.append(chunk) + + async with async_timeout.timeout(self.audio_timeout): + chunk = await self._audio_queue.get() + + return False + + async def _segment_audio( + self, + segmenter: VoiceCommandSegmenter, + chunk_buffer: Sequence[bytes], + ) -> AsyncIterable[bytes]: + """Yield audio chunks until voice command has finished.""" + # Buffered chunks first + for buffered_chunk in chunk_buffer: + yield buffered_chunk + + # Timeout if no audio comes in for a while. + # This means the caller hung up. + async with async_timeout.timeout(self.audio_timeout): + chunk = await self._audio_queue.get() + + while chunk: + if not segmenter.process(chunk): + # Voice command is finished + break + + yield chunk + + async with async_timeout.timeout(self.audio_timeout): + chunk = await self._audio_queue.get() + + def _clear_audio_queue(self) -> None: + while not self._audio_queue.empty(): + self._audio_queue.get_nowait() + + def _event_callback(self, event: PipelineEvent): + if not event.data: + return + + if event.type == PipelineEventType.INTENT_END: + # Capture conversation id + self._conversation_id = event.data["intent_output"]["conversation_id"] + elif event.type == PipelineEventType.TTS_END: + # Send TTS audio to caller over RTP + media_id = event.data["tts_output"]["media_id"] + self.hass.async_create_background_task( + self._send_tts(media_id), + "voip_pipeline_tts", + ) + + async def _send_tts(self, media_id: str) -> None: + """Send TTS audio to caller via RTP.""" + try: + if self.transport is None: + return + + _extension, audio_bytes = await tts.async_get_media_source_audio( + self.hass, + media_id, + ) + + _LOGGER.debug("Sending %s byte(s) of audio", len(audio_bytes)) + + # Time out 1 second after TTS audio should be finished + tts_samples = len(audio_bytes) / (WIDTH * CHANNELS) + tts_seconds = tts_samples / RATE + + async with async_timeout.timeout(tts_seconds + self.tts_extra_timeout): + # Assume TTS audio is 16Khz 16-bit mono + await self.hass.async_add_executor_job( + partial(self.send_audio, audio_bytes, **RTP_AUDIO_SETTINGS) + ) + except asyncio.TimeoutError as err: + _LOGGER.warning("TTS timeout") + raise err + finally: + # Signal pipeline to restart + self._tts_done.set() + + async def _play_listening_tone(self) -> None: + """Play a tone to indicate that Home Assistant is listening.""" + if self._tone_bytes is None: + # Do I/O in executor + self._tone_bytes = await self.hass.async_add_executor_job( + self._load_pcm, + "tone.pcm", + ) + + await self.hass.async_add_executor_job( + partial( + self.send_audio, + self._tone_bytes, + silence_before=self.tone_delay, + **RTP_AUDIO_SETTINGS, + ) + ) + + async def _play_processing_tone(self) -> None: + """Play a tone to indicate that Home Assistant is processing the voice command.""" + if self._processing_bytes is None: + # Do I/O in executor + self._processing_bytes = await self.hass.async_add_executor_job( + self._load_pcm, + "processing.pcm", + ) + + await self.hass.async_add_executor_job( + partial( + self.send_audio, + self._processing_bytes, + **RTP_AUDIO_SETTINGS, + ) + ) + + def _load_pcm(self, file_name: str) -> bytes: + """Load raw audio (16Khz, 16-bit mono).""" + return (Path(__file__).parent / file_name).read_bytes() + + +class PreRecordMessageProtocol(RtpDatagramProtocol): + """Plays a pre-recorded message on a loop.""" + + def __init__( + self, + hass: HomeAssistant, + file_name: str, + message_delay: float = 1.0, + loop_delay: float = 2.0, + ) -> None: + """Set up RTP server.""" + super().__init__(rate=RATE, width=WIDTH, channels=CHANNELS) + self.hass = hass + self.file_name = file_name + self.message_delay = message_delay + self.loop_delay = loop_delay + self._audio_task: asyncio.Task | None = None + self._audio_bytes: bytes | None = None + + def on_chunk(self, audio_bytes: bytes) -> None: + """Handle raw audio chunk.""" + if self.transport is None: + return + + if self._audio_bytes is None: + # 16Khz, 16-bit mono audio message + file_path = Path(__file__).parent / self.file_name + self._audio_bytes = file_path.read_bytes() + + if self._audio_task is None: + self._audio_task = self.hass.async_create_background_task( + self._play_message(), + "voip_not_connected", + ) + + async def _play_message(self) -> None: + await self.hass.async_add_executor_job( + partial( + self.send_audio, + self._audio_bytes, + silence_before=self.message_delay, + **RTP_AUDIO_SETTINGS, + ) + ) + + await asyncio.sleep(self.loop_delay) + + # Allow message to play again + self._audio_task = None diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index b6d97dea21..ab4fa78111 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -4,28 +4,21 @@ import logging from aiohttp.client_exceptions import ClientResponseError import async_timeout -import voluptuous as vol from volvooncall import Connection from volvooncall.dashboard import Instrument -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_NAME, CONF_PASSWORD, CONF_REGION, - CONF_RESOURCES, - CONF_SCAN_INTERVAL, CONF_UNIT_SYSTEM, CONF_USERNAME, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send 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, @@ -35,11 +28,9 @@ from homeassistant.helpers.update_coordinator import ( from .const import ( CONF_MUTABLE, CONF_SCANDINAVIAN_MILES, - CONF_SERVICE_URL, DEFAULT_UPDATE_INTERVAL, DOMAIN, PLATFORMS, - RESOURCES, UNIT_SYSTEM_IMPERIAL, UNIT_SYSTEM_METRIC, UNIT_SYSTEM_SCANDINAVIAN_MILES, @@ -49,68 +40,6 @@ from .errors import InvalidAuth _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_UPDATE_INTERVAL - ): vol.All(cv.time_period, vol.Clamp(min=DEFAULT_UPDATE_INTERVAL)), - vol.Optional(CONF_NAME, default={}): cv.schema_with_slug_keys( - cv.string - ), - vol.Optional(CONF_RESOURCES): vol.All( - cv.ensure_list, [vol.In(RESOURCES)] - ), - vol.Optional(CONF_REGION): cv.string, - vol.Optional(CONF_SERVICE_URL): cv.string, - vol.Optional(CONF_MUTABLE, default=True): cv.boolean, - vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Migrate from YAML to ConfigEntry.""" - if DOMAIN not in config: - return True - - hass.data[DOMAIN] = {} - - if not hass.config_entries.async_entries(DOMAIN): - new_conf = {} - new_conf[CONF_USERNAME] = config[DOMAIN][CONF_USERNAME] - new_conf[CONF_PASSWORD] = config[DOMAIN][CONF_PASSWORD] - new_conf[CONF_REGION] = config[DOMAIN].get(CONF_REGION) - new_conf[CONF_SCANDINAVIAN_MILES] = config[DOMAIN][CONF_SCANDINAVIAN_MILES] - new_conf[CONF_MUTABLE] = config[DOMAIN][CONF_MUTABLE] - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=new_conf - ) - ) - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version=None, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Volvo On Call component from a ConfigEntry.""" diff --git a/homeassistant/components/volvooncall/config_flow.py b/homeassistant/components/volvooncall/config_flow.py index c1b3ab3f66..d56d10ded5 100644 --- a/homeassistant/components/volvooncall/config_flow.py +++ b/homeassistant/components/volvooncall/config_flow.py @@ -106,10 +106,6 @@ class VolvoOnCallConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=user_schema, errors=errors ) - async def async_step_import(self, import_data) -> FlowResult: - """Import volvooncall config from configuration.yaml.""" - return await self.async_step_user(import_data) - async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_entry = self.hass.config_entries.async_get_entry( diff --git a/homeassistant/components/volvooncall/strings.json b/homeassistant/components/volvooncall/strings.json index 9e8471b04b..44b821b4b0 100644 --- a/homeassistant/components/volvooncall/strings.json +++ b/homeassistant/components/volvooncall/strings.json @@ -19,11 +19,5 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The Volvo On Call YAML configuration is being removed", - "description": "Configuring the Volvo On Call platform using YAML is being removed in a future release of Home Assistant.\n\nYour existing configuration has been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index efd20e37e8..f5d643b133 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -21,9 +21,6 @@ CONF_TEXT_TYPE = "text" # List from https://tinyurl.com/watson-tts-docs SUPPORTED_VOICES = [ - "ar-AR_OmarVoice", - "ar-MS_OmarVoice", - "cs-CZ_AlenaVoice", "de-DE_BirgitV2Voice", "de-DE_BirgitV3Voice", "de-DE_BirgitVoice", @@ -31,11 +28,8 @@ SUPPORTED_VOICES = [ "de-DE_DieterV3Voice", "de-DE_DieterVoice", "de-DE_ErikaV3Voice", - "en-AU_CraigVoice", - "en-AU_MadisonVoice", - "en-AU_SteveVoice", - "en-GB_KateV3Voice", - "en-GB_KateVoice", + "en-AU_HeidiExpressive", + "en-AU_JackExpressive", "en-GB_CharlotteV3Voice", "en-GB_JamesV3Voice", "en-GB_KateV3Voice", @@ -74,33 +68,15 @@ SUPPORTED_VOICES = [ "it-IT_FrancescaVoice", "ja-JP_EmiV3Voice", "ja-JP_EmiVoice", - "ko-KR_HyunjunVoice", - "ko-KR_SiWooVoice", - "ko-KR_YoungmiVoice", - "ko-KR_YunaVoice", - "nl-BE_AdeleVoice", - "nl-BE_BramVoice", - "nl-NL_EmmaVoice", - "nl-NL_LiamVoice", + "ko-KR_JinV3Voice", + "nl-NL_MerelV3Voice", "pt-BR_IsabelaV3Voice", "pt-BR_IsabelaVoice", - "sv-SE_IngridVoice", - "zh-CN_LiNaVoice", - "zh-CN_WangWeiVoice", - "zh-CN_ZhangJingVoice", ] DEPRECATED_VOICES = [ - "ar-AR_OmarVoice", - "ar-MS_OmarVoice", - "cs-CZ_AlenaVoice", "de-DE_BirgitVoice", "de-DE_DieterVoice", - "en-AU_CraigVoice", - "en-AU_MadisonVoice", - "en-AU_SteveVoice", - "en-GB_KateVoice", - "en-GB_KateV3Voice", "en-US_AllisonVoice", "en-US_LisaVoice", "en-US_MichaelVoice", @@ -111,19 +87,7 @@ DEPRECATED_VOICES = [ "fr-FR_ReneeVoice", "it-IT_FrancescaVoice", "ja-JP_EmiVoice", - "ko-KR_HyunjunVoice", - "ko-KR_SiWooVoice", - "ko-KR_YoungmiVoice", - "ko-KR_YunaVoice", - "nl-BE_AdeleVoice", - "nl-BE_BramVoice", - "nl-NL_EmmaVoice", - "nl-NL_LiamVoice", "pt-BR_IsabelaVoice", - "sv-SE_IngridVoice", - "zh-CN_LiNaVoice", - "zh-CN_WangWeiVoice", - "zh-CN_ZhangJingVoice", ] SUPPORTED_OUTPUT_FORMATS = [ diff --git a/homeassistant/components/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index b26732e4cb..a743844659 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -7,7 +7,13 @@ from homeassistant import config_entries from homeassistant.const import CONF_NAME, CONF_REGION from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + BooleanSelector, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( @@ -31,6 +37,46 @@ from .const import ( ) from .helpers import is_valid_config_entry +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_INCL_FILTER, default=""): TextSelector(), + vol.Optional(CONF_EXCL_FILTER, default=""): TextSelector(), + vol.Optional(CONF_REALTIME): BooleanSelector(), + vol.Required(CONF_VEHICLE_TYPE): SelectSelector( + SelectSelectorConfig( + options=sorted(VEHICLE_TYPES), + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_VEHICLE_TYPE, + ) + ), + vol.Required(CONF_UNITS): SelectSelector( + SelectSelectorConfig( + options=sorted(UNITS), + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_UNITS, + ) + ), + vol.Optional(CONF_AVOID_TOLL_ROADS): BooleanSelector(), + vol.Optional(CONF_AVOID_SUBSCRIPTION_ROADS): BooleanSelector(), + vol.Optional(CONF_AVOID_FERRIES): BooleanSelector(), + } +) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Required(CONF_ORIGIN): TextSelector(), + vol.Required(CONF_DESTINATION): TextSelector(), + vol.Required(CONF_REGION): SelectSelector( + SelectSelectorConfig( + options=sorted(REGIONS), + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_REGION, + ) + ), + } +) + def default_options(hass: HomeAssistant) -> dict[str, str | bool]: """Get the default options.""" @@ -57,43 +103,8 @@ class WazeOptionsFlow(config_entries.OptionsFlow): return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_INCL_FILTER, - default=self.config_entry.options.get(CONF_INCL_FILTER, ""), - ): cv.string, - vol.Optional( - CONF_EXCL_FILTER, - default=self.config_entry.options.get(CONF_EXCL_FILTER, ""), - ): cv.string, - vol.Optional( - CONF_REALTIME, - default=self.config_entry.options[CONF_REALTIME], - ): cv.boolean, - vol.Optional( - CONF_VEHICLE_TYPE, - default=self.config_entry.options[CONF_VEHICLE_TYPE], - ): vol.In(VEHICLE_TYPES), - vol.Optional( - CONF_UNITS, - default=self.config_entry.options[CONF_UNITS], - ): vol.In(UNITS), - vol.Optional( - CONF_AVOID_TOLL_ROADS, - default=self.config_entry.options[CONF_AVOID_TOLL_ROADS], - ): cv.boolean, - vol.Optional( - CONF_AVOID_SUBSCRIPTION_ROADS, - default=self.config_entry.options[ - CONF_AVOID_SUBSCRIPTION_ROADS - ], - ): cv.boolean, - vol.Optional( - CONF_AVOID_FERRIES, - default=self.config_entry.options[CONF_AVOID_FERRIES], - ): cv.boolean, - } + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, self.config_entry.options ), ) @@ -117,6 +128,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): user_input = user_input or {} if user_input: + user_input[CONF_REGION] = user_input[CONF_REGION].upper() if await self.hass.async_add_executor_job( is_valid_config_entry, self.hass, @@ -132,18 +144,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # If we get here, it's because we couldn't connect errors["base"] = "cannot_connect" + user_input[CONF_REGION] = user_input[CONF_REGION].lower() return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required( - CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) - ): cv.string, - vol.Required(CONF_ORIGIN): cv.string, - vol.Required(CONF_DESTINATION): cv.string, - vol.Required(CONF_REGION): vol.In(REGIONS), - } - ), + data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input), errors=errors, ) diff --git a/homeassistant/components/waze_travel_time/const.py b/homeassistant/components/waze_travel_time/const.py index 1121519f8c..698ba5a63b 100644 --- a/homeassistant/components/waze_travel_time/const.py +++ b/homeassistant/components/waze_travel_time/const.py @@ -25,7 +25,7 @@ IMPERIAL_UNITS = "imperial" METRIC_UNITS = "metric" UNITS = [METRIC_UNITS, IMPERIAL_UNITS] -REGIONS = ["US", "NA", "EU", "IL", "AU"] +REGIONS = ["us", "na", "eu", "il", "au"] VEHICLE_TYPES = ["car", "taxi", "motorcycle"] DEFAULT_OPTIONS: dict[str, str | bool] = { diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index ecbf3e9e12..cf709805f6 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -60,8 +60,6 @@ async def async_setup_entry( name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) data = WazeTravelTimeData( - None, - None, region, config_entry, ) @@ -85,7 +83,14 @@ class WazeTravelTime(SensorEntity): configuration_url="https://www.waze.com", ) - def __init__(self, unique_id, name, origin, destination, waze_data): + def __init__( + self, + unique_id: str, + name: str, + origin: str, + destination: str, + waze_data: WazeTravelTimeData, + ) -> None: """Initialize the Waze travel time sensor.""" self._attr_unique_id = unique_id self._waze_data = waze_data @@ -126,7 +131,7 @@ class WazeTravelTime(SensorEntity): "destination": self._waze_data.destination, } - async def first_update(self, _=None): + async def first_update(self, _=None) -> None: """Run first update and write state.""" await self.hass.async_add_executor_job(self.update) self.async_write_ha_state() @@ -142,12 +147,12 @@ class WazeTravelTime(SensorEntity): class WazeTravelTimeData: """WazeTravelTime Data object.""" - def __init__(self, origin, destination, region, config_entry): + def __init__(self, region: str, config_entry: ConfigEntry) -> None: """Set up WazeRouteCalculator.""" - self.origin = origin - self.destination = destination self.region = region self.config_entry = config_entry + self.origin: str | None = None + self.destination: str | None = None self.duration = None self.distance = None self.route = None diff --git a/homeassistant/components/waze_travel_time/strings.json b/homeassistant/components/waze_travel_time/strings.json index 9ed2ba8dfe..61b93f13f1 100644 --- a/homeassistant/components/waze_travel_time/strings.json +++ b/homeassistant/components/waze_travel_time/strings.json @@ -35,5 +35,29 @@ } } } + }, + "selector": { + "vehicle_type": { + "options": { + "car": "Car", + "taxi": "Taxi", + "motorcycle": "Motorcycle" + } + }, + "units": { + "options": { + "metric": "Metric System", + "imperial": "Imperial System" + } + }, + "region": { + "options": { + "us": "USA", + "na": "North America", + "eu": "Europe", + "il": "Israel", + "au": "Australia" + } + } } } diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index bd80f38b83..e58890a1d1 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -1,7 +1,7 @@ """Webhooks for Home Assistant.""" from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterable from http import HTTPStatus from ipaddress import ip_address import logging @@ -9,6 +9,7 @@ import secrets from typing import TYPE_CHECKING, Any from aiohttp import StreamReader +from aiohttp.hdrs import METH_GET, METH_HEAD, METH_POST, METH_PUT from aiohttp.web import Request, Response import voluptuous as vol @@ -25,6 +26,8 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "webhook" +DEFAULT_METHODS = (METH_POST, METH_PUT) +SUPPORTED_METHODS = (METH_GET, METH_HEAD, METH_POST, METH_PUT) URL_WEBHOOK_PATH = "/api/webhook/{webhook_id}" @@ -37,7 +40,8 @@ def async_register( webhook_id: str, handler: Callable[[HomeAssistant, str, Request], Awaitable[Response | None]], *, - local_only=False, + local_only: bool | None = False, + allowed_methods: Iterable[str] | None = None, ) -> None: """Register a webhook.""" handlers = hass.data.setdefault(DOMAIN, {}) @@ -45,11 +49,21 @@ def async_register( if webhook_id in handlers: raise ValueError("Handler is already defined!") + if allowed_methods is None: + allowed_methods = DEFAULT_METHODS + allowed_methods = frozenset(allowed_methods) + + if not allowed_methods.issubset(SUPPORTED_METHODS): + raise ValueError( + f"Unexpected method: {allowed_methods.difference(SUPPORTED_METHODS)}" + ) + handlers[webhook_id] = { "domain": domain, "name": name, "handler": handler, "local_only": local_only, + "allowed_methods": allowed_methods, } @@ -90,16 +104,18 @@ async def async_handle_webhook( """Handle a webhook.""" handlers: dict[str, dict[str, Any]] = hass.data.setdefault(DOMAIN, {}) + content_stream: StreamReader | MockStreamReader + if isinstance(request, MockRequest): + received_from = request.mock_source + content_stream = request.content + method_name = request.method + else: + received_from = request.remote + content_stream = request.content + method_name = request.method + # Always respond successfully to not give away if a hook exists or not. if (webhook := handlers.get(webhook_id)) is None: - content_stream: StreamReader | MockStreamReader - if isinstance(request, MockRequest): - received_from = request.mock_source - content_stream = request.content - else: - received_from = request.remote - content_stream = request.content - _LOGGER.info( "Received message for unregistered webhook %s from %s", webhook_id, @@ -111,7 +127,21 @@ async def async_handle_webhook( _LOGGER.debug("%s", content) return Response(status=HTTPStatus.OK) - if webhook["local_only"]: + if method_name not in webhook["allowed_methods"]: + if method_name == METH_HEAD: + # Allow websites to verify that the URL exists. + return Response(status=HTTPStatus.OK) + + _LOGGER.warning( + "Webhook %s only supports %s methods but %s was received from %s", + webhook_id, + ",".join(webhook["allowed_methods"]), + method_name, + received_from, + ) + return Response(status=HTTPStatus.METHOD_NOT_ALLOWED) + + if webhook["local_only"] in (True, None) and not isinstance(request, MockRequest): if TYPE_CHECKING: assert isinstance(request, Request) assert request.remote is not None @@ -123,7 +153,17 @@ async def async_handle_webhook( if not network.is_local(remote): _LOGGER.warning("Received remote request for local webhook %s", webhook_id) - return Response(status=HTTPStatus.OK) + if webhook["local_only"]: + return Response(status=HTTPStatus.OK) + if not webhook.get("warned_about_deprecation"): + webhook["warned_about_deprecation"] = True + _LOGGER.warning( + "Deprecation warning: " + "Webhook '%s' does not provide a value for local_only. " + "This webhook will be blocked after the 2023.7.0 release. " + "Use `local_only: false` to keep this webhook operating as-is", + webhook_id, + ) try: response = await webhook["handler"](hass, webhook_id, request) @@ -157,9 +197,11 @@ class WebhookView(HomeAssistantView): hass = request.app["hass"] return await async_handle_webhook(hass, webhook_id, request) + get = _handle head = _handle post = _handle put = _handle + get = _handle @websocket_api.websocket_command( @@ -181,6 +223,7 @@ def websocket_list( "domain": info["domain"], "name": info["name"], "local_only": info["local_only"], + "allowed_methods": sorted(info["allowed_methods"]), } for webhook_id, info in handlers.items() ] @@ -192,7 +235,7 @@ def websocket_list( { vol.Required("type"): "webhook/handle", vol.Required("webhook_id"): str, - vol.Required("method"): vol.In(["GET", "POST", "PUT"]), + vol.Required("method"): vol.In(SUPPORTED_METHODS), vol.Optional("body", default=""): str, vol.Optional("headers", default={}): {str: str}, vol.Optional("query", default=""): str, diff --git a/homeassistant/components/webhook/strings.json b/homeassistant/components/webhook/strings.json new file mode 100644 index 0000000000..53b932727d --- /dev/null +++ b/homeassistant/components/webhook/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "trigger_missing_local_only": { + "title": "Update webhook trigger: {webhook_id}", + "description": "A choice needs to be made about whether the {webhook_id} webhook automation trigger is accessible from the internet. [Edit the automation]({edit}) \"{automation_name}\", (`{entity_id}`) and click the gear icon beside the Webhook ID to choose a value for 'Only accessible from the local network'" + } + } +} diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py index cb1a6cb4eb..8c6051cc4a 100644 --- a/homeassistant/components/webhook/trigger.py +++ b/homeassistant/components/webhook/trigger.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +import logging from aiohttp import hdrs import voluptuous as vol @@ -9,24 +10,46 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN, async_register, async_unregister +from . import ( + DEFAULT_METHODS, + DOMAIN, + SUPPORTED_METHODS, + async_register, + async_unregister, +) + +_LOGGER = logging.getLogger(__name__) DEPENDENCIES = ("webhook",) +CONF_ALLOWED_METHODS = "allowed_methods" +CONF_LOCAL_ONLY = "local_only" + TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "webhook", vol.Required(CONF_WEBHOOK_ID): cv.string, + vol.Optional(CONF_ALLOWED_METHODS): vol.All( + cv.ensure_list, + [vol.All(vol.Upper, vol.In(SUPPORTED_METHODS))], + vol.Unique(), + ), + vol.Optional(CONF_LOCAL_ONLY): bool, } ) WEBHOOK_TRIGGERS = f"{DOMAIN}_triggers" -@dataclass +@dataclass(slots=True) class TriggerInstance: """Attached trigger settings.""" @@ -62,6 +85,32 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Trigger based on incoming webhooks.""" webhook_id: str = config[CONF_WEBHOOK_ID] + local_only = config.get(CONF_LOCAL_ONLY) + issue_id: str | None = None + if local_only is None: + issue_id = f"trigger_missing_local_only_{webhook_id}" + variables = trigger_info["variables"] or {} + automation_info = variables.get("this", {}) + automation_id = automation_info.get("attributes", {}).get("id") + automation_entity_id = automation_info.get("entity_id") + automation_name = trigger_info.get("name") or automation_entity_id + async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version="2023.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + learn_more_url="https://www.home-assistant.io/docs/automation/trigger/#webhook-trigger", + translation_key="trigger_missing_local_only", + translation_placeholders={ + "webhook_id": webhook_id, + "automation_name": automation_name, + "entity_id": automation_entity_id, + "edit": f"/config/automation/edit/{automation_id}", + }, + ) + allowed_methods = config.get(CONF_ALLOWED_METHODS, DEFAULT_METHODS) job = HassJob(action) triggers: dict[str, list[TriggerInstance]] = hass.data.setdefault( @@ -75,6 +124,8 @@ async def async_attach_trigger( trigger_info["name"], webhook_id, _handle_webhook, + local_only=local_only, + allowed_methods=allowed_methods, ) triggers[webhook_id] = [] @@ -84,6 +135,8 @@ async def async_attach_trigger( @callback def unregister(): """Unregister webhook.""" + if issue_id: + async_delete_issue(hass, DOMAIN, issue_id) triggers[webhook_id].remove(trigger_instance) if not triggers[webhook_id]: async_unregister(hass, webhook_id) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index fa5c6aac29..2b146d9447 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -399,9 +399,9 @@ async def handle_manifest_get( connection.send_error(msg["id"], const.ERR_NOT_FOUND, "Integration not found") +@callback @decorators.websocket_command({vol.Required("type"): "integration/setup_info"}) -@decorators.async_response -async def handle_integration_setup_info( +def handle_integration_setup_info( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle integrations command.""" @@ -648,6 +648,7 @@ async def handle_execute_script( connection.send_result(msg["id"], {"context": context}) +@callback @decorators.websocket_command( { vol.Required("type"): "fire_event", @@ -656,8 +657,7 @@ async def handle_execute_script( } ) @decorators.require_admin -@decorators.async_response -async def handle_fire_event( +def handle_fire_event( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle fire event command.""" diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 08d0531452..f91cc3a827 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -25,6 +25,9 @@ current_connection = ContextVar["ActiveConnection | None"]( "current_connection", default=None ) +MessageHandler = Callable[[HomeAssistant, "ActiveConnection", dict[str, Any]], None] +BinaryHandler = Callable[[HomeAssistant, "ActiveConnection", bytes], None] + class ActiveConnection: """Handle an active websocket client connection.""" @@ -46,7 +49,10 @@ class ActiveConnection: self.subscriptions: dict[Hashable, Callable[[], Any]] = {} self.last_id = 0 self.supported_features: dict[str, float] = {} - self.handlers = self.hass.data[const.DOMAIN] + self.handlers: dict[str, tuple[MessageHandler, vol.Schema]] = self.hass.data[ + const.DOMAIN + ] + self.binary_handlers: list[BinaryHandler | None] = [] current_connection.set(self) def get_description(self, request: web.Request | None) -> str: @@ -60,6 +66,38 @@ class ActiveConnection: """Return a context.""" return Context(user_id=self.user.id) + @callback + def async_register_binary_handler( + self, handler: BinaryHandler + ) -> tuple[int, Callable[[], None]]: + """Register a temporary binary handler for this connection. + + Returns a binary handler_id (1 byte) and a callback to unregister the handler. + """ + if len(self.binary_handlers) < 255: + index = len(self.binary_handlers) + self.binary_handlers.append(None) + else: + # Once the list is full, we search for a None entry to reuse. + index = None + for idx, existing in enumerate(self.binary_handlers): + if existing is None: + index = idx + break + + if index is None: + raise RuntimeError("Too many binary handlers registered") + + self.binary_handlers[index] = handler + + @callback + def unsub() -> None: + """Unregister the handler.""" + assert index is not None + self.binary_handlers[index] = None + + return index + 1, unsub + @callback def send_result(self, msg_id: int, result: Any | None = None) -> None: """Send a result message.""" @@ -75,6 +113,26 @@ class ActiveConnection: """Send a error message.""" self.send_message(messages.error_message(msg_id, code, message)) + @callback + def async_handle_binary(self, handler_id: int, payload: bytes) -> None: + """Handle a single incoming binary message.""" + index = handler_id - 1 + if ( + index < 0 + or index >= len(self.binary_handlers) + or (handler := self.binary_handlers[index]) is None + ): + self.logger.error( + "Received binary message for non-existing handler %s", handler_id + ) + return + + try: + handler(self.hass, self, payload) + except Exception: # pylint: disable=broad-except + self.logger.exception("Error handling binary message") + self.binary_handlers[index] = None + @callback def async_handle(self, msg: dict[str, Any]) -> None: """Handle a single incoming message.""" diff --git a/homeassistant/components/websocket_api/decorators.py b/homeassistant/components/websocket_api/decorators.py index 9afffd9fb2..a148ed2be8 100644 --- a/homeassistant/components/websocket_api/decorators.py +++ b/homeassistant/components/websocket_api/decorators.py @@ -128,15 +128,31 @@ def ws_require_user( def websocket_command( - schema: dict[vol.Marker, Any], + schema: dict[vol.Marker, Any] | vol.All, ) -> Callable[[const.WebSocketCommandHandler], const.WebSocketCommandHandler]: - """Tag a function as a websocket command.""" - command = schema["type"] + """Tag a function as a websocket command. + + The schema must be either a dictionary where the keys are voluptuous markers, or + a voluptuous.All schema where the first item is a voluptuous Mapping schema. + """ + if isinstance(schema, dict): + command = schema["type"] + else: + command = schema.validators[0].schema["type"] def decorate(func: const.WebSocketCommandHandler) -> const.WebSocketCommandHandler: """Decorate ws command function.""" # pylint: disable=protected-access - func._ws_schema = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend(schema) # type: ignore[attr-defined] + if isinstance(schema, dict): + func._ws_schema = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend(schema) # type: ignore[attr-defined] + else: + extended_schema = vol.All( + schema.validators[0].extend( + messages.BASE_COMMAND_MESSAGE_SCHEMA.schema + ), + *schema.validators[1:], + ) + func._ws_schema = extended_schema # type: ignore[attr-defined] func._ws_command = command # type: ignore[attr-defined] return func diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index de0b23e495..75eccc7aba 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -312,6 +312,15 @@ class WebSocketHandler: if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): break + if msg.type == WSMsgType.BINARY: + if len(msg.data) < 1: + disconnect_warn = "Received invalid binary message." + break + handler = msg.data[0] + payload = msg.data[1:] + connection.async_handle_binary(handler, payload) + continue + if msg.type != WSMsgType.TEXT: disconnect_warn = "Received non-Text message." break diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 0765c6a5b7..ec1ab267a3 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -132,7 +132,6 @@ def _state_diff_event(event: Event) -> dict: return _state_diff(event_old_state, event_new_state) -@lru_cache(maxsize=128) def _state_diff( old_state: State, new_state: State ) -> dict[str, dict[str, dict[str, dict[str, str | list[str]]]]]: diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index a70b5d7089..11a46293f2 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -140,6 +140,7 @@ class WemoDispatcher: """Initialize the WemoDispatcher.""" self._config_entry = config_entry self._added_serial_numbers: set[str] = set() + self._failed_serial_numbers: set[str] = set() self._loaded_platforms: set[Platform] = set() async def async_add_unique_device( @@ -149,7 +150,16 @@ class WemoDispatcher: if wemo.serialnumber in self._added_serial_numbers: return - coordinator = await async_register_device(hass, self._config_entry, wemo) + try: + coordinator = await async_register_device(hass, self._config_entry, wemo) + except pywemo.PyWeMoException as err: + if wemo.serialnumber not in self._failed_serial_numbers: + self._failed_serial_numbers.add(wemo.serialnumber) + _LOGGER.error( + "Unable to add WeMo %s %s: %s", repr(wemo), wemo.host, err + ) + return + platforms = set(WEMO_MODEL_DISPATCH.get(wemo.model_name, [Platform.SWITCH])) platforms.add(Platform.SENSOR) for platform in platforms: @@ -178,6 +188,7 @@ class WemoDispatcher: ) self._added_serial_numbers.add(wemo.serialnumber) + self._failed_serial_numbers.discard(wemo.serialnumber) class WemoDiscovery: diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index d744bc9efc..4b54f9746a 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.18.2"] + "requirements": ["whirlpool-sixth-sense==0.18.3"] } diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index c31ab6acd0..4a6b1dfb44 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -51,7 +51,9 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: ) hass.async_create_background_task(_async_discovery(), "wiz-discovery") - async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL) + async_track_time_interval( + hass, _async_discovery, DISCOVERY_INTERVAL, cancel_on_shutdown=True + ) return True diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 5afb5a6b44..9ba3fd2cb3 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -1,7 +1,7 @@ """DataUpdateCoordinator for WLED.""" from __future__ import annotations -from wled import WLED, Device as WLEDDevice, WLEDConnectionClosed, WLEDError +from wled import WLED, Device as WLEDDevice, WLEDConnectionClosedError, WLEDError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP @@ -68,7 +68,7 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): try: await self.wled.listen(callback=self.async_set_updated_data) - except WLEDConnectionClosed as err: + except WLEDConnectionClosedError as err: self.last_update_success = False self.logger.info(err) except WLEDError as err: diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index 99309b9f00..b6d205912c 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["wled==0.15.0"], + "requirements": ["wled==0.16.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 924414cadf..668b90159b 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -128,6 +128,13 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, value_fn=lambda device: device.info.wifi.bssid if device.info.wifi else None, ), + WLEDSensorEntityDescription( + key="ip", + name="IP", + icon="mdi:ip-network", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.info.ip, + ), ) diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 8daef2b351..d1ad8456ba 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -1 +1,28 @@ """Sensor to indicate whether the current day is a workday.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Workday from a config entry.""" + + entry.async_on_unload(entry.add_update_listener(async_update_listener)) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener for options.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Workday config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index e66efa039a..9c2e453c03 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import date, timedelta -import logging from typing import Any import holidays @@ -13,31 +12,33 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, BinarySensorEntity, ) -from homeassistant.const import CONF_NAME, WEEKDAYS +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt -_LOGGER = logging.getLogger(__name__) - -ALLOWED_DAYS = WEEKDAYS + ["holiday"] - -CONF_COUNTRY = "country" -CONF_PROVINCE = "province" -CONF_WORKDAYS = "workdays" -CONF_EXCLUDES = "excludes" -CONF_OFFSET = "days_offset" -CONF_ADD_HOLIDAYS = "add_holidays" -CONF_REMOVE_HOLIDAYS = "remove_holidays" - -# By default, Monday - Friday are workdays -DEFAULT_WORKDAYS = ["mon", "tue", "wed", "thu", "fri"] -# By default, public holidays, Saturdays and Sundays are excluded from workdays -DEFAULT_EXCLUDES = ["sat", "sun", "holiday"] -DEFAULT_NAME = "Workday Sensor" -DEFAULT_OFFSET = 0 +from .const import ( + ALLOWED_DAYS, + CONF_ADD_HOLIDAYS, + CONF_COUNTRY, + CONF_EXCLUDES, + CONF_OFFSET, + CONF_PROVINCE, + CONF_REMOVE_HOLIDAYS, + CONF_WORKDAYS, + DEFAULT_EXCLUDES, + DEFAULT_NAME, + DEFAULT_OFFSET, + DEFAULT_WORKDAYS, + DOMAIN, + LOGGER, +) def valid_country(value: Any) -> str: @@ -80,91 +81,104 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Workday sensor.""" - add_holidays: list[DateLike] = config[CONF_ADD_HOLIDAYS] - remove_holidays: list[str] = config[CONF_REMOVE_HOLIDAYS] - country: str = config[CONF_COUNTRY] - days_offset: int = config[CONF_OFFSET] - excludes: list[str] = config[CONF_EXCLUDES] - province: str | None = config.get(CONF_PROVINCE) - sensor_name: str = config[CONF_NAME] - workdays: list[str] = config[CONF_WORKDAYS] + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) - year: int = (get_date(dt.now()) + timedelta(days=days_offset)).year + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Workday sensor.""" + add_holidays: list[DateLike] = entry.options[CONF_ADD_HOLIDAYS] + remove_holidays: list[str] = entry.options[CONF_REMOVE_HOLIDAYS] + country: str = entry.options[CONF_COUNTRY] + days_offset: int = int(entry.options[CONF_OFFSET]) + excludes: list[str] = entry.options[CONF_EXCLUDES] + province: str | None = entry.options.get(CONF_PROVINCE) + sensor_name: str = entry.options[CONF_NAME] + workdays: list[str] = entry.options[CONF_WORKDAYS] + + year: int = (dt.now() + timedelta(days=days_offset)).year obj_holidays: HolidayBase = getattr(holidays, country)(years=year) if province: - if ( - hasattr(obj_holidays, "subdivisions") - and province in obj_holidays.subdivisions - ): + try: obj_holidays = getattr(holidays, country)(subdiv=province, years=year) - else: - _LOGGER.error("There is no subdivision %s in country %s", province, country) + except NotImplementedError: + LOGGER.error("There is no subdivision %s in country %s", province, country) return # Add custom holidays try: obj_holidays.append(add_holidays) - except TypeError: - _LOGGER.debug("No custom holidays or invalid holidays") + except ValueError as error: + LOGGER.error("Could not add custom holidays: %s", error) # Remove holidays - try: - for remove_holiday in remove_holidays: - try: - # is this formatted as a date? - if dt.parse_date(remove_holiday): - # remove holiday by date - removed = obj_holidays.pop(remove_holiday) - _LOGGER.debug("Removed %s", remove_holiday) - else: - # remove holiday by name - _LOGGER.debug("Treating '%s' as named holiday", remove_holiday) - removed = obj_holidays.pop_named(remove_holiday) - for holiday in removed: - _LOGGER.debug( - "Removed %s by name '%s'", holiday, remove_holiday - ) - except KeyError as unmatched: - _LOGGER.warning("No holiday found matching %s", unmatched) - except TypeError: - _LOGGER.debug("No holidays to remove or invalid holidays") + for remove_holiday in remove_holidays: + try: + # is this formatted as a date? + if dt.parse_date(remove_holiday): + # remove holiday by date + removed = obj_holidays.pop(remove_holiday) + LOGGER.debug("Removed %s", remove_holiday) + else: + # remove holiday by name + LOGGER.debug("Treating '%s' as named holiday", remove_holiday) + removed = obj_holidays.pop_named(remove_holiday) + for holiday in removed: + LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) + except KeyError as unmatched: + LOGGER.warning("No holiday found matching %s", unmatched) - _LOGGER.debug("Found the following holidays for your configuration:") + LOGGER.debug("Found the following holidays for your configuration:") for holiday_date, name in sorted(obj_holidays.items()): # Make explicit str variable to avoid "Incompatible types in assignment" _holiday_string = holiday_date.strftime("%Y-%m-%d") - _LOGGER.debug("%s %s", _holiday_string, name) + LOGGER.debug("%s %s", _holiday_string, name) - add_entities( - [IsWorkdaySensor(obj_holidays, workdays, excludes, days_offset, sensor_name)], + async_add_entities( + [ + IsWorkdaySensor( + obj_holidays, + workdays, + excludes, + days_offset, + sensor_name, + entry.entry_id, + ) + ], True, ) -def day_to_string(day: int) -> str | None: - """Convert day index 0 - 7 to string.""" - try: - return ALLOWED_DAYS[day] - except IndexError: - return None - - -def get_date(input_date: date) -> date: - """Return date. Needed for testing.""" - return input_date - - class IsWorkdaySensor(BinarySensorEntity): """Implementation of a Workday sensor.""" + _attr_has_entity_name = True + def __init__( self, obj_holidays: HolidayBase, @@ -172,9 +186,9 @@ class IsWorkdaySensor(BinarySensorEntity): excludes: list[str], days_offset: int, name: str, + entry_id: str, ) -> None: """Initialize the Workday sensor.""" - self._attr_name = name self._obj_holidays = obj_holidays self._workdays = workdays self._excludes = excludes @@ -184,6 +198,14 @@ class IsWorkdaySensor(BinarySensorEntity): CONF_EXCLUDES: excludes, CONF_OFFSET: days_offset, } + self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="python-holidays", + model=holidays.__version__, + name=name, + ) def is_include(self, day: str, now: date) -> bool: """Check if given day is in the includes list.""" @@ -209,12 +231,9 @@ class IsWorkdaySensor(BinarySensorEntity): self._attr_is_on = False # Get ISO day of the week (1 = Monday, 7 = Sunday) - adjusted_date = get_date(dt.now()) + timedelta(days=self._days_offset) + adjusted_date = dt.now() + timedelta(days=self._days_offset) day = adjusted_date.isoweekday() - 1 - day_of_week = day_to_string(day) - - if day_of_week is None: - return + day_of_week = ALLOWED_DAYS[day] if self.is_include(day_of_week, adjusted_date): self._attr_is_on = True diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py new file mode 100644 index 0000000000..65180d86d7 --- /dev/null +++ b/homeassistant/components/workday/config_flow.py @@ -0,0 +1,308 @@ +"""Adds config flow for Workday integration.""" +from __future__ import annotations + +from typing import Any + +import holidays +from holidays import HolidayBase +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlowWithConfigEntry, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) +from homeassistant.util import dt + +from .const import ( + ALLOWED_DAYS, + CONF_ADD_HOLIDAYS, + CONF_COUNTRY, + CONF_EXCLUDES, + CONF_OFFSET, + CONF_PROVINCE, + CONF_REMOVE_HOLIDAYS, + CONF_WORKDAYS, + DEFAULT_EXCLUDES, + DEFAULT_NAME, + DEFAULT_OFFSET, + DEFAULT_WORKDAYS, + DOMAIN, +) + +NONE_SENTINEL = "none" + + +def add_province_to_schema( + schema: vol.Schema, + options: dict[str, Any], +) -> vol.Schema: + """Update schema with province from country.""" + year: int = dt.now().year + obj_holidays: HolidayBase = getattr(holidays, options[CONF_COUNTRY])(years=year) + if not obj_holidays.subdivisions: + return schema + + province_list = [NONE_SENTINEL, *obj_holidays.subdivisions] + add_schema = { + vol.Optional(CONF_PROVINCE, default=NONE_SENTINEL): SelectSelector( + SelectSelectorConfig( + options=province_list, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_PROVINCE, + ) + ), + } + + return vol.Schema({**DATA_SCHEMA_OPT.schema, **add_schema}) + + +def validate_custom_dates(user_input: dict[str, Any]) -> None: + """Validate custom dates for add/remove holidays.""" + + for add_date in user_input[CONF_ADD_HOLIDAYS]: + if dt.parse_date(add_date) is None: + raise AddDatesError("Incorrect date") + + year: int = dt.now().year + obj_holidays: HolidayBase = getattr(holidays, user_input[CONF_COUNTRY])(years=year) + if user_input.get(CONF_PROVINCE): + obj_holidays = getattr(holidays, user_input[CONF_COUNTRY])( + subdiv=user_input[CONF_PROVINCE], years=year + ) + + for remove_date in user_input[CONF_REMOVE_HOLIDAYS]: + if dt.parse_date(remove_date) is None: + if obj_holidays.get_named(remove_date) == []: + raise RemoveDatesError("Incorrect date or name") + + +DATA_SCHEMA_SETUP = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Required(CONF_COUNTRY): SelectSelector( + SelectSelectorConfig( + options=list(holidays.list_supported_countries()), + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } +) + +DATA_SCHEMA_OPT = vol.Schema( + { + vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES): SelectSelector( + SelectSelectorConfig( + options=ALLOWED_DAYS, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="days", + ) + ), + vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): NumberSelector( + NumberSelectorConfig(min=-10, max=10, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): SelectSelector( + SelectSelectorConfig( + options=ALLOWED_DAYS, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="days", + ) + ), + vol.Optional(CONF_ADD_HOLIDAYS, default=[]): SelectSelector( + SelectSelectorConfig( + options=[], + multiple=True, + custom_value=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_REMOVE_HOLIDAYS, default=[]): SelectSelector( + SelectSelectorConfig( + options=[], + multiple=True, + custom_value=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } +) + + +class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Workday integration.""" + + VERSION = 1 + + data: dict[str, Any] = {} + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> WorkdayOptionsFlowHandler: + """Get the options flow for this handler.""" + return WorkdayOptionsFlowHandler(config_entry) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a configuration from config.yaml.""" + + abort_match = { + CONF_COUNTRY: config[CONF_COUNTRY], + CONF_EXCLUDES: config[CONF_EXCLUDES], + CONF_OFFSET: config[CONF_OFFSET], + CONF_WORKDAYS: config[CONF_WORKDAYS], + CONF_ADD_HOLIDAYS: config[CONF_ADD_HOLIDAYS], + CONF_REMOVE_HOLIDAYS: config[CONF_REMOVE_HOLIDAYS], + CONF_PROVINCE: config.get(CONF_PROVINCE), + } + new_config = config.copy() + new_config[CONF_PROVINCE] = config.get(CONF_PROVINCE) + + self._async_abort_entries_match(abort_match) + return await self.async_step_options(user_input=new_config) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + self.data = user_input + return await self.async_step_options() + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA_SETUP, + errors=errors, + ) + + async def async_step_options( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle remaining flow.""" + errors: dict[str, str] = {} + if user_input is not None: + combined_input: dict[str, Any] = {**self.data, **user_input} + if combined_input.get(CONF_PROVINCE, NONE_SENTINEL) == NONE_SENTINEL: + combined_input[CONF_PROVINCE] = None + + try: + await self.hass.async_add_executor_job( + validate_custom_dates, combined_input + ) + except AddDatesError: + errors["add_holidays"] = "add_holiday_error" + except RemoveDatesError: + errors["remove_holidays"] = "remove_holiday_error" + except NotImplementedError: + self.async_abort(reason="incorrect_province") + + abort_match = { + CONF_COUNTRY: combined_input[CONF_COUNTRY], + CONF_EXCLUDES: combined_input[CONF_EXCLUDES], + CONF_OFFSET: combined_input[CONF_OFFSET], + CONF_WORKDAYS: combined_input[CONF_WORKDAYS], + CONF_ADD_HOLIDAYS: combined_input[CONF_ADD_HOLIDAYS], + CONF_REMOVE_HOLIDAYS: combined_input[CONF_REMOVE_HOLIDAYS], + CONF_PROVINCE: combined_input[CONF_PROVINCE], + } + + self._async_abort_entries_match(abort_match) + if not errors: + return self.async_create_entry( + title=combined_input[CONF_NAME], + data={}, + options=combined_input, + ) + + schema = await self.hass.async_add_executor_job( + add_province_to_schema, DATA_SCHEMA_OPT, self.data + ) + new_schema = self.add_suggested_values_to_schema(schema, user_input) + return self.async_show_form( + step_id="options", + data_schema=new_schema, + errors=errors, + ) + + +class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Handle Workday options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage Workday options.""" + errors: dict[str, str] = {} + + if user_input is not None: + combined_input: dict[str, Any] = {**self.options, **user_input} + if combined_input.get(CONF_PROVINCE, NONE_SENTINEL) == NONE_SENTINEL: + combined_input[CONF_PROVINCE] = None + + try: + await self.hass.async_add_executor_job( + validate_custom_dates, combined_input + ) + except AddDatesError: + errors["add_holidays"] = "add_holiday_error" + except RemoveDatesError: + errors["remove_holidays"] = "remove_holiday_error" + else: + try: + self._async_abort_entries_match( + { + CONF_COUNTRY: self._config_entry.options[CONF_COUNTRY], + CONF_EXCLUDES: combined_input[CONF_EXCLUDES], + CONF_OFFSET: combined_input[CONF_OFFSET], + CONF_WORKDAYS: combined_input[CONF_WORKDAYS], + CONF_ADD_HOLIDAYS: combined_input[CONF_ADD_HOLIDAYS], + CONF_REMOVE_HOLIDAYS: combined_input[CONF_REMOVE_HOLIDAYS], + CONF_PROVINCE: combined_input[CONF_PROVINCE], + } + ) + except AbortFlow as err: + errors = {"base": err.reason} + else: + return self.async_create_entry(data=combined_input) + + saved_options = self.options.copy() + if saved_options[CONF_PROVINCE] is None: + saved_options[CONF_PROVINCE] = NONE_SENTINEL + schema: vol.Schema = await self.hass.async_add_executor_job( + add_province_to_schema, DATA_SCHEMA_OPT, self.options + ) + new_schema = self.add_suggested_values_to_schema(schema, user_input) + + return self.async_show_form( + step_id="init", + data_schema=new_schema, + errors=errors, + ) + + +class AddDatesError(HomeAssistantError): + """Exception for error adding dates.""" + + +class RemoveDatesError(HomeAssistantError): + """Exception for error removing dates.""" + + +class CountryNotExist(HomeAssistantError): + """Exception country does not exist error.""" diff --git a/homeassistant/components/workday/const.py b/homeassistant/components/workday/const.py new file mode 100644 index 0000000000..20905fb989 --- /dev/null +++ b/homeassistant/components/workday/const.py @@ -0,0 +1,28 @@ +"""Add constants for Workday integration.""" +from __future__ import annotations + +import logging + +from homeassistant.const import WEEKDAYS, Platform + +LOGGER = logging.getLogger(__package__) + +ALLOWED_DAYS = WEEKDAYS + ["holiday"] + +DOMAIN = "workday" +PLATFORMS = [Platform.BINARY_SENSOR] + +CONF_COUNTRY = "country" +CONF_PROVINCE = "province" +CONF_WORKDAYS = "workdays" +CONF_EXCLUDES = "excludes" +CONF_OFFSET = "days_offset" +CONF_ADD_HOLIDAYS = "add_holidays" +CONF_REMOVE_HOLIDAYS = "remove_holidays" + +# By default, Monday - Friday are workdays +DEFAULT_WORKDAYS = ["mon", "tue", "wed", "thu", "fri"] +# By default, public holidays, Saturdays and Sundays are excluded from workdays +DEFAULT_EXCLUDES = ["sat", "sun", "holiday"] +DEFAULT_NAME = "Workday Sensor" +DEFAULT_OFFSET = 0 diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 442456066c..e018eaa588 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -1,7 +1,8 @@ { "domain": "workday", "name": "Workday", - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@gjohansson-ST"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/workday", "iot_class": "local_polling", "loggers": [ @@ -11,5 +12,5 @@ "korean_lunar_calendar" ], "quality_scale": "internal", - "requirements": ["holidays==0.18.0"] + "requirements": ["holidays==0.21.13"] } diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json new file mode 100644 index 0000000000..f34af9ff91 --- /dev/null +++ b/homeassistant/components/workday/strings.json @@ -0,0 +1,90 @@ +{ + "config": { + "abort": { + "incorrect_province": "Incorrect subdivision from yaml import" + }, + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "country": "Country" + } + }, + "options": { + "data": { + "excludes": "Excludes", + "days_offset": "Offset", + "workdays": "Workdays", + "add_holidays": "Add holidays", + "remove_holidays": "Remove Holidays", + "province": "Subdivision of country" + }, + "data_description": { + "excludes": "List of workdays to exclude", + "days_offset": "Days offset", + "workdays": "List of workdays", + "add_holidays": "Add custom holidays as YYYY-MM-DD", + "remove_holidays": "Remove holidays as YYYY-MM-DD or by using partial of name", + "province": "State, Terroritory, Province, Region of Country" + } + } + }, + "error": { + "add_holiday_error": "Incorrect format on date (YYYY-MM-DD)", + "remove_holiday_error": "Incorrect format on date (YYYY-MM-DD) or holiday name not found", + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "excludes": "[%key:component::workday::config::step::options::data::excludes%]", + "days_offset": "[%key:component::workday::config::step::options::data::days_offset%]", + "workdays": "[%key:component::workday::config::step::options::data::workdays%]", + "add_holidays": "[%key:component::workday::config::step::options::data::add_holidays%]", + "remove_holidays": "[%key:component::workday::config::step::options::data::remove_holidays%]", + "province": "[%key:component::workday::config::step::options::data::province%]" + }, + "data_description": { + "excludes": "[%key:component::workday::config::step::options::data_description::excludes%]", + "days_offset": "[%key:component::workday::config::step::options::data_description::days_offset%]", + "workdays": "[%key:component::workday::config::step::options::data_description::workdays%]", + "add_holidays": "[%key:component::workday::config::step::options::data_description::add_holidays%]", + "remove_holidays": "[%key:component::workday::config::step::options::data_description::remove_holidays%]", + "province": "[%key:component::workday::config::step::options::data_description::province%]" + } + } + }, + "error": { + "add_holiday_error": "Incorrect format on date (YYYY-MM-DD)", + "remove_holiday_error": "Incorrect format on date (YYYY-MM-DD) or holiday name not found", + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Workday YAML configuration is being removed", + "description": "Configuring Workday using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Workday YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + }, + "selector": { + "province": { + "options": { + "none": "No subdivision" + } + }, + "days": { + "options": { + "mon": "Monday", + "tue": "Tuesday", + "wed": "Wednesday", + "thu": "Thursday", + "fri": "Friday", + "sat": "Saturday", + "sun": "Sunday", + "holiday": "Holidays" + } + } + } +} diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py new file mode 100644 index 0000000000..8676365212 --- /dev/null +++ b/homeassistant/components/wyoming/__init__.py @@ -0,0 +1,44 @@ +"""The Wyoming integration.""" +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .data import WyomingService + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Load Wyoming.""" + service = await WyomingService.create(entry.data["host"], entry.data["port"]) + + if service is None: + raise ConfigEntryNotReady("Unable to connect") + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = service + + await hass.config_entries.async_forward_entry_setups( + entry, + service.platforms, + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Wyoming.""" + service: WyomingService = hass.data[DOMAIN][entry.entry_id] + + unload_ok = await hass.config_entries.async_unload_platforms( + entry, + service.platforms, + ) + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + + return unload_ok diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py new file mode 100644 index 0000000000..e1b41b5405 --- /dev/null +++ b/homeassistant/components/wyoming/config_flow.py @@ -0,0 +1,99 @@ +"""Config flow for Wyoming integration.""" +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN +from .data import WyomingService + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT): int, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Wyoming integration.""" + + VERSION = 1 + + _hassio_discovery: HassioServiceInfo + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + service = await WyomingService.create( + user_input[CONF_HOST], + user_input[CONF_PORT], + ) + + if service is None: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "cannot_connect"}, + ) + + # ASR = automated speech recognition (STT) + asr_installed = [asr for asr in service.info.asr if asr.installed] + tts_installed = [tts for tts in service.info.tts if tts.installed] + + if asr_installed: + name = asr_installed[0].name + elif tts_installed: + name = tts_installed[0].name + else: + return self.async_abort(reason="no_services") + + return self.async_create_entry(title=name, data=user_input) + + async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: + """Handle Supervisor add-on discovery.""" + await self.async_set_unique_id(discovery_info.uuid) + self._abort_if_unique_id_configured() + + self._hassio_discovery = discovery_info + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm Supervisor discovery.""" + errors: dict[str, str] = {} + + if user_input is not None: + uri = urlparse(self._hassio_discovery.config["uri"]) + if service := await WyomingService.create(uri.hostname, uri.port): + if not any( + asr for asr in service.info.asr if asr.installed + ) and not any(tts for tts in service.info.tts if tts.installed): + return self.async_abort(reason="no_services") + + return self.async_create_entry( + title=self._hassio_discovery.name, + data={CONF_HOST: uri.hostname, CONF_PORT: uri.port}, + ) + + errors = {"base": "cannot_connect"} + + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders={"addon": self._hassio_discovery.name}, + errors=errors, + ) diff --git a/homeassistant/components/wyoming/const.py b/homeassistant/components/wyoming/const.py new file mode 100644 index 0000000000..26443cc11e --- /dev/null +++ b/homeassistant/components/wyoming/const.py @@ -0,0 +1,7 @@ +"""Constants for the Wyoming integration.""" + +DOMAIN = "wyoming" + +SAMPLE_RATE = 16000 +SAMPLE_WIDTH = 2 +SAMPLE_CHANNELS = 1 diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py new file mode 100644 index 0000000000..3ef93810b6 --- /dev/null +++ b/homeassistant/components/wyoming/data.py @@ -0,0 +1,77 @@ +"""Base class for Wyoming providers.""" +from __future__ import annotations + +import asyncio + +import async_timeout +from wyoming.client import AsyncTcpClient +from wyoming.info import Describe, Info + +from homeassistant.const import Platform + +from .error import WyomingError + +_INFO_TIMEOUT = 1 +_INFO_RETRY_WAIT = 2 +_INFO_RETRIES = 3 + + +class WyomingService: + """Hold info for Wyoming service.""" + + def __init__(self, host: str, port: int, info: Info) -> None: + """Initialize Wyoming service.""" + self.host = host + self.port = port + self.info = info + platforms = [] + if any(asr.installed for asr in info.asr): + platforms.append(Platform.STT) + if any(tts.installed for tts in info.tts): + platforms.append(Platform.TTS) + self.platforms = platforms + + @classmethod + async def create(cls, host: str, port: int) -> WyomingService | None: + """Create a Wyoming service.""" + info = await load_wyoming_info(host, port) + if info is None: + return None + + return cls(host, port, info) + + +async def load_wyoming_info( + host: str, + port: int, + retries: int = _INFO_RETRIES, + retry_wait: float = _INFO_RETRY_WAIT, + timeout: float = _INFO_TIMEOUT, +) -> Info | None: + """Load info from Wyoming server.""" + wyoming_info: Info | None = None + + for _ in range(retries + 1): + try: + async with AsyncTcpClient(host, port) as client: + with async_timeout.timeout(timeout): + # Describe -> Info + await client.write_event(Describe().event()) + while True: + event = await client.read_event() + if event is None: + raise WyomingError( + "Connection closed unexpectedly", + ) + + if Info.is_type(event.type): + wyoming_info = Info.from_event(event) + break # while + + if wyoming_info is not None: + break # for + except (asyncio.TimeoutError, OSError, WyomingError): + # Sleep and try again + await asyncio.sleep(retry_wait) + + return wyoming_info diff --git a/homeassistant/components/wyoming/error.py b/homeassistant/components/wyoming/error.py new file mode 100644 index 0000000000..40b2e70ce6 --- /dev/null +++ b/homeassistant/components/wyoming/error.py @@ -0,0 +1,6 @@ +"""Errors for the Wyoming integration.""" +from homeassistant.exceptions import HomeAssistantError + + +class WyomingError(HomeAssistantError): + """Base class for Wyoming errors.""" diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json new file mode 100644 index 0000000000..9ad8092bb8 --- /dev/null +++ b/homeassistant/components/wyoming/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "wyoming", + "name": "Wyoming Protocol", + "codeowners": ["@balloob", "@synesthesiam"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/wyoming", + "iot_class": "local_push", + "requirements": ["wyoming==0.0.1"] +} diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json new file mode 100644 index 0000000000..20d73d8dc1 --- /dev/null +++ b/homeassistant/components/wyoming/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "hassio_confirm": { + "description": "Do you want to configure Home Assistant to connect to the Wyoming service provided by the add-on: {addon}?" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "no_services": "No services found at endpoint" + } + } +} diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py new file mode 100644 index 0000000000..8d3f653450 --- /dev/null +++ b/homeassistant/components/wyoming/stt.py @@ -0,0 +1,129 @@ +"""Support for Wyoming speech to text services.""" +from collections.abc import AsyncIterable +import logging + +from wyoming.asr import Transcript +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.client import AsyncTcpClient + +from homeassistant.components import stt +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH +from .data import WyomingService +from .error import WyomingError + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Wyoming speech to text.""" + service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + WyomingSttProvider(config_entry, service), + ] + ) + + +class WyomingSttProvider(stt.SpeechToTextEntity): + """Wyoming speech to text provider.""" + + def __init__( + self, + config_entry: ConfigEntry, + service: WyomingService, + ) -> None: + """Set up provider.""" + self.service = service + asr_service = service.info.asr[0] + + model_languages: set[str] = set() + for asr_model in asr_service.models: + if asr_model.installed: + model_languages.update(asr_model.languages) + + self._supported_languages = list(model_languages) + self._attr_name = asr_service.name + self._attr_unique_id = f"{config_entry.entry_id}-stt" + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return self._supported_languages + + @property + def supported_formats(self) -> list[stt.AudioFormats]: + """Return a list of supported formats.""" + return [stt.AudioFormats.WAV] + + @property + def supported_codecs(self) -> list[stt.AudioCodecs]: + """Return a list of supported codecs.""" + return [stt.AudioCodecs.PCM] + + @property + def supported_bit_rates(self) -> list[stt.AudioBitRates]: + """Return a list of supported bitrates.""" + return [stt.AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> list[stt.AudioSampleRates]: + """Return a list of supported samplerates.""" + return [stt.AudioSampleRates.SAMPLERATE_16000] + + @property + def supported_channels(self) -> list[stt.AudioChannels]: + """Return a list of supported channels.""" + return [stt.AudioChannels.CHANNEL_MONO] + + async def async_process_audio_stream( + self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes] + ) -> stt.SpeechResult: + """Process an audio stream to STT service.""" + try: + async with AsyncTcpClient(self.service.host, self.service.port) as client: + await client.write_event( + AudioStart( + rate=SAMPLE_RATE, + width=SAMPLE_WIDTH, + channels=SAMPLE_CHANNELS, + ).event(), + ) + + async for audio_bytes in stream: + chunk = AudioChunk( + rate=SAMPLE_RATE, + width=SAMPLE_WIDTH, + channels=SAMPLE_CHANNELS, + audio=audio_bytes, + ) + await client.write_event(chunk.event()) + + await client.write_event(AudioStop().event()) + + while True: + event = await client.read_event() + if event is None: + _LOGGER.debug("Connection lost") + return stt.SpeechResult(None, stt.SpeechResultState.ERROR) + + if Transcript.is_type(event.type): + transcript = Transcript.from_event(event) + text = transcript.text + break + + except (OSError, WyomingError) as err: + _LOGGER.exception("Error processing audio stream: %s", err) + return stt.SpeechResult(None, stt.SpeechResultState.ERROR) + + return stt.SpeechResult( + text, + stt.SpeechResultState.SUCCESS, + ) diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py new file mode 100644 index 0000000000..f2e314dc13 --- /dev/null +++ b/homeassistant/components/wyoming/tts.py @@ -0,0 +1,155 @@ +"""Support for Wyoming text to speech services.""" +from collections import defaultdict +import io +import logging +import wave + +from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStop +from wyoming.client import AsyncTcpClient +from wyoming.tts import Synthesize + +from homeassistant.components import tts +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .data import WyomingService +from .error import WyomingError + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Wyoming speech to text.""" + service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + WyomingTtsProvider(config_entry, service), + ] + ) + + +class WyomingTtsProvider(tts.TextToSpeechEntity): + """Wyoming text to speech provider.""" + + def __init__( + self, + config_entry: ConfigEntry, + service: WyomingService, + ) -> None: + """Set up provider.""" + self.service = service + self._tts_service = next(tts for tts in service.info.tts if tts.installed) + + voice_languages: set[str] = set() + self._voices: dict[str, list[tts.Voice]] = defaultdict(list) + for voice in self._tts_service.voices: + if not voice.installed: + continue + + voice_languages.update(voice.languages) + for language in voice.languages: + self._voices[language].append( + tts.Voice( + voice_id=voice.name, + name=voice.name, + ) + ) + + self._supported_languages: list[str] = list(voice_languages) + + self._attr_name = self._tts_service.name + self._attr_unique_id = f"{config_entry.entry_id}-tts" + + @property + def default_language(self): + """Return default language.""" + if not self._supported_languages: + return None + + return self._supported_languages[0] + + @property + def supported_languages(self): + """Return list of supported languages.""" + return self._supported_languages + + @property + def supported_options(self): + """Return list of supported options like voice, emotion.""" + return [tts.ATTR_AUDIO_OUTPUT, tts.ATTR_VOICE] + + @property + def default_options(self): + """Return a dict include default options.""" + return {tts.ATTR_AUDIO_OUTPUT: "wav"} + + @callback + def async_get_supported_voices(self, language: str) -> list[tts.Voice] | None: + """Return a list of supported voices for a language.""" + return self._voices.get(language) + + async def async_get_tts_audio(self, message, language, options=None): + """Load TTS from UNIX socket.""" + try: + async with AsyncTcpClient(self.service.host, self.service.port) as client: + await client.write_event(Synthesize(message).event()) + + with io.BytesIO() as wav_io: + wav_writer: wave.Wave_write | None = None + while True: + event = await client.read_event() + if event is None: + _LOGGER.debug("Connection lost") + return (None, None) + + if AudioStop.is_type(event.type): + break + + if AudioChunk.is_type(event.type): + chunk = AudioChunk.from_event(event) + if wav_writer is None: + wav_writer = wave.open(wav_io, "wb") + wav_writer.setframerate(chunk.rate) + wav_writer.setsampwidth(chunk.width) + wav_writer.setnchannels(chunk.channels) + + wav_writer.writeframes(chunk.audio) + + if wav_writer is not None: + wav_writer.close() + + data = wav_io.getvalue() + + except (OSError, WyomingError): + return (None, None) + + if (options is None) or (options[tts.ATTR_AUDIO_OUTPUT] == "wav"): + return ("wav", data) + + # Raw output (convert to 16Khz, 16-bit mono) + with io.BytesIO(data) as wav_io: + wav_reader: wave.Wave_read = wave.open(wav_io, "rb") + raw_data = ( + AudioChunkConverter( + rate=16000, + width=2, + channels=1, + ) + .convert( + AudioChunk( + audio=wav_reader.readframes(wav_reader.getnframes()), + rate=wav_reader.getframerate(), + width=wav_reader.getsampwidth(), + channels=wav_reader.getnchannels(), + ) + ) + .audio + ) + + return ("raw", raw_data) diff --git a/homeassistant/components/xbox_live/__init__.py b/homeassistant/components/xbox_live/__init__.py deleted file mode 100644 index cc9e8ac351..0000000000 --- a/homeassistant/components/xbox_live/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The xbox_live component.""" diff --git a/homeassistant/components/xbox_live/manifest.json b/homeassistant/components/xbox_live/manifest.json deleted file mode 100644 index bf3e798da0..0000000000 --- a/homeassistant/components/xbox_live/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "xbox_live", - "name": "Xbox Live", - "codeowners": ["@MartinHjelmare"], - "documentation": "https://www.home-assistant.io/integrations/xbox_live", - "iot_class": "cloud_polling", - "loggers": ["xboxapi"], - "requirements": ["xboxapi==2.0.1"] -} diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py deleted file mode 100644 index d95031a646..0000000000 --- a/homeassistant/components/xbox_live/sensor.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Sensor for Xbox Live account status.""" -from __future__ import annotations - -from datetime import timedelta -import logging - -import voluptuous as vol -from xboxapi import Client - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_API_KEY, CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -_LOGGER = logging.getLogger(__name__) - -CONF_XUID = "xuid" - -ICON = "mdi:microsoft-xbox" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_XUID): vol.All(cv.ensure_list, [cv.string]), - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Xbox platform.""" - create_issue( - hass, - "xbox_live", - "pending_removal", - breaks_in_ha_version="2023.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="pending_removal", - ) - _LOGGER.warning( - "The Xbox Live integration is deprecated " - "and will be removed in Home Assistant 2023.2" - ) - api = Client(api_key=config[CONF_API_KEY]) - entities = [] - - # request profile info to check api connection - response = api.api_get("profile") - if not response.ok: - _LOGGER.error( - ( - "Can't setup X API connection. Check your account or " - "api key on xapi.us. Code: %s Description: %s " - ), - response.status_code, - response.reason, - ) - return - - users = config[CONF_XUID] - - interval = timedelta(minutes=1 * len(users)) - interval = config.get(CONF_SCAN_INTERVAL, interval) - - for xuid in users: - if (gamercard := get_user_gamercard(api, xuid)) is None: - continue - entities.append(XboxSensor(api, xuid, gamercard, interval)) - - add_entities(entities, True) - - -def get_user_gamercard(api, xuid): - """Get profile info.""" - gamercard = api.gamer(gamertag="", xuid=xuid).get("gamercard") - _LOGGER.debug("User gamercard: %s", gamercard) - - if gamercard.get("success", True) and gamercard.get("code") is None: - return gamercard - _LOGGER.error( - "Can't get user profile %s. Error Code: %s Description: %s", - xuid, - gamercard.get("code", "unknown"), - gamercard.get("description", "unknown"), - ) - return None - - -class XboxSensor(SensorEntity): - """A class for the Xbox account.""" - - _attr_should_poll = False - - def __init__(self, api, xuid, gamercard, interval): - """Initialize the sensor.""" - self._state = None - self._presence = [] - self._xuid = xuid - self._api = api - self._gamertag = gamercard["gamertag"] - self._gamerscore = gamercard["gamerscore"] - self._interval = interval - self._picture = gamercard["gamerpicSmallSslImagePath"] - self._tier = gamercard["tier"] - - @property - def name(self): - """Return the name of the sensor.""" - return self._gamertag - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attributes = {"gamerscore": self._gamerscore, "tier": self._tier} - - for device in self._presence: - for title in device["titles"]: - attributes[f'{device["type"]} {title["placement"]}'] = title["name"] - - return attributes - - @property - def entity_picture(self): - """Avatar of the account.""" - return self._picture - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON - - async def async_added_to_hass(self) -> None: - """Start custom polling.""" - - @callback - def async_update(event_time=None): - """Update the entity.""" - self.async_schedule_update_ha_state(True) - - async_track_time_interval(self.hass, async_update, self._interval) - - def update(self) -> None: - """Update state data from Xbox API.""" - presence = self._api.gamer(gamertag="", xuid=self._xuid).get("presence") - _LOGGER.debug("User presence: %s", presence) - self._state = presence["state"] - self._presence = presence.get("devices", []) diff --git a/homeassistant/components/xbox_live/strings.json b/homeassistant/components/xbox_live/strings.json deleted file mode 100644 index 0f73f851bd..0000000000 --- a/homeassistant/components/xbox_live/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "pending_removal": { - "title": "The Xbox Live integration is being removed", - "description": "The Xbox Live integration is pending removal from Home Assistant and will no longer be available as of Home Assistant 2023.2.\n\nThe integration is being removed, because it is only useful for the legacy device Xbox 360 and the upstream API now requires a paid subscription. Newer consoles are supported by the Xbox integration for free.\n\nRemove the Xbox Live YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - } -} diff --git a/homeassistant/components/xiaomi_aqara/strings.json b/homeassistant/components/xiaomi_aqara/strings.json index 66ad4d0135..63fb48542c 100644 --- a/homeassistant/components/xiaomi_aqara/strings.json +++ b/homeassistant/components/xiaomi_aqara/strings.json @@ -6,7 +6,7 @@ "description": "If the IP and MAC addresses are left empty, auto-discovery is used", "data": { "interface": "The network interface to use", - "host": "[%key:common::config_flow::data::ip%] (optional)", + "host": "IP address (optional)", "mac": "Mac Address (optional)" } }, @@ -29,7 +29,7 @@ "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface", "invalid_interface": "Invalid network interface", "invalid_key": "Invalid gateway key", - "invalid_host": "[%key:common::config_flow::error::invalid_host%], see https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", + "invalid_host": "Invalid hostname or IP address, see https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", "invalid_mac": "Invalid Mac Address" }, "abort": { diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index d78d2f72e3..4d5cddd951 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.16.4"] + "requirements": ["xiaomi-ble==0.17.0"] } diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 58b85bc34e..b6810cf4cf 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -291,6 +291,7 @@ async def async_create_miio_device_and_coordinator( name = entry.title device: MiioDevice | None = None migrate = False + lazy_discover = False update_method = _async_update_data_default coordinator_class: type[DataUpdateCoordinator[Any]] = DataUpdateCoordinator @@ -307,38 +308,41 @@ async def async_create_miio_device_and_coordinator( # Humidifiers if model in MODELS_HUMIDIFIER_MIOT: - device = AirHumidifierMiot(host, token) + device = AirHumidifierMiot(host, token, lazy_discover=lazy_discover) migrate = True elif model in MODELS_HUMIDIFIER_MJJSQ: - device = AirHumidifierMjjsq(host, token, model=model) + device = AirHumidifierMjjsq( + host, token, lazy_discover=lazy_discover, model=model + ) migrate = True elif model in MODELS_HUMIDIFIER_MIIO: - device = AirHumidifier(host, token, model=model) + device = AirHumidifier(host, token, lazy_discover=lazy_discover, model=model) migrate = True # Airpurifiers and Airfresh elif model in MODELS_PURIFIER_MIOT: - device = AirPurifierMiot(host, token) + device = AirPurifierMiot(host, token, lazy_discover=lazy_discover) elif model.startswith("zhimi.airpurifier."): - device = AirPurifier(host, token) + device = AirPurifier(host, token, lazy_discover=lazy_discover) elif model.startswith("zhimi.airfresh."): - device = AirFresh(host, token) + device = AirFresh(host, token, lazy_discover=lazy_discover) elif model == MODEL_AIRFRESH_A1: - device = AirFreshA1(host, token) + device = AirFreshA1(host, token, lazy_discover=lazy_discover) elif model == MODEL_AIRFRESH_T2017: - device = AirFreshT2017(host, token) + device = AirFreshT2017(host, token, lazy_discover=lazy_discover) elif ( model in MODELS_VACUUM or model.startswith(ROBOROCK_GENERIC) or model.startswith(ROCKROBO_GENERIC) ): + # TODO: add lazy_discover as argument when python-miio add support # pylint: disable=fixme device = RoborockVacuum(host, token) update_method = _async_update_data_vacuum coordinator_class = DataUpdateCoordinator[VacuumCoordinatorData] # Pedestal fans elif model in MODEL_TO_CLASS_MAP: - device = MODEL_TO_CLASS_MAP[model](host, token) + device = MODEL_TO_CLASS_MAP[model](host, token, lazy_discover=lazy_discover) elif model in MODELS_FAN_MIIO: - device = Fan(host, token, model=model) + device = Fan(host, token, lazy_discover=lazy_discover, model=model) else: _LOGGER.error( ( diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index c343fe9a5f..dfcb503182 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -42,7 +42,7 @@ "host": "[%key:common::config_flow::data::ip%]", "token": "[%key:common::config_flow::data::api_token%]" }, - "description": "You will need the 32 character [%key:common::config_flow::data::api_token%], see https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token for instructions. Please note, that this [%key:common::config_flow::data::api_token%] is different from the key used by the Xiaomi Aqara integration." + "description": "You will need the 32 character API token, see https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token for instructions. Please note, that this API token is different from the key used by the Xiaomi Aqara integration." }, "connect": { "data": { diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 6bb58752a0..381229edea 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.1"] + "requirements": ["yalexs-ble==2.1.16"] } diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index 0f1f138fd6..c2d1a2155c 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -22,7 +22,7 @@ } }, "error": { - "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and again.", + "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and try again.", "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%]", diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index d3e7e48815..1fbae6c88a 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -29,7 +29,7 @@ CONF_STOP_ID = "stop_id" CONF_ROUTE = "routes" DEFAULT_NAME = "Yandex Transport" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) @@ -70,6 +70,7 @@ class DiscoverYandexTransport(SensorEntity): """Implementation of yandex_transport sensor.""" _attr_attribution = "Data provided by maps.yandex.ru" + _attr_icon = "mdi:bus" def __init__(self, requester: YandexMapsRequester, stop_id, routes, name) -> None: """Initialize sensor.""" @@ -168,8 +169,3 @@ class DiscoverYandexTransport(SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" return self._attrs - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index f6d2d7fde7..8eb2991c9d 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -16,7 +16,7 @@ from typing_extensions import Self from homeassistant import config_entries from homeassistant.components import network, ssdp -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import discovery_flow from homeassistant.helpers.event import async_call_later, async_track_time_interval @@ -180,7 +180,9 @@ class YeelightScanner: # Delay starting the flow in case the discovery is the result # of another discovery - async_call_later(self._hass, 1, _async_start_flow) + async_call_later( + self._hass, 1, HassJob(_async_start_flow, cancel_on_shutdown=True) + ) @callback def _async_process_entry(self, headers: CaseInsensitiveDict) -> None: diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 7362a09609..c10cc8158e 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -7,6 +7,7 @@ from datetime import timedelta from typing import Any import async_timeout +from yolink.const import ATTR_DEVICE_SMART_REMOTER from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError from yolink.home_manager import YoLinkHome @@ -16,11 +17,16 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + device_registry as dr, +) from . import api -from .const import DOMAIN +from .const import DOMAIN, YOLINK_EVENT from .coordinator import YoLinkCoordinator +from .device_trigger import CONF_LONG_PRESS, CONF_SHORT_PRESS SCAN_INTERVAL = timedelta(minutes=5) @@ -53,9 +59,32 @@ class YoLinkHomeMessageListener(MessageListener): device_coordinators = entry_data.device_coordinators if not device_coordinators: return - device_coordiantor = device_coordinators.get(device.device_id) - if device_coordiantor is not None: - device_coordiantor.async_set_updated_data(msg_data) + device_coordinator = device_coordinators.get(device.device_id) + if device_coordinator is None: + return + device_coordinator.async_set_updated_data(msg_data) + # handling events + if ( + device_coordinator.device.device_type == ATTR_DEVICE_SMART_REMOTER + and msg_data.get("event") is not None + ): + device_registry = dr.async_get(self._hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_coordinator.device.device_id)} + ) + if device_entry is None: + return + key_press_type = None + if msg_data["event"]["type"] == "Press": + key_press_type = CONF_SHORT_PRESS + else: + key_press_type = CONF_LONG_PRESS + button_idx = msg_data["event"]["keyMask"] + event_data = { + "type": f"button_{button_idx}_{key_press_type}", + "device_id": device_entry.id, + } + self._hass.bus.async_fire(YOLINK_EVENT, event_data) @dataclass diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 61cbc8b302..935889a036 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -7,3 +7,4 @@ ATTR_DEVICE_TYPE = "type" ATTR_DEVICE_NAME = "name" ATTR_DEVICE_STATE = "state" ATTR_DEVICE_ID = "deviceId" +YOLINK_EVENT = f"{DOMAIN}_event" diff --git a/homeassistant/components/yolink/device_trigger.py b/homeassistant/components/yolink/device_trigger.py new file mode 100644 index 0000000000..aac860c6a2 --- /dev/null +++ b/homeassistant/components/yolink/device_trigger.py @@ -0,0 +1,88 @@ +"""Provides device triggers for YoLink.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +from yolink.const import ATTR_DEVICE_SMART_REMOTER + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN, YOLINK_EVENT + +CONF_BUTTON_1 = "button_1" +CONF_BUTTON_2 = "button_2" +CONF_BUTTON_3 = "button_3" +CONF_BUTTON_4 = "button_4" +CONF_SHORT_PRESS = "short_press" +CONF_LONG_PRESS = "long_press" + +REMOTE_TRIGGER_TYPES = { + f"{CONF_BUTTON_1}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_1}_{CONF_LONG_PRESS}", + f"{CONF_BUTTON_2}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_2}_{CONF_LONG_PRESS}", + f"{CONF_BUTTON_3}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_3}_{CONF_LONG_PRESS}", + f"{CONF_BUTTON_4}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_4}_{CONF_LONG_PRESS}", +} + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + {vol.Required(CONF_TYPE): vol.In(REMOTE_TRIGGER_TYPES)} +) + + +# YoLink Remotes YS3604/YS3605/YS3606/YS3607 +DEVICE_TRIGGER_TYPES: dict[str, set[str]] = { + ATTR_DEVICE_SMART_REMOTER: REMOTE_TRIGGER_TYPES, +} + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """List device triggers for YoLink devices.""" + device_registry = dr.async_get(hass) + registry_device = device_registry.async_get(device_id) + if not registry_device or registry_device.model != ATTR_DEVICE_SMART_REMOTER: + return [] + + triggers = [] + for trigger in DEVICE_TRIGGER_TYPES[ATTR_DEVICE_SMART_REMOTER]: + triggers.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_PLATFORM: "device", + CONF_TYPE: trigger, + } + ) + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + event_config = { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: YOLINK_EVENT, + event_trigger.CONF_EVENT_DATA: { + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + CONF_TYPE: config[CONF_TYPE], + }, + } + event_config = event_trigger.TRIGGER_SCHEMA(event_config) + return await event_trigger.async_attach_trigger( + hass, event_config, action, trigger_info, platform_type="device" + ) diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 4850df4a26..5f89f54ccb 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -1,4 +1,4 @@ -"""YoLink Binary Sensor.""" +"""YoLink Sensor.""" from __future__ import annotations from collections.abc import Callable @@ -15,6 +15,7 @@ from yolink.const import ( ATTR_DEVICE_MULTI_OUTLET, ATTR_DEVICE_OUTLET, ATTR_DEVICE_SIREN, + ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_THERMOSTAT, @@ -68,6 +69,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_MULTI_OUTLET, + ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_OUTLET, ATTR_DEVICE_SIREN, ATTR_DEVICE_SWITCH, @@ -84,6 +86,7 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_LOCK, diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 94fe5dc09a..de16e1a6e3 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -21,5 +21,17 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } + }, + "device_automation": { + "trigger_type": { + "button_1_short_press": "Button_1 (short press)", + "button_1_long_press": "Button_1 (long press)", + "button_2_short_press": "Button_2 (short press)", + "button_2_long_press": "Button_2 (long press)", + "button_3_short_press": "Button_3 (short press)", + "button_3_long_press": "Button_3 (long press)", + "button_4_short_press": "Button_4 (short press)", + "button_4_long_press": "Button_4 (long press)" + } } } diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index b9120f433d..057533081e 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -10,7 +10,14 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE, UnitOfEnergy, UnitOfPower, UnitOfVolume +from homeassistant.const import ( + CONF_DEVICE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfVolume, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -47,6 +54,15 @@ async def async_setup_entry( DeliveryMeterSensor(coordinator, device, "high"), ExtraMeterSensor(coordinator, device, "total"), ExtraMeterPowerSensor(coordinator, device, "usage"), + PhasePowerSensor(coordinator, device, 1), + PhaseVoltageSensor(coordinator, device, 1), + PhaseCurrentSensor(coordinator, device, 1), + PhasePowerSensor(coordinator, device, 2), + PhaseVoltageSensor(coordinator, device, 2), + PhaseCurrentSensor(coordinator, device, 2), + PhasePowerSensor(coordinator, device, 3), + PhaseVoltageSensor(coordinator, device, 3), + PhaseCurrentSensor(coordinator, device, 3), ] ) @@ -193,6 +209,87 @@ class EnergyMeterSensor(YoulessBaseSensor): return getattr(self.coordinator.data.power_meter, f"_{self._type}", None) +class PhasePowerSensor(YoulessBaseSensor): + """The current power usage of a single phase.""" + + _attr_native_unit_of_measurement = UnitOfPower.WATT + _attr_device_class = SensorDeviceClass.POWER + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int + ) -> None: + """Initialize the power phase sensor.""" + super().__init__( + coordinator, device, "power", "Energy usage", f"phase_{phase}_power" + ) + self._attr_name = f"Phase {phase} power" + self._phase = phase + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor value from the coordinator.""" + phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) + if phase_sensor is None: + return None + + return phase_sensor.power + + +class PhaseVoltageSensor(YoulessBaseSensor): + """The current voltage of a single phase.""" + + _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT + _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int + ) -> None: + """Initialize the voltage phase sensor.""" + super().__init__( + coordinator, device, "power", "Energy usage", f"phase_{phase}_voltage" + ) + self._attr_name = f"Phase {phase} voltage" + self._phase = phase + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor value from the coordinator for phase voltage.""" + phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) + if phase_sensor is None: + return None + + return phase_sensor.voltage + + +class PhaseCurrentSensor(YoulessBaseSensor): + """The current current of a single phase.""" + + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + _attr_device_class = SensorDeviceClass.CURRENT + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int + ) -> None: + """Initialize the current phase sensor.""" + super().__init__( + coordinator, device, "power", "Energy usage", f"phase_{phase}_current" + ) + self._attr_name = f"Phase {phase} current" + self._phase = phase + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor value from the coordinator for phase current.""" + phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) + if phase_sensor is None: + return None + + return phase_sensor.current + + class ExtraMeterSensor(YoulessBaseSensor): """The Youless extra meter value sensor (s0).""" diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index badc124271..17fb4c5856 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -97,7 +97,7 @@ CONFIG_SCHEMA = vol.Schema( ) -@dataclass +@dataclass(slots=True) class ZeroconfServiceInfo(BaseServiceInfo): """Prepared info from mDNS entries.""" @@ -564,14 +564,20 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: if isinstance(value, bytes): properties[key] = value.decode("utf-8") - if not (addresses := service.addresses or service.parsed_addresses()): + if not (ip_addresses := service.ip_addresses_by_version(IPVersion.All)): return None - if (host := _first_non_link_local_address(addresses)) is None: + host: str | None = None + for ip_addr in ip_addresses: + if not ip_addr.is_link_local and not ip_addr.is_unspecified: + host = str(ip_addr) + break + if not host: return None + assert service.server is not None, "server cannot be none if there are addresses" return ZeroconfServiceInfo( - host=str(host), - addresses=service.parsed_addresses(), + host=host, + addresses=[str(ip_addr) for ip_addr in ip_addresses], port=service.port, hostname=service.server, type=service.type, @@ -580,30 +586,6 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: ) -def _first_non_link_local_address( - addresses: list[bytes] | list[str], -) -> str | None: - """Return the first ipv6 or non-link local ipv4 address, preferring IPv4.""" - for address in addresses: - ip_addr = ip_address(address) - if ( - not ip_addr.is_link_local - and not ip_addr.is_unspecified - and ip_addr.version == 4 - ): - return str(ip_addr) - # If we didn't find a good IPv4 address, check for IPv6 addresses. - for address in addresses: - ip_addr = ip_address(address) - if ( - not ip_addr.is_link_local - and not ip_addr.is_unspecified - and ip_addr.version == 6 - ): - return str(ip_addr) - return None - - def _suppress_invalid_properties(properties: dict) -> None: """Suppress any properties that will cause zeroconf to fail to startup.""" diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index b7a643bb46..314a0c9ef7 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.47.4"] + "requirements": ["zeroconf==0.58.2"] } diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 3c6b7c7186..9b520c4681 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -24,7 +24,6 @@ DEFAULT_NAME = "Zestimate" NAME = "zestimate" ZESTIMATE = f"{DEFAULT_NAME}:{NAME}" -ICON = "mdi:home-variant" ATTR_AMOUNT = "amount" ATTR_CHANGE = "amount_change_30_days" @@ -67,6 +66,7 @@ class ZestimateDataSensor(SensorEntity): """Implementation of a Zestimate sensor.""" _attr_attribution = "Data provided by Zillow.com" + _attr_icon = "mdi:home-variant" def __init__(self, name, params): """Initialize the sensor.""" @@ -103,11 +103,6 @@ class ZestimateDataSensor(SensorEntity): attributes["address"] = self.address return attributes - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self): """Get the latest data and update the states.""" diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index dd07d4da42..5607cabffe 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -17,7 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType -from . import api +from . import websocket_api from .core import ZHAGateway from .core.const import ( BAUD_RATES, @@ -131,7 +131,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b model=zha_gateway.radio_description, ) - api.async_load_api(hass) + websocket_api.async_load_api(hass) async def async_zha_shutdown(event): """Handle shutdown tasks.""" @@ -150,11 +150,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload ZHA config entry.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA].pop(DATA_ZHA_GATEWAY) await zha_gateway.shutdown() GROUP_PROBE.cleanup() - api.async_unload_api(hass) + websocket_api.async_unload_api(hass) # our components don't have unload methods so no need to look at return values await asyncio.gather( diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index 6a5e8bb476..b6794e909d 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -25,13 +25,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery -from .core.channels.security import ( +from .core.cluster_handlers.security import ( SIGNAL_ALARM_TRIGGERED, SIGNAL_ARMED_STATE_CHANGED, - IasAce as AceChannel, + IasAce as AceClusterHandler, ) from .core.const import ( - CHANNEL_IAS_ACE, + CLUSTER_HANDLER_IAS_ACE, CONF_ALARM_ARM_REQUIRES_CODE, CONF_ALARM_FAILED_TRIES, CONF_ALARM_MASTER_CODE, @@ -77,10 +77,11 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -@STRICT_MATCH(channel_names=CHANNEL_IAS_ACE) +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_ACE) class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): """Entity for ZHA alarm control devices.""" + _attr_name: str = "Alarm control panel" _attr_code_format = CodeFormat.TEXT _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME @@ -89,18 +90,20 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.TRIGGER ) - def __init__(self, unique_id, zha_device: ZHADevice, channels, **kwargs) -> None: + def __init__( + self, unique_id, zha_device: ZHADevice, cluster_handlers, **kwargs + ) -> None: """Initialize the ZHA alarm control device.""" - super().__init__(unique_id, zha_device, channels, **kwargs) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) cfg_entry = zha_device.gateway.config_entry - self._channel: AceChannel = channels[0] - self._channel.panel_code = async_get_zha_config_value( + self._cluster_handler: AceClusterHandler = cluster_handlers[0] + self._cluster_handler.panel_code = async_get_zha_config_value( cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_MASTER_CODE, "1234" ) - self._channel.code_required_arm_actions = async_get_zha_config_value( + self._cluster_handler.code_required_arm_actions = async_get_zha_config_value( cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_ARM_REQUIRES_CODE, False ) - self._channel.max_invalid_tries = async_get_zha_config_value( + self._cluster_handler.max_invalid_tries = async_get_zha_config_value( cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_FAILED_TRIES, 3 ) @@ -108,10 +111,10 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._channel, SIGNAL_ARMED_STATE_CHANGED, self.async_set_armed_mode + self._cluster_handler, SIGNAL_ARMED_STATE_CHANGED, self.async_set_armed_mode ) self.async_accept_signal( - self._channel, SIGNAL_ALARM_TRIGGERED, self.async_alarm_trigger + self._cluster_handler, SIGNAL_ALARM_TRIGGERED, self.async_alarm_trigger ) @callback @@ -122,26 +125,26 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): @property def code_arm_required(self) -> bool: """Whether the code is required for arm actions.""" - return self._channel.code_required_arm_actions + return self._cluster_handler.code_required_arm_actions async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - self._channel.arm(IasAce.ArmMode.Disarm, code, 0) + self._cluster_handler.arm(IasAce.ArmMode.Disarm, code, 0) self.async_write_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - self._channel.arm(IasAce.ArmMode.Arm_Day_Home_Only, code, 0) + self._cluster_handler.arm(IasAce.ArmMode.Arm_Day_Home_Only, code, 0) self.async_write_ha_state() async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - self._channel.arm(IasAce.ArmMode.Arm_All_Zones, code, 0) + self._cluster_handler.arm(IasAce.ArmMode.Arm_All_Zones, code, 0) self.async_write_ha_state() async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - self._channel.arm(IasAce.ArmMode.Arm_Night_Sleep_Only, code, 0) + self._cluster_handler.arm(IasAce.ArmMode.Arm_Night_Sleep_Only, code, 0) self.async_write_ha_state() async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -151,4 +154,4 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): @property def state(self) -> str | None: """Return the state of the entity.""" - return IAS_ACE_STATE_MAP.get(self._channel.armed_state) + return IAS_ACE_STATE_MAP.get(self._cluster_handler.armed_state) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index d0e04e0c16..652f19d24b 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -1,1549 +1,113 @@ -"""Web socket API for Zigbee Home Automation devices.""" +"""API for Zigbee Home Automation.""" + from __future__ import annotations -import asyncio -import logging -from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast +from typing import TYPE_CHECKING -import voluptuous as vol -import zigpy.backups from zigpy.backups import NetworkBackup -from zigpy.config.validators import cv_boolean -from zigpy.types.named import EUI64 -from zigpy.zcl.clusters.security import IasAce -import zigpy.zdo.types as zdo_types - -from homeassistant.components import websocket_api -from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME -from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.service import async_register_admin_service +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from .core.const import ( - ATTR_ARGS, - ATTR_ATTRIBUTE, - ATTR_CLUSTER_ID, - ATTR_CLUSTER_TYPE, - ATTR_COMMAND_TYPE, - ATTR_ENDPOINT_ID, - ATTR_IEEE, - ATTR_LEVEL, - ATTR_MANUFACTURER, - ATTR_MEMBERS, - ATTR_PARAMS, - ATTR_TYPE, - ATTR_VALUE, - ATTR_WARNING_DEVICE_DURATION, - ATTR_WARNING_DEVICE_MODE, - ATTR_WARNING_DEVICE_STROBE, - ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, - ATTR_WARNING_DEVICE_STROBE_INTENSITY, - BINDINGS, - CHANNEL_IAS_WD, - CLUSTER_COMMAND_SERVER, - CLUSTER_COMMANDS_CLIENT, - CLUSTER_COMMANDS_SERVER, - CLUSTER_TYPE_IN, - CLUSTER_TYPE_OUT, CONF_RADIO_TYPE, - CUSTOM_CONFIGURATION, DATA_ZHA, + DATA_ZHA_CONFIG, DATA_ZHA_GATEWAY, DOMAIN, - EZSP_OVERWRITE_EUI64, - GROUP_ID, - GROUP_IDS, - GROUP_NAME, - MFG_CLUSTER_ID_START, - WARNING_DEVICE_MODE_EMERGENCY, - WARNING_DEVICE_SOUND_HIGH, - WARNING_DEVICE_SQUAWK_MODE_ARMED, - WARNING_DEVICE_STROBE_HIGH, - WARNING_DEVICE_STROBE_YES, - ZHA_ALARM_OPTIONS, - ZHA_CHANNEL_MSG, - ZHA_CONFIG_SCHEMAS, -) -from .core.gateway import EntityReference -from .core.group import GroupMember -from .core.helpers import ( - async_cluster_exists, - async_is_bindable_target, - cluster_command_schema_to_vol_schema, - convert_install_code, - get_matched_clusters, - qr_to_install_code, + RadioType, ) +from .core.gateway import ZHAGateway if TYPE_CHECKING: - from homeassistant.components.websocket_api.connection import ActiveConnection - - from .core.device import ZHADevice - from .core.gateway import ZHAGateway - -_LOGGER = logging.getLogger(__name__) - -TYPE = "type" -CLIENT = "client" -ID = "id" -RESPONSE = "response" -DEVICE_INFO = "device_info" - -ATTR_DURATION = "duration" -ATTR_GROUP = "group" -ATTR_IEEE_ADDRESS = "ieee_address" -ATTR_INSTALL_CODE = "install_code" -ATTR_SOURCE_IEEE = "source_ieee" -ATTR_TARGET_IEEE = "target_ieee" -ATTR_QR_CODE = "qr_code" - -SERVICE_PERMIT = "permit" -SERVICE_REMOVE = "remove" -SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = "set_zigbee_cluster_attribute" -SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = "issue_zigbee_cluster_command" -SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND = "issue_zigbee_group_command" -SERVICE_DIRECT_ZIGBEE_BIND = "issue_direct_zigbee_bind" -SERVICE_DIRECT_ZIGBEE_UNBIND = "issue_direct_zigbee_unbind" -SERVICE_WARNING_DEVICE_SQUAWK = "warning_device_squawk" -SERVICE_WARNING_DEVICE_WARN = "warning_device_warn" -SERVICE_ZIGBEE_BIND = "service_zigbee_bind" -IEEE_SERVICE = "ieee_based_service" - -IEEE_SCHEMA = vol.All(cv.string, EUI64.convert) - -# typing typevar -_T = TypeVar("_T") + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant -def _ensure_list_if_present(value: _T | None) -> list[_T] | list[Any] | None: - """Wrap value in list if it is provided and not one.""" - if value is None: - return None - return cast("list[_T]", value) if isinstance(value, list) else [value] +def _get_gateway(hass: HomeAssistant) -> ZHAGateway: + """Get a reference to the ZHA gateway device.""" + return hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] -SERVICE_PERMIT_PARAMS = { - vol.Optional(ATTR_IEEE): IEEE_SCHEMA, - vol.Optional(ATTR_DURATION, default=60): vol.All( - vol.Coerce(int), vol.Range(0, 254) - ), - vol.Inclusive(ATTR_SOURCE_IEEE, "install_code"): IEEE_SCHEMA, - vol.Inclusive(ATTR_INSTALL_CODE, "install_code"): vol.All( - cv.string, convert_install_code - ), - vol.Exclusive(ATTR_QR_CODE, "install_code"): vol.All(cv.string, qr_to_install_code), -} +def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Find the singleton ZHA config entry, if one exists.""" -SERVICE_SCHEMAS = { - SERVICE_PERMIT: vol.Schema( - vol.All( - cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), - SERVICE_PERMIT_PARAMS, - ) - ), - IEEE_SERVICE: vol.Schema( - vol.All( - cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), - {vol.Required(ATTR_IEEE): IEEE_SCHEMA}, - ) - ), - SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - vol.Required(ATTR_ATTRIBUTE): vol.Any(cv.positive_int, str), - vol.Required(ATTR_VALUE): vol.Any(int, cv.boolean, cv.string), - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } - ), - SERVICE_WARNING_DEVICE_SQUAWK: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Optional( - ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_SQUAWK_MODE_ARMED - ): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES - ): cv.positive_int, - vol.Optional( - ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH - ): cv.positive_int, - } - ), - SERVICE_WARNING_DEVICE_WARN: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Optional( - ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_MODE_EMERGENCY - ): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES - ): cv.positive_int, - vol.Optional( - ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH - ): cv.positive_int, - vol.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 - ): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE_INTENSITY, default=WARNING_DEVICE_STROBE_HIGH - ): cv.positive_int, - } - ), - SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.All( - vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - vol.Required(ATTR_COMMAND): cv.positive_int, - vol.Required(ATTR_COMMAND_TYPE): cv.string, - vol.Exclusive(ATTR_ARGS, "attrs_params"): _ensure_list_if_present, - vol.Exclusive(ATTR_PARAMS, "attrs_params"): dict, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } - ), - cv.deprecated(ATTR_ARGS), - cv.has_at_least_one_key(ATTR_ARGS, ATTR_PARAMS), - ), - SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND: vol.Schema( - { - vol.Required(ATTR_GROUP): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - vol.Required(ATTR_COMMAND): cv.positive_int, - vol.Optional(ATTR_ARGS, default=[]): cv.ensure_list, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } - ), -} + # If ZHA is already running, use its config entry + try: + zha_gateway = _get_gateway(hass) + except KeyError: + pass + else: + return zha_gateway.config_entry + + # Otherwise, find one + entries = hass.config_entries.async_entries(DOMAIN) + + if len(entries) != 1: + raise ValueError(f"Invalid number of ZHA config entries: {entries!r}") + + return entries[0] -class ClusterBinding(NamedTuple): - """Describes a cluster binding.""" +def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: + """Get the network settings for the currently active ZHA network.""" + zha_gateway: ZHAGateway = _get_gateway(hass) + app = zha_gateway.application_controller - name: str - type: str - id: int - endpoint_id: int - - -def _cv_group_member(value: dict[str, Any]) -> GroupMember: - """Transform a group member.""" - return GroupMember( - ieee=value[ATTR_IEEE], - endpoint_id=value[ATTR_ENDPOINT_ID], + return NetworkBackup( + node_info=app.state.node_info, + network_info=app.state.network_info, ) -def _cv_cluster_binding(value: dict[str, Any]) -> ClusterBinding: - """Transform a cluster binding.""" - return ClusterBinding( - name=value[ATTR_NAME], - type=value[ATTR_TYPE], - id=value[ATTR_ID], - endpoint_id=value[ATTR_ENDPOINT_ID], - ) +async def async_get_last_network_settings( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> NetworkBackup | None: + """Get the network settings for the last-active ZHA network.""" + if config_entry is None: + config_entry = _get_config_entry(hass) + config = hass.data.get(DATA_ZHA, {}).get(DATA_ZHA_CONFIG, {}) + zha_gateway = ZHAGateway(hass, config, config_entry) -def _cv_zigpy_network_backup(value: dict[str, Any]) -> zigpy.backups.NetworkBackup: - """Transform a zigpy network backup.""" + app_controller_cls, app_config = zha_gateway.get_application_controller_data() + app = app_controller_cls(app_config) try: - return zigpy.backups.NetworkBackup.from_dict(value) - except ValueError as err: - raise vol.Invalid(str(err)) from err + await app._load_db() # pylint: disable=protected-access + settings = max(app.backups, key=lambda b: b.backup_time) + except ValueError: + settings = None + finally: + await app.shutdown() + return settings -GROUP_MEMBER_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), - } - ), - _cv_group_member, -) +async def async_get_network_settings( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> NetworkBackup | None: + """Get ZHA network settings, preferring the active settings if ZHA is running.""" -CLUSTER_BINDING_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(ATTR_NAME): cv.string, - vol.Required(ATTR_TYPE): cv.string, - vol.Required(ATTR_ID): vol.Coerce(int), - vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), - } - ), - _cv_cluster_binding, -) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required("type"): "zha/devices/permit", - **SERVICE_PERMIT_PARAMS, - } -) -@websocket_api.async_response -async def websocket_permit_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Permit ZHA zigbee devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - duration: int = msg[ATTR_DURATION] - ieee: EUI64 | None = msg.get(ATTR_IEEE) - - async def forward_messages(data): - """Forward events to websocket.""" - connection.send_message(websocket_api.event_message(msg["id"], data)) - - remove_dispatcher_function = async_dispatcher_connect( - hass, "zha_gateway_message", forward_messages - ) - - @callback - def async_cleanup() -> None: - """Remove signal listener and turn off debug mode.""" - zha_gateway.async_disable_debug_mode() - remove_dispatcher_function() - - connection.subscriptions[msg["id"]] = async_cleanup - zha_gateway.async_enable_debug_mode() - src_ieee: EUI64 - code: bytes - if ATTR_SOURCE_IEEE in msg: - src_ieee = msg[ATTR_SOURCE_IEEE] - code = msg[ATTR_INSTALL_CODE] - _LOGGER.debug("Allowing join for %s device with install code", src_ieee) - await zha_gateway.application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - elif ATTR_QR_CODE in msg: - src_ieee, code = msg[ATTR_QR_CODE] - _LOGGER.debug("Allowing join for %s device with install code", src_ieee) - await zha_gateway.application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - else: - await zha_gateway.application_controller.permit(time_s=duration, node=ieee) - connection.send_result(msg[ID]) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices"}) -@websocket_api.async_response -async def websocket_get_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - devices = [device.zha_device_info for device in zha_gateway.devices.values()] - connection.send_result(msg[ID], devices) - - -@callback -def _get_entity_name( - zha_gateway: ZHAGateway, entity_ref: EntityReference -) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) - return entry.name if entry else None - - -@callback -def _get_entity_original_name( - zha_gateway: ZHAGateway, entity_ref: EntityReference -) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) - return entry.original_name if entry else None - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices/groupable"}) -@websocket_api.async_response -async def websocket_get_groupable_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA devices that can be grouped.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - - devices = [device for device in zha_gateway.devices.values() if device.is_groupable] - groupable_devices = [] - - for device in devices: - entity_refs = zha_gateway.device_registry[device.ieee] - for ep_id in device.async_get_groupable_endpoints(): - groupable_devices.append( - { - "endpoint_id": ep_id, - "entities": [ - { - "name": _get_entity_name(zha_gateway, entity_ref), - "original_name": _get_entity_original_name( - zha_gateway, entity_ref - ), - } - for entity_ref in entity_refs - if list(entity_ref.cluster_channels.values())[ - 0 - ].cluster.endpoint.endpoint_id - == ep_id - ], - "device": device.zha_device_info, - } - ) - - connection.send_result(msg[ID], groupable_devices) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/groups"}) -@websocket_api.async_response -async def websocket_get_groups( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - groups = [group.group_info for group in zha_gateway.groups.values()] - connection.send_result(msg[ID], groups) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/device", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_get_device( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - - if not (zha_device := zha_gateway.devices.get(ieee)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Device not found" - ) - ) - return - - device_info = zha_device.zha_device_info - connection.send_result(msg[ID], device_info) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group", - vol.Required(GROUP_ID): cv.positive_int, - } -) -@websocket_api.async_response -async def websocket_get_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id: int = msg[GROUP_ID] - - if not (zha_group := zha_gateway.groups.get(group_id)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" - ) - ) - return - - group_info = zha_group.group_info - connection.send_result(msg[ID], group_info) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/add", - vol.Required(GROUP_NAME): cv.string, - vol.Optional(GROUP_ID): cv.positive_int, - vol.Optional(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_add_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Add a new ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_name: str = msg[GROUP_NAME] - group_id: int | None = msg.get(GROUP_ID) - members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) - group = await zha_gateway.async_create_zigpy_group(group_name, members, group_id) - assert group - connection.send_result(msg[ID], group.group_info) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/remove", - vol.Required(GROUP_IDS): vol.All(cv.ensure_list, [cv.positive_int]), - } -) -@websocket_api.async_response -async def websocket_remove_groups( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Remove the specified ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_ids: list[int] = msg[GROUP_IDS] - - if len(group_ids) > 1: - tasks = [] - for group_id in group_ids: - tasks.append(zha_gateway.async_remove_zigpy_group(group_id)) - await asyncio.gather(*tasks) - else: - await zha_gateway.async_remove_zigpy_group(group_ids[0]) - ret_groups = [group.group_info for group in zha_gateway.groups.values()] - connection.send_result(msg[ID], ret_groups) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/members/add", - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_add_group_members( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Add members to a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id: int = msg[GROUP_ID] - members: list[GroupMember] = msg[ATTR_MEMBERS] - - if not (zha_group := zha_gateway.groups.get(group_id)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" - ) - ) - return - - await zha_group.async_add_members(members) - ret_group = zha_group.group_info - connection.send_result(msg[ID], ret_group) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/members/remove", - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_remove_group_members( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Remove members from a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id: int = msg[GROUP_ID] - members: list[GroupMember] = msg[ATTR_MEMBERS] - - if not (zha_group := zha_gateway.groups.get(group_id)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" - ) - ) - return - - await zha_group.async_remove_members(members) - ret_group = zha_group.group_info - connection.send_result(msg[ID], ret_group) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/reconfigure", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_reconfigure_node( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Reconfigure a ZHA nodes entities by its ieee address.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - device: ZHADevice | None = zha_gateway.get_device(ieee) - - async def forward_messages(data): - """Forward events to websocket.""" - connection.send_message(websocket_api.event_message(msg["id"], data)) - - remove_dispatcher_function = async_dispatcher_connect( - hass, ZHA_CHANNEL_MSG, forward_messages - ) - - @callback - def async_cleanup() -> None: - """Remove signal listener.""" - remove_dispatcher_function() - - connection.subscriptions[msg["id"]] = async_cleanup - - _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) - assert device - hass.async_create_task(device.async_configure()) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/topology/update", - } -) -@websocket_api.async_response -async def websocket_update_topology( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Update the ZHA network topology.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - hass.async_create_task(zha_gateway.application_controller.topology.scan()) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_device_clusters( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a list of device clusters.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - zha_device = zha_gateway.get_device(ieee) - response_clusters = [] - if zha_device is not None: - clusters_by_endpoint = zha_device.async_get_clusters() - for ep_id, clusters in clusters_by_endpoint.items(): - for c_id, cluster in clusters[CLUSTER_TYPE_IN].items(): - response_clusters.append( - { - TYPE: CLUSTER_TYPE_IN, - ID: c_id, - ATTR_NAME: cluster.__class__.__name__, - "endpoint_id": ep_id, - } - ) - for c_id, cluster in clusters[CLUSTER_TYPE_OUT].items(): - response_clusters.append( - { - TYPE: CLUSTER_TYPE_OUT, - ID: c_id, - ATTR_NAME: cluster.__class__.__name__, - "endpoint_id": ep_id, - } - ) - - connection.send_result(msg[ID], response_clusters) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters/attributes", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - } -) -@websocket_api.async_response -async def websocket_device_cluster_attributes( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a list of cluster attributes.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - endpoint_id: int = msg[ATTR_ENDPOINT_ID] - cluster_id: int = msg[ATTR_CLUSTER_ID] - cluster_type: str = msg[ATTR_CLUSTER_TYPE] - cluster_attributes: list[dict[str, Any]] = [] - zha_device = zha_gateway.get_device(ieee) - attributes = None - if zha_device is not None: - attributes = zha_device.async_get_cluster_attributes( - endpoint_id, cluster_id, cluster_type - ) - if attributes is not None: - for attr_id, attr in attributes.items(): - cluster_attributes.append({ID: attr_id, ATTR_NAME: attr.name}) - _LOGGER.debug( - "Requested attributes for: %s: %s, %s: '%s', %s: %s, %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - RESPONSE, - cluster_attributes, - ) - - connection.send_result(msg[ID], cluster_attributes) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters/commands", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - } -) -@websocket_api.async_response -async def websocket_device_cluster_commands( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a list of cluster commands.""" - import voluptuous_serialize # pylint: disable=import-outside-toplevel - - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - endpoint_id: int = msg[ATTR_ENDPOINT_ID] - cluster_id: int = msg[ATTR_CLUSTER_ID] - cluster_type: str = msg[ATTR_CLUSTER_TYPE] - zha_device = zha_gateway.get_device(ieee) - cluster_commands: list[dict[str, Any]] = [] - commands = None - if zha_device is not None: - commands = zha_device.async_get_cluster_commands( - endpoint_id, cluster_id, cluster_type - ) - - if commands is not None: - for cmd_id, cmd in commands[CLUSTER_COMMANDS_CLIENT].items(): - cluster_commands.append( - { - TYPE: CLIENT, - ID: cmd_id, - ATTR_NAME: cmd.name, - "schema": voluptuous_serialize.convert( - cluster_command_schema_to_vol_schema(cmd.schema), - custom_serializer=cv.custom_serializer, - ), - } - ) - for cmd_id, cmd in commands[CLUSTER_COMMANDS_SERVER].items(): - cluster_commands.append( - { - TYPE: CLUSTER_COMMAND_SERVER, - ID: cmd_id, - ATTR_NAME: cmd.name, - "schema": voluptuous_serialize.convert( - cluster_command_schema_to_vol_schema(cmd.schema), - custom_serializer=cv.custom_serializer, - ), - } - ) - _LOGGER.debug( - "Requested commands for: %s: %s, %s: '%s', %s: %s, %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - RESPONSE, - cluster_commands, - ) - - connection.send_result(msg[ID], cluster_commands) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters/attributes/value", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - vol.Required(ATTR_ATTRIBUTE): int, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } -) -@websocket_api.async_response -async def websocket_read_zigbee_cluster_attributes( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Read zigbee attribute for cluster on ZHA entity.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - endpoint_id: int = msg[ATTR_ENDPOINT_ID] - cluster_id: int = msg[ATTR_CLUSTER_ID] - cluster_type: str = msg[ATTR_CLUSTER_TYPE] - attribute: int = msg[ATTR_ATTRIBUTE] - manufacturer: int | None = msg.get(ATTR_MANUFACTURER) - zha_device = zha_gateway.get_device(ieee) - success = {} - failure = {} - if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code - cluster = zha_device.async_get_cluster( - endpoint_id, cluster_id, cluster_type=cluster_type - ) - success, failure = await cluster.read_attributes( - [attribute], allow_cache=False, only_cache=False, manufacturer=manufacturer - ) - _LOGGER.debug( - ( - "Read attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" - " %s: [%s]," - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_ATTRIBUTE, - attribute, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - str(success.get(attribute)), - "failure", - failure, - ) - connection.send_result(msg[ID], str(success.get(attribute))) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/bindable", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_get_bindable_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_IEEE] - source_device = zha_gateway.get_device(source_ieee) - - devices = [ - device.zha_device_info - for device in zha_gateway.devices.values() - if async_is_bindable_target(source_device, device) - ] - - _LOGGER.debug( - "Get bindable devices: %s: [%s], %s: [%s]", - ATTR_SOURCE_IEEE, - source_ieee, - "bindable devices", - devices, - ) - - connection.send_message(websocket_api.result_message(msg[ID], devices)) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/bind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_bind_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] - await async_binding_operation( - zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Bind_req - ) - _LOGGER.info( - "Devices bound: %s: [%s] %s: [%s]", - ATTR_SOURCE_IEEE, - source_ieee, - ATTR_TARGET_IEEE, - target_ieee, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/unbind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_unbind_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Remove a direct binding between devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] - await async_binding_operation( - zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Unbind_req - ) - _LOGGER.info( - "Devices un-bound: %s: [%s] %s: [%s]", - ATTR_SOURCE_IEEE, - source_ieee, - ATTR_TARGET_IEEE, - target_ieee, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/groups/bind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_bind_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Directly bind a device to a group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - group_id: int = msg[GROUP_ID] - bindings: list[ClusterBinding] = msg[BINDINGS] - source_device = zha_gateway.get_device(source_ieee) - assert source_device - await source_device.async_bind_to_group(group_id, bindings) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/groups/unbind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_unbind_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Unbind a device from a group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - group_id: int = msg[GROUP_ID] - bindings: list[ClusterBinding] = msg[BINDINGS] - source_device = zha_gateway.get_device(source_ieee) - assert source_device - await source_device.async_unbind_from_group(group_id, bindings) - - -async def async_binding_operation( - zha_gateway: ZHAGateway, - source_ieee: EUI64, - target_ieee: EUI64, - operation: zdo_types.ZDOCmd, -) -> None: - """Create or remove a direct zigbee binding between 2 devices.""" - - source_device = zha_gateway.get_device(source_ieee) - target_device = zha_gateway.get_device(target_ieee) - - assert source_device - assert target_device - clusters_to_bind = await get_matched_clusters(source_device, target_device) - - zdo = source_device.device.zdo - bind_tasks = [] - for binding_pair in clusters_to_bind: - op_msg = "cluster: %s %s --> [%s]" - op_params = ( - binding_pair.source_cluster.cluster_id, - operation.name, - target_ieee, - ) - zdo.debug(f"processing {op_msg}", *op_params) - - bind_tasks.append( - ( - zdo.request( - operation, - source_device.ieee, - binding_pair.source_cluster.endpoint.endpoint_id, - binding_pair.source_cluster.cluster_id, - binding_pair.destination_address, - ), - op_msg, - op_params, - ) - ) - res = await asyncio.gather(*(t[0] for t in bind_tasks), return_exceptions=True) - for outcome, log_msg in zip(res, bind_tasks): - if isinstance(outcome, Exception): - fmt = f"{log_msg[1]} failed: %s" - else: - fmt = f"{log_msg[1]} completed: %s" - zdo.debug(fmt, *(log_msg[2] + (outcome,))) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/configuration"}) -@websocket_api.async_response -async def websocket_get_configuration( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - import voluptuous_serialize # pylint: disable=import-outside-toplevel - - def custom_serializer(schema: Any) -> Any: - """Serialize additional types for voluptuous_serialize.""" - if schema is cv_boolean: - return {"type": "bool"} - if schema is vol.Schema: - return voluptuous_serialize.convert( - schema, custom_serializer=custom_serializer - ) - - return cv.custom_serializer(schema) - - data: dict[str, dict[str, Any]] = {"schemas": {}, "data": {}} - for section, schema in ZHA_CONFIG_SCHEMAS.items(): - if section == ZHA_ALARM_OPTIONS and not async_cluster_exists( - hass, IasAce.cluster_id - ): - continue - data["schemas"][section] = voluptuous_serialize.convert( - schema, custom_serializer=custom_serializer - ) - data["data"][section] = zha_gateway.config_entry.options.get( - CUSTOM_CONFIGURATION, {} - ).get(section, {}) - - # send default values for unconfigured options - for entry in data["schemas"][section]: - if data["data"][section].get(entry["name"]) is None: - data["data"][section][entry["name"]] = entry["default"] - - connection.send_result(msg[ID], data) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/configuration/update", - vol.Required("data"): ZHA_CONFIG_SCHEMAS, - } -) -@websocket_api.async_response -async def websocket_update_zha_configuration( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Update the ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - options = zha_gateway.config_entry.options - data_to_save = {**options, **{CUSTOM_CONFIGURATION: msg["data"]}} - - for section, schema in ZHA_CONFIG_SCHEMAS.items(): - for entry in schema.schema: - # remove options that match defaults - if ( - data_to_save[CUSTOM_CONFIGURATION].get(section, {}).get(entry) - == entry.default() - ): - data_to_save[CUSTOM_CONFIGURATION][section].pop(entry) - # remove entire section block if empty - if ( - not data_to_save[CUSTOM_CONFIGURATION].get(section) - and section in data_to_save[CUSTOM_CONFIGURATION] - ): - data_to_save[CUSTOM_CONFIGURATION].pop(section) - - # remove entire custom_configuration block if empty - if ( - not data_to_save.get(CUSTOM_CONFIGURATION) - and CUSTOM_CONFIGURATION in data_to_save - ): - data_to_save.pop(CUSTOM_CONFIGURATION) - - _LOGGER.info( - "Updating ZHA custom configuration options from %s to %s", - options, - data_to_save, - ) - - hass.config_entries.async_update_entry( - zha_gateway.config_entry, options=data_to_save - ) - status = await hass.config_entries.async_reload(zha_gateway.config_entry.entry_id) - connection.send_result(msg[ID], status) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/settings"}) -@websocket_api.async_response -async def websocket_get_network_settings( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA network settings.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - # Serialize the current network settings - backup = NetworkBackup( - node_info=application_controller.state.node_info, - network_info=application_controller.state.network_info, - ) - - connection.send_result( - msg[ID], - { - "radio_type": zha_gateway.config_entry.data[CONF_RADIO_TYPE], - "settings": backup.as_dict(), - }, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/list"}) -@websocket_api.async_response -async def websocket_list_network_backups( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA network settings.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - # Serialize known backups - connection.send_result( - msg[ID], [backup.as_dict() for backup in application_controller.backups] - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/create"}) -@websocket_api.async_response -async def websocket_create_network_backup( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Create a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - # This can take 5-30s - backup = await application_controller.backups.create_backup(load_devices=True) - connection.send_result( - msg[ID], - { - "backup": backup.as_dict(), - "is_complete": backup.is_complete(), - }, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/network/backups/restore", - vol.Required("backup"): _cv_zigpy_network_backup, - vol.Optional("ezsp_force_write_eui64", default=False): cv.boolean, - } -) -@websocket_api.async_response -async def websocket_restore_network_backup( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Restore a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - backup = msg["backup"] - - if msg["ezsp_force_write_eui64"]: - backup.network_info.stack_specific.setdefault("ezsp", {})[ - EZSP_OVERWRITE_EUI64 - ] = True - - # This can take 30-40s try: - await application_controller.backups.restore_backup(backup) - except ValueError as err: - connection.send_error(msg[ID], websocket_api.const.ERR_INVALID_FORMAT, str(err)) - else: - connection.send_result(msg[ID]) + return async_get_active_network_settings(hass) + except KeyError: + return await async_get_last_network_settings(hass, config_entry) -@callback -def async_load_api(hass: HomeAssistant) -> None: - """Set up the web socket API.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller +def async_get_radio_type( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> RadioType: + """Get ZHA radio type.""" + if config_entry is None: + config_entry = _get_config_entry(hass) - async def permit(service: ServiceCall) -> None: - """Allow devices to join this network.""" - duration: int = service.data[ATTR_DURATION] - ieee: EUI64 | None = service.data.get(ATTR_IEEE) - src_ieee: EUI64 - code: bytes - if ATTR_SOURCE_IEEE in service.data: - src_ieee = service.data[ATTR_SOURCE_IEEE] - code = service.data[ATTR_INSTALL_CODE] - _LOGGER.info("Allowing join for %s device with install code", src_ieee) - await application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - return - - if ATTR_QR_CODE in service.data: - src_ieee, code = service.data[ATTR_QR_CODE] - _LOGGER.info("Allowing join for %s device with install code", src_ieee) - await application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - return - - if ieee: - _LOGGER.info("Permitting joins for %ss on %s device", duration, ieee) - else: - _LOGGER.info("Permitting joins for %ss", duration) - await application_controller.permit(time_s=duration, node=ieee) - - async_register_admin_service( - hass, DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT] - ) - - async def remove(service: ServiceCall) -> None: - """Remove a node from the network.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = service.data[ATTR_IEEE] - zha_device: ZHADevice | None = zha_gateway.get_device(ieee) - if zha_device is not None and zha_device.is_active_coordinator: - _LOGGER.info("Removing the coordinator (%s) is not allowed", ieee) - return - _LOGGER.info("Removing node %s", ieee) - await application_controller.remove(ieee) - - async_register_admin_service( - hass, DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[IEEE_SERVICE] - ) - - async def set_zigbee_cluster_attributes(service: ServiceCall) -> None: - """Set zigbee attribute for cluster on zha entity.""" - ieee: EUI64 = service.data[ATTR_IEEE] - endpoint_id: int = service.data[ATTR_ENDPOINT_ID] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - cluster_type: str = service.data[ATTR_CLUSTER_TYPE] - attribute: int | str = service.data[ATTR_ATTRIBUTE] - value: int | bool | str = service.data[ATTR_VALUE] - manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) - zha_device = zha_gateway.get_device(ieee) - response = None - if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code - response = await zha_device.write_zigbee_attribute( - endpoint_id, - cluster_id, - attribute, - value, - cluster_type=cluster_type, - manufacturer=manufacturer, - ) - _LOGGER.debug( - ( - "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" - " [%s] %s: [%s]" - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_ATTRIBUTE, - attribute, - ATTR_VALUE, - value, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - response, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, - set_zigbee_cluster_attributes, - schema=SERVICE_SCHEMAS[SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE], - ) - - async def issue_zigbee_cluster_command(service: ServiceCall) -> None: - """Issue command on zigbee cluster on ZHA entity.""" - ieee: EUI64 = service.data[ATTR_IEEE] - endpoint_id: int = service.data[ATTR_ENDPOINT_ID] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - cluster_type: str = service.data[ATTR_CLUSTER_TYPE] - command: int = service.data[ATTR_COMMAND] - command_type: str = service.data[ATTR_COMMAND_TYPE] - args: list | None = service.data.get(ATTR_ARGS) - params: dict | None = service.data.get(ATTR_PARAMS) - manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) - zha_device = zha_gateway.get_device(ieee) - if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code - - await zha_device.issue_cluster_command( - endpoint_id, - cluster_id, - command, - command_type, - args, - params, - cluster_type=cluster_type, - manufacturer=manufacturer, - ) - _LOGGER.debug( - ( - "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" - " %s: [%s] %s: [%s] %s: [%s]" - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_COMMAND, - command, - ATTR_COMMAND_TYPE, - command_type, - ATTR_ARGS, - args, - ATTR_PARAMS, - params, - ATTR_MANUFACTURER, - manufacturer, - ) - else: - raise ValueError(f"Device with IEEE {str(ieee)} not found") - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, - issue_zigbee_cluster_command, - schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND], - ) - - async def issue_zigbee_group_command(service: ServiceCall) -> None: - """Issue command on zigbee cluster on a zigbee group.""" - group_id: int = service.data[ATTR_GROUP] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - command: int = service.data[ATTR_COMMAND] - args: list = service.data[ATTR_ARGS] - manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) - group = zha_gateway.get_group(group_id) - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - _LOGGER.error("Missing manufacturer attribute for cluster: %d", cluster_id) - response = None - if group is not None: - cluster = group.endpoint[cluster_id] - response = await cluster.command( - command, *args, manufacturer=manufacturer, expect_reply=True - ) - _LOGGER.debug( - "Issued group command for: %s: [%s] %s: [%s] %s: %s %s: [%s] %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_COMMAND, - command, - ATTR_ARGS, - args, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - response, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, - issue_zigbee_group_command, - schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], - ) - - def _get_ias_wd_channel(zha_device): - """Get the IASWD channel for a device.""" - cluster_channels = { - ch.name: ch - for pool in zha_device.channels.pools - for ch in pool.claimed_channels.values() - } - return cluster_channels.get(CHANNEL_IAS_WD) - - async def warning_device_squawk(service: ServiceCall) -> None: - """Issue the squawk command for an IAS warning device.""" - ieee: EUI64 = service.data[ATTR_IEEE] - mode: int = service.data[ATTR_WARNING_DEVICE_MODE] - strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] - level: int = service.data[ATTR_LEVEL] - - if (zha_device := zha_gateway.get_device(ieee)) is not None: - if channel := _get_ias_wd_channel(zha_device): - await channel.issue_squawk(mode, strobe, level) - else: - _LOGGER.error( - "Squawking IASWD: %s: [%s] is missing the required IASWD channel!", - ATTR_IEEE, - str(ieee), - ) - else: - _LOGGER.error( - "Squawking IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) - ) - _LOGGER.debug( - "Squawking IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", - ATTR_IEEE, - str(ieee), - ATTR_WARNING_DEVICE_MODE, - mode, - ATTR_WARNING_DEVICE_STROBE, - strobe, - ATTR_LEVEL, - level, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_WARNING_DEVICE_SQUAWK, - warning_device_squawk, - schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_SQUAWK], - ) - - async def warning_device_warn(service: ServiceCall) -> None: - """Issue the warning command for an IAS warning device.""" - ieee: EUI64 = service.data[ATTR_IEEE] - mode: int = service.data[ATTR_WARNING_DEVICE_MODE] - strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] - level: int = service.data[ATTR_LEVEL] - duration: int = service.data[ATTR_WARNING_DEVICE_DURATION] - duty_mode: int = service.data[ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE] - intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY] - - if (zha_device := zha_gateway.get_device(ieee)) is not None: - if channel := _get_ias_wd_channel(zha_device): - await channel.issue_start_warning( - mode, strobe, level, duration, duty_mode, intensity - ) - else: - _LOGGER.error( - "Warning IASWD: %s: [%s] is missing the required IASWD channel!", - ATTR_IEEE, - str(ieee), - ) - else: - _LOGGER.error( - "Warning IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) - ) - _LOGGER.debug( - "Warning IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", - ATTR_IEEE, - str(ieee), - ATTR_WARNING_DEVICE_MODE, - mode, - ATTR_WARNING_DEVICE_STROBE, - strobe, - ATTR_LEVEL, - level, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_WARNING_DEVICE_WARN, - warning_device_warn, - schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_WARN], - ) - - websocket_api.async_register_command(hass, websocket_permit_devices) - websocket_api.async_register_command(hass, websocket_get_devices) - websocket_api.async_register_command(hass, websocket_get_groupable_devices) - websocket_api.async_register_command(hass, websocket_get_groups) - websocket_api.async_register_command(hass, websocket_get_device) - websocket_api.async_register_command(hass, websocket_get_group) - websocket_api.async_register_command(hass, websocket_add_group) - websocket_api.async_register_command(hass, websocket_remove_groups) - websocket_api.async_register_command(hass, websocket_add_group_members) - websocket_api.async_register_command(hass, websocket_remove_group_members) - websocket_api.async_register_command(hass, websocket_bind_group) - websocket_api.async_register_command(hass, websocket_unbind_group) - websocket_api.async_register_command(hass, websocket_reconfigure_node) - websocket_api.async_register_command(hass, websocket_device_clusters) - websocket_api.async_register_command(hass, websocket_device_cluster_attributes) - websocket_api.async_register_command(hass, websocket_device_cluster_commands) - websocket_api.async_register_command(hass, websocket_read_zigbee_cluster_attributes) - websocket_api.async_register_command(hass, websocket_get_bindable_devices) - websocket_api.async_register_command(hass, websocket_bind_devices) - websocket_api.async_register_command(hass, websocket_unbind_devices) - websocket_api.async_register_command(hass, websocket_update_topology) - websocket_api.async_register_command(hass, websocket_get_configuration) - websocket_api.async_register_command(hass, websocket_update_zha_configuration) - websocket_api.async_register_command(hass, websocket_get_network_settings) - websocket_api.async_register_command(hass, websocket_list_network_backups) - websocket_api.async_register_command(hass, websocket_create_network_backup) - websocket_api.async_register_command(hass, websocket_restore_network_backup) + return RadioType[config_entry.data[CONF_RADIO_TYPE]] -@callback -def async_unload_api(hass: HomeAssistant) -> None: - """Unload the ZHA API.""" - hass.services.async_remove(DOMAIN, SERVICE_PERMIT) - hass.services.async_remove(DOMAIN, SERVICE_REMOVE) - hass.services.async_remove(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE) - hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND) - hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND) - hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_SQUAWK) - hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_WARN) +def async_get_radio_path( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> str: + """Get ZHA radio path.""" + if config_entry is None: + config_entry = _get_config_entry(hass) + + return config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index b6a0af8e45..6b080db081 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -2,24 +2,29 @@ from __future__ import annotations import functools +from typing import Any + +import zigpy.types as t +from zigpy.zcl.clusters.general import OnOff +from zigpy.zcl.clusters.security import IasZone from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_ON, Platform +from homeassistant.const import STATE_ON, EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( - CHANNEL_ACCELEROMETER, - CHANNEL_BINARY_INPUT, - CHANNEL_OCCUPANCY, - CHANNEL_ON_OFF, - CHANNEL_ZONE, + CLUSTER_HANDLER_ACCELEROMETER, + CLUSTER_HANDLER_BINARY_INPUT, + CLUSTER_HANDLER_OCCUPANCY, + CLUSTER_HANDLER_ON_OFF, + CLUSTER_HANDLER_ZONE, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -28,17 +33,29 @@ from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity # Zigbee Cluster Library Zone Type to Home Assistant device class -CLASS_MAPPING = { - 0x000D: BinarySensorDeviceClass.MOTION, - 0x0015: BinarySensorDeviceClass.OPENING, - 0x0028: BinarySensorDeviceClass.SMOKE, - 0x002A: BinarySensorDeviceClass.MOISTURE, - 0x002B: BinarySensorDeviceClass.GAS, - 0x002D: BinarySensorDeviceClass.VIBRATION, +IAS_ZONE_CLASS_MAPPING = { + IasZone.ZoneType.Motion_Sensor: BinarySensorDeviceClass.MOTION, + IasZone.ZoneType.Contact_Switch: BinarySensorDeviceClass.OPENING, + IasZone.ZoneType.Fire_Sensor: BinarySensorDeviceClass.SMOKE, + IasZone.ZoneType.Water_Sensor: BinarySensorDeviceClass.MOISTURE, + IasZone.ZoneType.Carbon_Monoxide_Sensor: BinarySensorDeviceClass.GAS, + IasZone.ZoneType.Vibration_Movement_Sensor: BinarySensorDeviceClass.VIBRATION, +} + +IAS_ZONE_NAME_MAPPING = { + IasZone.ZoneType.Motion_Sensor: "Motion", + IasZone.ZoneType.Contact_Switch: "Opening", + IasZone.ZoneType.Fire_Sensor: "Smoke", + IasZone.ZoneType.Water_Sensor: "Moisture", + IasZone.ZoneType.Carbon_Monoxide_Sensor: "Gas", + IasZone.ZoneType.Vibration_Movement_Sensor: "Vibration", } STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.BINARY_SENSOR) MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.BINARY_SENSOR) +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + ZHA_ENTITIES.config_diagnostic_match, Platform.BINARY_SENSOR +) async def async_setup_entry( @@ -64,119 +81,157 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): SENSOR_ATTR: str | None = None - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize the ZHA binary sensor.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel = channels[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cluster_handler = cluster_handlers[0] async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) - @callback - def async_restore_last_state(self, last_state): - """Restore previous state.""" - super().async_restore_last_state(last_state) - self._state = last_state.state == STATE_ON - @property def is_on(self) -> bool: """Return True if the switch is on based on the state machine.""" - if self._state is None: + raw_state = self._cluster_handler.cluster.get(self.SENSOR_ATTR) + if raw_state is None: return False - return self._state + return self.parse(raw_state) @callback def async_set_state(self, attr_id, attr_name, value): """Set the state.""" - if self.SENSOR_ATTR is None or attr_name != self.SENSOR_ATTR: - return - self._state = bool(value) self.async_write_ha_state() - async def async_update(self) -> None: - """Attempt to retrieve on off state from the binary sensor.""" - await super().async_update() - attribute = getattr(self._channel, "value_attribute", "on_off") - attr_value = await self._channel.get_attribute_value(attribute) - if attr_value is not None: - self._state = attr_value + @staticmethod + def parse(value: bool | int) -> bool: + """Parse the raw attribute into a bool state.""" + return bool(value) -@MULTI_MATCH(channel_names=CHANNEL_ACCELEROMETER) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ACCELEROMETER) class Accelerometer(BinarySensor): """ZHA BinarySensor.""" SENSOR_ATTR = "acceleration" + _attr_name: str = "Accelerometer" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.MOVING -@MULTI_MATCH(channel_names=CHANNEL_OCCUPANCY) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY) class Occupancy(BinarySensor): """ZHA BinarySensor.""" SENSOR_ATTR = "occupancy" + _attr_name: str = "Occupancy" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OCCUPANCY -@STRICT_MATCH(channel_names=CHANNEL_ON_OFF) +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) class Opening(BinarySensor): - """ZHA BinarySensor.""" + """ZHA OnOff BinarySensor.""" SENSOR_ATTR = "on_off" + _attr_name: str = "Opening" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OPENING + # Client/out cluster attributes aren't stored in the zigpy database, but are properly stored in the runtime cache. + # We need to manually restore the last state from the sensor state to the runtime cache for now. + @callback + def async_restore_last_state(self, last_state): + """Restore previous state to zigpy cache.""" + self._cluster_handler.cluster.update_attribute( + OnOff.attributes_by_name[self.SENSOR_ATTR].id, + t.Bool.true if last_state.state == STATE_ON else t.Bool.false, + ) -@MULTI_MATCH(channel_names=CHANNEL_BINARY_INPUT) + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BINARY_INPUT) class BinaryInput(BinarySensor): """ZHA BinarySensor.""" SENSOR_ATTR = "present_value" + _attr_name: str = "Binary input" @STRICT_MATCH( - channel_names=CHANNEL_ON_OFF, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, manufacturers="IKEA of Sweden", models=lambda model: isinstance(model, str) and model is not None and model.find("motion") != -1, ) @STRICT_MATCH( - channel_names=CHANNEL_ON_OFF, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, manufacturers="Philips", models={"SML001", "SML002"}, ) -class Motion(BinarySensor): - """ZHA BinarySensor.""" +class Motion(Opening): + """ZHA OnOff BinarySensor with motion device class.""" - SENSOR_ATTR = "on_off" + _attr_name: str = "Motion" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.MOTION -@MULTI_MATCH(channel_names=CHANNEL_ZONE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ZONE) class IASZone(BinarySensor): """ZHA IAS BinarySensor.""" SENSOR_ATTR = "zone_status" + @property + def name(self) -> str | None: + """Return the name of the sensor.""" + zone_type = self._cluster_handler.cluster.get("zone_type") + return IAS_ZONE_NAME_MAPPING.get(zone_type, "iaszone") + @property def device_class(self) -> BinarySensorDeviceClass | None: """Return device class from component DEVICE_CLASSES.""" - return CLASS_MAPPING.get(self._channel.cluster.get("zone_type")) + zone_type = self._cluster_handler.cluster.get("zone_type") + return IAS_ZONE_CLASS_MAPPING.get(zone_type) - async def async_update(self) -> None: - """Attempt to retrieve on off state from the binary sensor.""" - await super().async_update() - value = await self._channel.get_attribute_value("zone_status") - if value is not None: - self._state = value & 3 + @staticmethod + def parse(value: bool | int) -> bool: + """Parse the raw attribute into a bool state.""" + return BinarySensor.parse(value & 3) # use only bit 0 and 1 for alarm state + + # temporary code to migrate old IasZone sensors to update attribute cache state once + # remove in 2024.4.0 + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return state attributes.""" + return {"migrated_to_cache": True} # writing new state means we're migrated + + # temporary migration code + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + # trigger migration if extra state attribute is not present + if "migrated_to_cache" not in last_state.attributes: + self.migrate_to_zigpy_cache(last_state) + + # temporary migration code + @callback + def migrate_to_zigpy_cache(self, last_state): + """Save old IasZone sensor state to attribute cache.""" + # previous HA versions did not update the attribute cache for IasZone sensors, so do it once here + # a HA state write is triggered shortly afterwards and writes the "migrated_to_cache" extra state attribute + if last_state.state == STATE_ON: + migrated_state = IasZone.ZoneStatus.Alarm_1 + else: + migrated_state = IasZone.ZoneStatus(0) + + self._cluster_handler.cluster.update_attribute( + IasZone.attributes_by_name[self.SENSOR_ATTR].id, migrated_state + ) @MULTI_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_htnnfasr", }, @@ -186,17 +241,19 @@ class FrostLock(BinarySensor, id_suffix="frost_lock"): SENSOR_ATTR = "frost_lock" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.LOCK + _attr_name: str = "Frost lock" -@MULTI_MATCH(channel_names="ikea_airpurifier") +@MULTI_MATCH(cluster_handler_names="ikea_airpurifier") class ReplaceFilter(BinarySensor, id_suffix="replace_filter"): """ZHA BinarySensor.""" SENSOR_ATTR = "replace_filter" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM + _attr_name: str = "Replace filter" -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederErrorDetected(BinarySensor, id_suffix="error_detected"): """ZHA aqara pet feeder error detected binary sensor.""" @@ -206,7 +263,8 @@ class AqaraPetFeederErrorDetected(BinarySensor, id_suffix="error_detected"): @MULTI_MATCH( - channel_names="opple_cluster", models={"lumi.plug.mmeu01", "lumi.plug.maeu01"} + cluster_handler_names="opple_cluster", + models={"lumi.plug.mmeu01", "lumi.plug.maeu01"}, ) class XiaomiPlugConsumerConnected(BinarySensor, id_suffix="consumer_connected"): """ZHA Xiaomi plug consumer connected binary sensor.""" @@ -214,3 +272,52 @@ class XiaomiPlugConsumerConnected(BinarySensor, id_suffix="consumer_connected"): SENSOR_ATTR = "consumer_connected" _attr_name: str = "Consumer connected" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PLUG + + +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatWindowOpen(BinarySensor, id_suffix="window_open"): + """ZHA Aqara thermostat window open binary sensor.""" + + SENSOR_ATTR = "window_open" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.WINDOW + _attr_name: str = "Window open" + + +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatValveAlarm(BinarySensor, id_suffix="valve_alarm"): + """ZHA Aqara thermostat valve alarm binary sensor.""" + + SENSOR_ATTR = "valve_alarm" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM + _attr_name: str = "Valve alarm" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) +class AqaraThermostatCalibrated(BinarySensor, id_suffix="calibrated"): + """ZHA Aqara thermostat calibrated binary sensor.""" + + SENSOR_ATTR = "calibrated" + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + _attr_name: str = "Calibrated" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) +class AqaraThermostatExternalSensor(BinarySensor, id_suffix="sensor"): + """ZHA Aqara thermostat external sensor binary sensor.""" + + SENSOR_ATTR = "sensor" + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + _attr_name: str = "External sensor" + + +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) +class AqaraLinkageAlarmState(BinarySensor, id_suffix="linkage_alarm_state"): + """ZHA Aqara linkage alarm state binary sensor.""" + + SENSOR_ATTR = "linkage_alarm_state" + _attr_name: str = "Linkage alarm state" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.SMOKE diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 14547216dc..6564f3bc39 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -18,12 +18,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery -from .core.const import CHANNEL_IDENTIFY, DATA_ZHA, SIGNAL_ADD_ENTITIES +from .core.const import CLUSTER_HANDLER_IDENTIFY, DATA_ZHA, SIGNAL_ADD_ENTITIES from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice @@ -65,12 +65,12 @@ class ZHAButton(ZhaEntity, ButtonEntity): self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this button.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: ZigbeeChannel = channels[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cluster_handler: ClusterHandler = cluster_handlers[0] @abc.abstractmethod def get_args(self) -> list[Any]: @@ -78,12 +78,12 @@ class ZHAButton(ZhaEntity, ButtonEntity): async def async_press(self) -> None: """Send out a update command.""" - command = getattr(self._channel, self._command_name) + command = getattr(self._cluster_handler, self._command_name) arguments = self.get_args() await command(*arguments) -@MULTI_MATCH(channel_names=CHANNEL_IDENTIFY) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_IDENTIFY) class ZHAIdentifyButton(ZHAButton): """Defines a ZHA identify button.""" @@ -92,7 +92,7 @@ class ZHAIdentifyButton(ZHAButton): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -100,10 +100,10 @@ class ZHAIdentifyButton(ZHAButton): Return entity if it is a supported configuration, otherwise return None """ if ZHA_ENTITIES.prevent_entity_creation( - Platform.BUTTON, zha_device.ieee, CHANNEL_IDENTIFY + Platform.BUTTON, zha_device.ieee, CLUSTER_HANDLER_IDENTIFY ): return None - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) _attr_device_class: ButtonDeviceClass = ButtonDeviceClass.UPDATE _attr_entity_category = EntityCategory.DIAGNOSTIC @@ -126,17 +126,17 @@ class ZHAAttributeButton(ZhaEntity, ButtonEntity): self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this button.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: ZigbeeChannel = channels[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cluster_handler: ClusterHandler = cluster_handlers[0] async def async_press(self) -> None: """Write attribute with defined value.""" try: - result = await self._channel.cluster.write_attributes( + result = await self._cluster_handler.cluster.write_attributes( {self._attribute_name: self._attribute_value} ) except zigpy.exceptions.ZigbeeException as ex: @@ -149,7 +149,7 @@ class ZHAAttributeButton(ZhaEntity, ButtonEntity): @CONFIG_DIAGNOSTIC_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_htnnfasr", }, @@ -164,7 +164,9 @@ class FrostLockResetButton(ZHAAttributeButton, id_suffix="reset_frost_lock"): _attr_entity_category = EntityCategory.CONFIG -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac01"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} +) class NoPresenceStatusResetButton( ZHAAttributeButton, id_suffix="reset_no_presence_status" ): @@ -177,10 +179,22 @@ class NoPresenceStatusResetButton( _attr_entity_category = EntityCategory.CONFIG -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederFeedButton(ZHAAttributeButton, id_suffix="feeding"): """Defines a feed button for the aqara c1 pet feeder.""" _attribute_name = "feeding" _attr_name = "Feed" _attribute_value = 1 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraSelfTestButton(ZHAAttributeButton, id_suffix="self_test"): + """Defines a ZHA self-test button for Aqara smoke sensors.""" + + _attribute_name = "self_test" + _attr_name = "Self-test" + _attribute_value = 1 + _attr_entity_category = EntityCategory.CONFIG diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index 022be309c4..9f999bd52f 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -43,8 +43,8 @@ import homeassistant.util.dt as dt_util from .core import discovery from .core.const import ( - CHANNEL_FAN, - CHANNEL_THERMOSTAT, + CLUSTER_HANDLER_FAN, + CLUSTER_HANDLER_THERMOSTAT, DATA_ZHA, PRESET_COMPLEX, PRESET_SCHEDULE, @@ -127,9 +127,9 @@ async def async_setup_entry( @MULTI_MATCH( - channel_names=CHANNEL_THERMOSTAT, - aux_channels=CHANNEL_FAN, - stop_on_match_group=CHANNEL_THERMOSTAT, + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + aux_cluster_handlers=CLUSTER_HANDLER_FAN, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) class Thermostat(ZhaEntity, ClimateEntity): """Representation of a ZHA Thermostat device.""" @@ -139,15 +139,16 @@ class Thermostat(ZhaEntity, ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_name: str = "Thermostat" - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize ZHA Thermostat instance.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._thrm = self.cluster_channels.get(CHANNEL_THERMOSTAT) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._thrm = self.cluster_handlers.get(CLUSTER_HANDLER_THERMOSTAT) self._preset = PRESET_NONE self._presets = [] self._supported_flags = ClimateEntityFeature.TARGET_TEMPERATURE - self._fan = self.cluster_channels.get(CHANNEL_FAN) + self._fan = self.cluster_handlers.get(CLUSTER_HANDLER_FAN) @property def current_temperature(self): @@ -480,9 +481,9 @@ class Thermostat(ZhaEntity, ClimateEntity): @MULTI_MATCH( - channel_names={CHANNEL_THERMOSTAT, "sinope_manufacturer_specific"}, + cluster_handler_names={CLUSTER_HANDLER_THERMOSTAT, "sinope_manufacturer_specific"}, manufacturers="Sinope Technologies", - stop_on_match_group=CHANNEL_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) class SinopeTechnologiesThermostat(Thermostat): """Sinope Technologies Thermostat.""" @@ -490,12 +491,12 @@ class SinopeTechnologiesThermostat(Thermostat): manufacturer = 0x119C update_time_interval = timedelta(minutes=randint(45, 75)) - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize ZHA Thermostat instance.""" - super().__init__(unique_id, zha_device, channels, **kwargs) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._presets = [PRESET_AWAY, PRESET_NONE] self._supported_flags |= ClimateEntityFeature.PRESET_MODE - self._manufacturer_ch = self.cluster_channels["sinope_manufacturer_specific"] + self._manufacturer_ch = self.cluster_handlers["sinope_manufacturer_specific"] @property def _rm_rs_action(self) -> HVACAction: @@ -536,8 +537,10 @@ class SinopeTechnologiesThermostat(Thermostat): async def async_added_to_hass(self) -> None: """Run when about to be added to Hass.""" await super().async_added_to_hass() - async_track_time_interval( - self.hass, self._async_update_time, self.update_time_interval + self.async_on_remove( + async_track_time_interval( + self.hass, self._async_update_time, self.update_time_interval + ) ) self._async_update_time() @@ -553,28 +556,28 @@ class SinopeTechnologiesThermostat(Thermostat): @MULTI_MATCH( - channel_names=CHANNEL_THERMOSTAT, - aux_channels=CHANNEL_FAN, + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + aux_cluster_handlers=CLUSTER_HANDLER_FAN, manufacturers={"Zen Within", "LUX"}, - stop_on_match_group=CHANNEL_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) class ZenWithinThermostat(Thermostat): """Zen Within Thermostat implementation.""" @MULTI_MATCH( - channel_names=CHANNEL_THERMOSTAT, - aux_channels=CHANNEL_FAN, + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + aux_cluster_handlers=CLUSTER_HANDLER_FAN, manufacturers="Centralite", models={"3157100", "3157100-E"}, - stop_on_match_group=CHANNEL_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) class CentralitePearl(ZenWithinThermostat): """Centralite Pearl Thermostat implementation.""" @STRICT_MATCH( - channel_names=CHANNEL_THERMOSTAT, + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, manufacturers={ "_TZE200_ckud7u2l", "_TZE200_ywdxldoj", @@ -594,9 +597,9 @@ class CentralitePearl(ZenWithinThermostat): class MoesThermostat(Thermostat): """Moes Thermostat implementation.""" - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize ZHA Thermostat instance.""" - super().__init__(unique_id, zha_device, channels, **kwargs) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._presets = [ PRESET_NONE, PRESET_AWAY, @@ -668,7 +671,7 @@ class MoesThermostat(Thermostat): @STRICT_MATCH( - channel_names=CHANNEL_THERMOSTAT, + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, manufacturers={ "_TZE200_b6wax7g0", }, @@ -676,9 +679,9 @@ class MoesThermostat(Thermostat): class BecaThermostat(Thermostat): """Beca Thermostat implementation.""" - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize ZHA Thermostat instance.""" - super().__init__(unique_id, zha_device, channels, **kwargs) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._presets = [ PRESET_NONE, PRESET_AWAY, @@ -743,10 +746,10 @@ class BecaThermostat(Thermostat): @MULTI_MATCH( - channel_names=CHANNEL_THERMOSTAT, + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, manufacturers="Stelpro", models={"SORB"}, - stop_on_match_group=CHANNEL_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) class StelproFanHeater(Thermostat): """Stelpro Fan Heater implementation.""" @@ -758,7 +761,7 @@ class StelproFanHeater(Thermostat): @STRICT_MATCH( - channel_names=CHANNEL_THERMOSTAT, + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, manufacturers={ "_TZE200_7yoranx2", "_TZE200_e9ba97vf", # TV01-ZG @@ -780,9 +783,9 @@ class ZONNSMARTThermostat(Thermostat): PRESET_HOLIDAY = "holiday" PRESET_FROST = "frost protect" - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize ZHA Thermostat instance.""" - super().__init__(unique_id, zha_device, channels, **kwargs) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._presets = [ PRESET_NONE, self.PRESET_HOLIDAY, diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 05dc67314e..53c4e33881 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -101,7 +101,7 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: addon_port = ListPortInfo( - device=silabs_multiprotocol_addon.get_zigbee_socket(hass, addon_info), + device=silabs_multiprotocol_addon.get_zigbee_socket(), skip_link_detection=True, ) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py deleted file mode 100644 index a708e65a07..0000000000 --- a/homeassistant/components/zha/core/channels/__init__.py +++ /dev/null @@ -1,385 +0,0 @@ -"""Channels module for Zigbee Home Automation.""" -from __future__ import annotations - -import asyncio -from typing import TYPE_CHECKING, Any - -from typing_extensions import Self -import zigpy.endpoint -import zigpy.zcl.clusters.closures - -from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_send - -from . import ( # noqa: F401 - base, - closures, - general, - homeautomation, - hvac, - lighting, - lightlink, - manufacturerspecific, - measurement, - protocol, - security, - smartenergy, -) -from .. import ( - const, - device as zha_core_device, - discovery as zha_disc, - registries as zha_regs, -) - -if TYPE_CHECKING: - from ...entity import ZhaEntity - from ..device import ZHADevice - -_ChannelsDictType = dict[str, base.ZigbeeChannel] - - -class Channels: - """All discovered channels of a device.""" - - def __init__(self, zha_device: ZHADevice) -> None: - """Initialize instance.""" - self._pools: list[ChannelPool] = [] - self._power_config: base.ZigbeeChannel | None = None - self._identify: base.ZigbeeChannel | None = None - self._unique_id = str(zha_device.ieee) - self._zdo_channel = base.ZDOChannel(zha_device.device.endpoints[0], zha_device) - self._zha_device = zha_device - - @property - def pools(self) -> list[ChannelPool]: - """Return channel pools list.""" - return self._pools - - @property - def power_configuration_ch(self) -> base.ZigbeeChannel | None: - """Return power configuration channel.""" - return self._power_config - - @power_configuration_ch.setter - def power_configuration_ch(self, channel: base.ZigbeeChannel) -> None: - """Power configuration channel setter.""" - if self._power_config is None: - self._power_config = channel - - @property - def identify_ch(self) -> base.ZigbeeChannel | None: - """Return power configuration channel.""" - return self._identify - - @identify_ch.setter - def identify_ch(self, channel: base.ZigbeeChannel) -> None: - """Power configuration channel setter.""" - if self._identify is None: - self._identify = channel - - @property - def zdo_channel(self) -> base.ZDOChannel: - """Return ZDO channel.""" - return self._zdo_channel - - @property - def zha_device(self) -> ZHADevice: - """Return parent ZHA device.""" - return self._zha_device - - @property - def unique_id(self) -> str: - """Return the unique id for this channel.""" - return self._unique_id - - @property - def zigbee_signature(self) -> dict[int, dict[str, Any]]: - """Get the zigbee signatures for the pools in channels.""" - return { - signature[0]: signature[1] - for signature in [pool.zigbee_signature for pool in self.pools] - } - - @classmethod - def new(cls, zha_device: ZHADevice) -> Self: - """Create new instance.""" - channels = cls(zha_device) - for ep_id in sorted(zha_device.device.endpoints): - channels.add_pool(ep_id) - return channels - - def add_pool(self, ep_id: int) -> None: - """Add channels for a specific endpoint.""" - if ep_id == 0: - return - self._pools.append(ChannelPool.new(self, ep_id)) - - async def async_initialize(self, from_cache: bool = False) -> None: - """Initialize claimed channels.""" - await self.zdo_channel.async_initialize(from_cache) - self.zdo_channel.debug("'async_initialize' stage succeeded") - await asyncio.gather( - *(pool.async_initialize(from_cache) for pool in self.pools) - ) - - async def async_configure(self) -> None: - """Configure claimed channels.""" - await self.zdo_channel.async_configure() - self.zdo_channel.debug("'async_configure' stage succeeded") - await asyncio.gather(*(pool.async_configure() for pool in self.pools)) - async_dispatcher_send( - self.zha_device.hass, - const.ZHA_CHANNEL_MSG, - { - const.ATTR_TYPE: const.ZHA_CHANNEL_CFG_DONE, - }, - ) - - @callback - def async_new_entity( - self, - component: str, - entity_class: type[ZhaEntity], - unique_id: str, - channels: list[base.ZigbeeChannel], - ): - """Signal new entity addition.""" - if self.zha_device.status == zha_core_device.DeviceStatus.INITIALIZED: - return - - self.zha_device.hass.data[const.DATA_ZHA][component].append( - (entity_class, (unique_id, self.zha_device, channels)) - ) - - @callback - def async_send_signal(self, signal: str, *args: Any) -> None: - """Send a signal through hass dispatcher.""" - async_dispatcher_send(self.zha_device.hass, signal, *args) - - @callback - def zha_send_event(self, event_data: dict[str, str | int]) -> None: - """Relay events to hass.""" - self.zha_device.hass.bus.async_fire( - const.ZHA_EVENT, - { - const.ATTR_DEVICE_IEEE: str(self.zha_device.ieee), - const.ATTR_UNIQUE_ID: self.unique_id, - ATTR_DEVICE_ID: self.zha_device.device_id, - **event_data, - }, - ) - - -class ChannelPool: - """All channels of an endpoint.""" - - def __init__(self, channels: Channels, ep_id: int) -> None: - """Initialize instance.""" - self._all_channels: _ChannelsDictType = {} - self._channels = channels - self._claimed_channels: _ChannelsDictType = {} - self._id = ep_id - self._client_channels: dict[str, base.ClientChannel] = {} - self._unique_id = f"{channels.unique_id}-{ep_id}" - - @property - def all_channels(self) -> _ChannelsDictType: - """All server channels of an endpoint.""" - return self._all_channels - - @property - def claimed_channels(self) -> _ChannelsDictType: - """Channels in use.""" - return self._claimed_channels - - @property - def client_channels(self) -> dict[str, base.ClientChannel]: - """Return a dict of client channels.""" - return self._client_channels - - @property - def endpoint(self) -> zigpy.endpoint.Endpoint: - """Return endpoint of zigpy device.""" - return self._channels.zha_device.device.endpoints[self.id] - - @property - def id(self) -> int: - """Return endpoint id.""" - return self._id - - @property - def nwk(self) -> int: - """Device NWK for logging.""" - return self._channels.zha_device.nwk - - @property - def is_mains_powered(self) -> bool | None: - """Device is_mains_powered.""" - return self._channels.zha_device.is_mains_powered - - @property - def manufacturer(self) -> str: - """Return device manufacturer.""" - return self._channels.zha_device.manufacturer - - @property - def manufacturer_code(self) -> int | None: - """Return device manufacturer.""" - return self._channels.zha_device.manufacturer_code - - @property - def hass(self) -> HomeAssistant: - """Return hass.""" - return self._channels.zha_device.hass - - @property - def model(self) -> str: - """Return device model.""" - return self._channels.zha_device.model - - @property - def quirk_class(self) -> str: - """Return device quirk class.""" - return self._channels.zha_device.quirk_class - - @property - def skip_configuration(self) -> bool: - """Return True if device does not require channel configuration.""" - return self._channels.zha_device.skip_configuration - - @property - def unique_id(self) -> str: - """Return the unique id for this channel.""" - return self._unique_id - - @property - def zigbee_signature(self) -> tuple[int, dict[str, Any]]: - """Get the zigbee signature for the endpoint this pool represents.""" - return ( - self.endpoint.endpoint_id, - { - const.ATTR_PROFILE_ID: self.endpoint.profile_id, - const.ATTR_DEVICE_TYPE: f"0x{self.endpoint.device_type:04x}" - if self.endpoint.device_type is not None - else "", - const.ATTR_IN_CLUSTERS: [ - f"0x{cluster_id:04x}" - for cluster_id in sorted(self.endpoint.in_clusters) - ], - const.ATTR_OUT_CLUSTERS: [ - f"0x{cluster_id:04x}" - for cluster_id in sorted(self.endpoint.out_clusters) - ], - }, - ) - - @classmethod - def new(cls, channels: Channels, ep_id: int) -> Self: - """Create new channels for an endpoint.""" - pool = cls(channels, ep_id) - pool.add_all_channels() - pool.add_client_channels() - if not channels.zha_device.is_coordinator: - zha_disc.PROBE.discover_entities(pool) - return pool - - @callback - def add_all_channels(self) -> None: - """Create and add channels for all input clusters.""" - for cluster_id, cluster in self.endpoint.in_clusters.items(): - channel_class = zha_regs.ZIGBEE_CHANNEL_REGISTRY.get( - cluster_id, base.ZigbeeChannel - ) - # really ugly hack to deal with xiaomi using the door lock cluster - # incorrectly. - if ( - hasattr(cluster, "ep_attribute") - and cluster_id == zigpy.zcl.clusters.closures.DoorLock.cluster_id - and cluster.ep_attribute == "multistate_input" - ): - channel_class = general.MultistateInput - # end of ugly hack - channel = channel_class(cluster, self) - if channel.name == const.CHANNEL_POWER_CONFIGURATION: - if ( - self._channels.power_configuration_ch - or self._channels.zha_device.is_mains_powered - ): - # on power configuration channel per device - continue - self._channels.power_configuration_ch = channel - elif channel.name == const.CHANNEL_IDENTIFY: - self._channels.identify_ch = channel - - self.all_channels[channel.id] = channel - - @callback - def add_client_channels(self) -> None: - """Create client channels for all output clusters if in the registry.""" - for cluster_id, channel_class in zha_regs.CLIENT_CHANNELS_REGISTRY.items(): - cluster = self.endpoint.out_clusters.get(cluster_id) - if cluster is not None: - channel = channel_class(cluster, self) - self.client_channels[channel.id] = channel - - async def async_initialize(self, from_cache: bool = False) -> None: - """Initialize claimed channels.""" - await self._execute_channel_tasks("async_initialize", from_cache) - - async def async_configure(self) -> None: - """Configure claimed channels.""" - await self._execute_channel_tasks("async_configure") - - async def _execute_channel_tasks(self, func_name: str, *args: Any) -> None: - """Add a throttled channel task and swallow exceptions.""" - channels = [*self.claimed_channels.values(), *self.client_channels.values()] - tasks = [getattr(ch, func_name)(*args) for ch in channels] - results = await asyncio.gather(*tasks, return_exceptions=True) - for channel, outcome in zip(channels, results): - if isinstance(outcome, Exception): - channel.warning( - "'%s' stage failed: %s", func_name, str(outcome), exc_info=outcome - ) - continue - channel.debug("'%s' stage succeeded", func_name) - - @callback - def async_new_entity( - self, - component: str, - entity_class: type[ZhaEntity], - unique_id: str, - channels: list[base.ZigbeeChannel], - ): - """Signal new entity addition.""" - self._channels.async_new_entity(component, entity_class, unique_id, channels) - - @callback - def async_send_signal(self, signal: str, *args: Any) -> None: - """Send a signal through hass dispatcher.""" - self._channels.async_send_signal(signal, *args) - - @callback - def claim_channels(self, channels: list[base.ZigbeeChannel]) -> None: - """Claim a channel.""" - self.claimed_channels.update({ch.id: ch for ch in channels}) - - @callback - def unclaimed_channels(self) -> list[base.ZigbeeChannel]: - """Return a list of available (unclaimed) channels.""" - claimed = set(self.claimed_channels) - available = set(self.all_channels) - return [self.all_channels[chan_id] for chan_id in (available - claimed)] - - @callback - def zha_send_event(self, event_data: dict[str, Any]) -> None: - """Relay events to hass.""" - self._channels.zha_send_event( - { - const.ATTR_UNIQUE_ID: self.unique_id, - const.ATTR_ENDPOINT_ID: self.id, - **event_data, - } - ) diff --git a/homeassistant/components/zha/core/channels/helpers.py b/homeassistant/components/zha/core/channels/helpers.py deleted file mode 100644 index 2297af312e..0000000000 --- a/homeassistant/components/zha/core/channels/helpers.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Helpers for use with ZHA Zigbee channels.""" -from .base import ZigbeeChannel - - -def is_hue_motion_sensor(channel: ZigbeeChannel) -> bool: - """Return true if the manufacturer and model match known Hue motion sensor models.""" - return channel.cluster.endpoint.manufacturer in ( - "Philips", - "Signify Netherlands B.V.", - ) and channel.cluster.endpoint.model in ( - "SML001", - "SML002", - "SML003", - "SML004", - ) diff --git a/homeassistant/components/zha/core/channels/protocol.py b/homeassistant/components/zha/core/channels/protocol.py deleted file mode 100644 index 51d837a801..0000000000 --- a/homeassistant/components/zha/core/channels/protocol.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Protocol channels module for Zigbee Home Automation.""" -from zigpy.zcl.clusters import protocol - -from .. import registries -from .base import ZigbeeChannel - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.AnalogInputExtended.cluster_id) -class AnalogInputExtended(ZigbeeChannel): - """Analog Input Extended channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.AnalogInputRegular.cluster_id) -class AnalogInputRegular(ZigbeeChannel): - """Analog Input Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.AnalogOutputExtended.cluster_id) -class AnalogOutputExtended(ZigbeeChannel): - """Analog Output Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.AnalogOutputRegular.cluster_id) -class AnalogOutputRegular(ZigbeeChannel): - """Analog Output Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.AnalogValueExtended.cluster_id) -class AnalogValueExtended(ZigbeeChannel): - """Analog Value Extended edition channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.AnalogValueRegular.cluster_id) -class AnalogValueRegular(ZigbeeChannel): - """Analog Value Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.BacnetProtocolTunnel.cluster_id) -class BacnetProtocolTunnel(ZigbeeChannel): - """Bacnet Protocol Tunnel channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.BinaryInputExtended.cluster_id) -class BinaryInputExtended(ZigbeeChannel): - """Binary Input Extended channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.BinaryInputRegular.cluster_id) -class BinaryInputRegular(ZigbeeChannel): - """Binary Input Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.BinaryOutputExtended.cluster_id) -class BinaryOutputExtended(ZigbeeChannel): - """Binary Output Extended channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.BinaryOutputRegular.cluster_id) -class BinaryOutputRegular(ZigbeeChannel): - """Binary Output Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.BinaryValueExtended.cluster_id) -class BinaryValueExtended(ZigbeeChannel): - """Binary Value Extended channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.BinaryValueRegular.cluster_id) -class BinaryValueRegular(ZigbeeChannel): - """Binary Value Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.GenericTunnel.cluster_id) -class GenericTunnel(ZigbeeChannel): - """Generic Tunnel channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register( - protocol.MultistateInputExtended.cluster_id -) -class MultiStateInputExtended(ZigbeeChannel): - """Multistate Input Extended channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.MultistateInputRegular.cluster_id) -class MultiStateInputRegular(ZigbeeChannel): - """Multistate Input Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register( - protocol.MultistateOutputExtended.cluster_id -) -class MultiStateOutputExtended(ZigbeeChannel): - """Multistate Output Extended channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register( - protocol.MultistateOutputRegular.cluster_id -) -class MultiStateOutputRegular(ZigbeeChannel): - """Multistate Output Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register( - protocol.MultistateValueExtended.cluster_id -) -class MultiStateValueExtended(ZigbeeChannel): - """Multistate Value Extended channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.MultistateValueRegular.cluster_id) -class MultiStateValueRegular(ZigbeeChannel): - """Multistate Value Regular channel.""" diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py similarity index 70% rename from homeassistant/components/zha/core/channels/base.py rename to homeassistant/components/zha/core/cluster_handlers/__init__.py index ae5980cd63..b3ec6b828e 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -1,4 +1,4 @@ -"""Base classes for channels.""" +"""Cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations import asyncio @@ -29,19 +29,19 @@ from ..const import ( ATTR_TYPE, ATTR_UNIQUE_ID, ATTR_VALUE, - CHANNEL_ZDO, + CLUSTER_HANDLER_ZDO, REPORT_CONFIG_ATTR_PER_REQ, SIGNAL_ATTR_UPDATED, - ZHA_CHANNEL_MSG, - ZHA_CHANNEL_MSG_BIND, - ZHA_CHANNEL_MSG_CFG_RPT, - ZHA_CHANNEL_MSG_DATA, - ZHA_CHANNEL_READS_PER_REQ, + ZHA_CLUSTER_HANDLER_MSG, + ZHA_CLUSTER_HANDLER_MSG_BIND, + ZHA_CLUSTER_HANDLER_MSG_CFG_RPT, + ZHA_CLUSTER_HANDLER_MSG_DATA, + ZHA_CLUSTER_HANDLER_READS_PER_REQ, ) from ..helpers import LogMixin, retryable_req, safe_read if TYPE_CHECKING: - from . import ChannelPool + from ..endpoint import Endpoint _LOGGER = logging.getLogger(__name__) @@ -49,34 +49,38 @@ _LOGGER = logging.getLogger(__name__) class AttrReportConfig(TypedDict, total=True): """Configuration to report for the attributes.""" - # Could be either an attribute name or attribute id - attr: str | int + # An attribute name + attr: str # The config for the attribute reporting configuration consists of a tuple for # (minimum_reported_time_interval_s, maximum_reported_time_interval_s, value_delta) config: tuple[int, int, int | float] -def parse_and_log_command(channel, tsn, command_id, args): +def parse_and_log_command(cluster_handler, tsn, command_id, args): """Parse and log a zigbee cluster command.""" - cmd = channel.cluster.server_commands.get(command_id, [command_id])[0] - channel.debug( + try: + name = cluster_handler.cluster.server_commands[command_id].name + except KeyError: + name = f"0x{command_id:02X}" + + cluster_handler.debug( "received '%s' command with %s args on cluster_id '%s' tsn '%s'", - cmd, + name, args, - channel.cluster.cluster_id, + cluster_handler.cluster.cluster_id, tsn, ) - return cmd + return name -def decorate_command(channel, command): +def decorate_command(cluster_handler, command): """Wrap a cluster command to make it safe.""" @wraps(command) async def wrapper(*args, **kwds): try: result = await command(*args, **kwds) - channel.debug( + cluster_handler.debug( "executed '%s' command with args: '%s' kwargs: '%s' result: %s", command.__name__, args, @@ -86,7 +90,7 @@ def decorate_command(channel, command): return result except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: - channel.debug( + cluster_handler.debug( "command failed: '%s' args: '%s' kwargs '%s' exception: '%s'", command.__name__, args, @@ -98,65 +102,65 @@ def decorate_command(channel, command): return wrapper -class ChannelStatus(Enum): - """Status of a channel.""" +class ClusterHandlerStatus(Enum): + """Status of a cluster handler.""" CREATED = 1 CONFIGURED = 2 INITIALIZED = 3 -class ZigbeeChannel(LogMixin): - """Base channel for a Zigbee cluster.""" +class ClusterHandler(LogMixin): + """Base cluster handler for a Zigbee cluster.""" REPORT_CONFIG: tuple[AttrReportConfig, ...] = () BIND: bool = True - # Dict of attributes to read on channel initialization. + # Dict of attributes to read on cluster handler initialization. # Dict keys -- attribute ID or names, with bool value indicating whether a cached # attribute read is acceptable. ZCL_INIT_ATTRS: dict[int | str, bool] = {} - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize ZigbeeChannel.""" - self._generic_id = f"channel_0x{cluster.cluster_id:04x}" - self._ch_pool = ch_pool + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize ClusterHandler.""" + self._generic_id = f"cluster_handler_0x{cluster.cluster_id:04x}" + self._endpoint: Endpoint = endpoint self._cluster = cluster - self._id = f"{ch_pool.id}:0x{cluster.cluster_id:04x}" - unique_id = ch_pool.unique_id.replace("-", ":") + self._id = f"{endpoint.id}:0x{cluster.cluster_id:04x}" + unique_id = endpoint.unique_id.replace("-", ":") self._unique_id = f"{unique_id}:0x{cluster.cluster_id:04x}" if not hasattr(self, "_value_attribute") and self.REPORT_CONFIG: - attr = self.REPORT_CONFIG[0].get("attr") - if isinstance(attr, str): - attribute: ZCLAttributeDef = self.cluster.attributes_by_name.get(attr) - if attribute is not None: - self.value_attribute = attribute.id - else: - self.value_attribute = None - else: - self.value_attribute = attr - self._status = ChannelStatus.CREATED + attr_def: ZCLAttributeDef = self.cluster.attributes_by_name[ + self.REPORT_CONFIG[0]["attr"] + ] + self.value_attribute = attr_def.id + self._status = ClusterHandlerStatus.CREATED self._cluster.add_listener(self) self.data_cache: dict[str, Enum] = {} + @classmethod + def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: + """Filter the cluster match for specific devices.""" + return True + @property def id(self) -> str: - """Return channel id unique for this device only.""" + """Return cluster handler id unique for this device only.""" return self._id @property def generic_id(self): - """Return the generic id for this channel.""" + """Return the generic id for this cluster handler.""" return self._generic_id @property def unique_id(self): - """Return the unique id for this channel.""" + """Return the unique id for this cluster handler.""" return self._unique_id @property def cluster(self): - """Return the zigpy cluster for this channel.""" + """Return the zigpy cluster for this cluster handler.""" return self._cluster @property @@ -166,7 +170,7 @@ class ZigbeeChannel(LogMixin): @property def status(self): - """Return the status of the channel.""" + """Return the status of the cluster handler.""" return self._status def __hash__(self) -> int: @@ -176,7 +180,7 @@ class ZigbeeChannel(LogMixin): @callback def async_send_signal(self, signal: str, *args: Any) -> None: """Send a signal through hass dispatcher.""" - self._ch_pool.async_send_signal(signal, *args) + self._endpoint.async_send_signal(signal, *args) async def bind(self): """Bind a zigbee cluster. @@ -188,11 +192,11 @@ class ZigbeeChannel(LogMixin): res = await self.cluster.bind() self.debug("bound '%s' cluster: %s", self.cluster.ep_attribute, res[0]) async_dispatcher_send( - self._ch_pool.hass, - ZHA_CHANNEL_MSG, + self._endpoint.device.hass, + ZHA_CLUSTER_HANDLER_MSG, { - ATTR_TYPE: ZHA_CHANNEL_MSG_BIND, - ZHA_CHANNEL_MSG_DATA: { + ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_BIND, + ZHA_CLUSTER_HANDLER_MSG_DATA: { "cluster_name": self.cluster.name, "cluster_id": self.cluster.cluster_id, "success": res[0] == 0, @@ -201,14 +205,17 @@ class ZigbeeChannel(LogMixin): ) except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: self.debug( - "Failed to bind '%s' cluster: %s", self.cluster.ep_attribute, str(ex) + "Failed to bind '%s' cluster: %s", + self.cluster.ep_attribute, + str(ex), + exc_info=ex, ) async_dispatcher_send( - self._ch_pool.hass, - ZHA_CHANNEL_MSG, + self._endpoint.device.hass, + ZHA_CLUSTER_HANDLER_MSG, { - ATTR_TYPE: ZHA_CHANNEL_MSG_BIND, - ZHA_CHANNEL_MSG_DATA: { + ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_BIND, + ZHA_CLUSTER_HANDLER_MSG_DATA: { "cluster_name": self.cluster.name, "cluster_id": self.cluster.cluster_id, "success": False, @@ -224,12 +231,20 @@ class ZigbeeChannel(LogMixin): """ event_data = {} kwargs = {} - if self.cluster.cluster_id >= 0xFC00 and self._ch_pool.manufacturer_code: - kwargs["manufacturer"] = self._ch_pool.manufacturer_code + if ( + self.cluster.cluster_id >= 0xFC00 + and self._endpoint.device.manufacturer_code + ): + kwargs["manufacturer"] = self._endpoint.device.manufacturer_code for attr_report in self.REPORT_CONFIG: attr, config = attr_report["attr"], attr_report["config"] - attr_name = self.cluster.attributes.get(attr, [attr])[0] + + try: + attr_name = self.cluster.find_attribute(attr).name + except KeyError: + attr_name = attr + event_data[attr_name] = { "min": config[0], "max": config[1], @@ -265,11 +280,11 @@ class ZigbeeChannel(LogMixin): ) async_dispatcher_send( - self._ch_pool.hass, - ZHA_CHANNEL_MSG, + self._endpoint.device.hass, + ZHA_CLUSTER_HANDLER_MSG, { - ATTR_TYPE: ZHA_CHANNEL_MSG_CFG_RPT, - ZHA_CHANNEL_MSG_DATA: { + ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_CFG_RPT, + ZHA_CLUSTER_HANDLER_MSG_DATA: { "cluster_name": self.cluster.name, "cluster_id": self.cluster.cluster_id, "attributes": event_data, @@ -278,7 +293,7 @@ class ZigbeeChannel(LogMixin): ) def _configure_reporting_status( - self, attrs: dict[int | str, tuple[int, int, float | int]], res: list | tuple + self, attrs: dict[str, tuple[int, int, float | int]], res: list | tuple ) -> None: """Parse configure reporting result.""" if isinstance(res, (Exception, ConfigureReportingResponseRecord)): @@ -300,14 +315,13 @@ class ZigbeeChannel(LogMixin): return failed = [ - self.cluster.attributes.get(r.attrid, [r.attrid])[0] - for r in res - if r.status != Status.SUCCESS + self.cluster.find_attribute(record.attrid).name + for record in res + if record.status != Status.SUCCESS ] - attributes = {self.cluster.attributes.get(r, [r])[0] for r in attrs} self.debug( "Successfully configured reporting for '%s' on '%s' cluster", - attributes - set(failed), + set(attrs) - set(failed), self.name, ) self.debug( @@ -319,43 +333,45 @@ class ZigbeeChannel(LogMixin): async def async_configure(self) -> None: """Set cluster binding and attribute reporting.""" - if not self._ch_pool.skip_configuration: + if not self._endpoint.device.skip_configuration: if self.BIND: self.debug("Performing cluster binding") await self.bind() if self.cluster.is_server: self.debug("Configuring cluster attribute reporting") await self.configure_reporting() - ch_specific_cfg = getattr(self, "async_configure_channel_specific", None) + ch_specific_cfg = getattr( + self, "async_configure_cluster_handler_specific", None + ) if ch_specific_cfg: - self.debug("Performing channel specific configuration") + self.debug("Performing cluster handler specific configuration") await ch_specific_cfg() - self.debug("finished channel configuration") + self.debug("finished cluster handler configuration") else: - self.debug("skipping channel configuration") - self._status = ChannelStatus.CONFIGURED + self.debug("skipping cluster handler configuration") + self._status = ClusterHandlerStatus.CONFIGURED @retryable_req(delays=(1, 1, 3)) async def async_initialize(self, from_cache: bool) -> None: - """Initialize channel.""" - if not from_cache and self._ch_pool.skip_configuration: - self.debug("Skipping channel initialization") - self._status = ChannelStatus.INITIALIZED + """Initialize cluster handler.""" + if not from_cache and self._endpoint.device.skip_configuration: + self.debug("Skipping cluster handler initialization") + self._status = ClusterHandlerStatus.INITIALIZED return - self.debug("initializing channel: from_cache: %s", from_cache) + self.debug("initializing cluster handler: from_cache: %s", from_cache) cached = [a for a, cached in self.ZCL_INIT_ATTRS.items() if cached] uncached = [a for a, cached in self.ZCL_INIT_ATTRS.items() if not cached] uncached.extend([cfg["attr"] for cfg in self.REPORT_CONFIG]) if cached: - self.debug("initializing cached channel attributes: %s", cached) + self.debug("initializing cached cluster handler attributes: %s", cached) await self._get_attributes( True, cached, from_cache=True, only_cache=from_cache ) if uncached: self.debug( - "initializing uncached channel attributes: %s - from cache[%s]", + "initializing uncached cluster handler attributes: %s - from cache[%s]", uncached, from_cache, ) @@ -363,13 +379,17 @@ class ZigbeeChannel(LogMixin): True, uncached, from_cache=from_cache, only_cache=from_cache ) - ch_specific_init = getattr(self, "async_initialize_channel_specific", None) + ch_specific_init = getattr( + self, "async_initialize_cluster_handler_specific", None + ) if ch_specific_init: - self.debug("Performing channel specific initialization: %s", uncached) + self.debug( + "Performing cluster handler specific initialization: %s", uncached + ) await ch_specific_init(from_cache=from_cache) - self.debug("finished channel initialization") - self._status = ChannelStatus.INITIALIZED + self.debug("finished cluster handler initialization") + self._status = ClusterHandlerStatus.INITIALIZED @callback def cluster_command(self, tsn, command_id, args): @@ -404,13 +424,13 @@ class ZigbeeChannel(LogMixin): else: raise TypeError(f"Unexpected zha_send_event {command!r} argument: {arg!r}") - self._ch_pool.zha_send_event( + self._endpoint.device.zha_send_event( { ATTR_UNIQUE_ID: self.unique_id, ATTR_CLUSTER_ID: self.cluster.cluster_id, ATTR_COMMAND: command, # Maintain backwards compatibility with the old zigpy response format - ATTR_ARGS: args, + ATTR_ARGS: args, # type: ignore[dict-item] ATTR_PARAMS: params, } ) @@ -427,7 +447,7 @@ class ZigbeeChannel(LogMixin): async def get_attribute_value(self, attribute, from_cache=True): """Get the value for an attribute.""" manufacturer = None - manufacturer_code = self._ch_pool.manufacturer_code + manufacturer_code = self._endpoint.device.manufacturer_code if self.cluster.cluster_id >= 0xFC00 and manufacturer_code: manufacturer = manufacturer_code result = await safe_read( @@ -448,11 +468,11 @@ class ZigbeeChannel(LogMixin): ) -> dict[int | str, Any]: """Get the values for a list of attributes.""" manufacturer = None - manufacturer_code = self._ch_pool.manufacturer_code + manufacturer_code = self._endpoint.device.manufacturer_code if self.cluster.cluster_id >= 0xFC00 and manufacturer_code: manufacturer = manufacturer_code - chunk = attributes[:ZHA_CHANNEL_READS_PER_REQ] - rest = attributes[ZHA_CHANNEL_READS_PER_REQ:] + chunk = attributes[:ZHA_CLUSTER_HANDLER_READS_PER_REQ] + rest = attributes[ZHA_CLUSTER_HANDLER_READS_PER_REQ:] result = {} while chunk: try: @@ -473,8 +493,8 @@ class ZigbeeChannel(LogMixin): ) if raise_exceptions: raise - chunk = rest[:ZHA_CHANNEL_READS_PER_REQ] - rest = rest[ZHA_CHANNEL_READS_PER_REQ:] + chunk = rest[:ZHA_CLUSTER_HANDLER_READS_PER_REQ] + rest = rest[ZHA_CLUSTER_HANDLER_READS_PER_REQ:] return result get_attributes = partialmethod(_get_attributes, False) @@ -482,7 +502,7 @@ class ZigbeeChannel(LogMixin): def log(self, level, msg, *args, **kwargs): """Log a message.""" msg = f"[%s:%s]: {msg}" - args = (self._ch_pool.nwk, self._id) + args + args = (self._endpoint.device.nwk, self._id) + args _LOGGER.log(level, msg, *args, **kwargs) def __getattr__(self, name): @@ -494,31 +514,31 @@ class ZigbeeChannel(LogMixin): return self.__getattribute__(name) -class ZDOChannel(LogMixin): - """Channel for ZDO events.""" +class ZDOClusterHandler(LogMixin): + """Cluster handler for ZDO events.""" - def __init__(self, cluster, device): - """Initialize ZDOChannel.""" - self.name = CHANNEL_ZDO - self._cluster = cluster + def __init__(self, device): + """Initialize ZDOClusterHandler.""" + self.name = CLUSTER_HANDLER_ZDO + self._cluster = device.device.endpoints[0] self._zha_device = device - self._status = ChannelStatus.CREATED + self._status = ClusterHandlerStatus.CREATED self._unique_id = f"{str(device.ieee)}:{device.name}_ZDO" self._cluster.add_listener(self) @property def unique_id(self): - """Return the unique id for this channel.""" + """Return the unique id for this cluster handler.""" return self._unique_id @property def cluster(self): - """Return the aigpy cluster for this channel.""" + """Return the aigpy cluster for this cluster handler.""" return self._cluster @property def status(self): - """Return the status of the channel.""" + """Return the status of the cluster handler.""" return self._status @callback @@ -530,12 +550,12 @@ class ZDOChannel(LogMixin): """Permit handler.""" async def async_initialize(self, from_cache): - """Initialize channel.""" - self._status = ChannelStatus.INITIALIZED + """Initialize cluster handler.""" + self._status = ClusterHandlerStatus.INITIALIZED async def async_configure(self): - """Configure channel.""" - self._status = ChannelStatus.CONFIGURED + """Configure cluster handler.""" + self._status = ClusterHandlerStatus.CONFIGURED def log(self, level, msg, *args, **kwargs): """Log a message.""" @@ -544,8 +564,8 @@ class ZDOChannel(LogMixin): _LOGGER.log(level, msg, *args, **kwargs) -class ClientChannel(ZigbeeChannel): - """Channel listener for Zigbee client (output) clusters.""" +class ClientClusterHandler(ClusterHandler): + """ClusterHandler for Zigbee client (output) clusters.""" @callback def attribute_updated(self, attrid, value): diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/cluster_handlers/closures.py similarity index 85% rename from homeassistant/components/zha/core/channels/closures.py rename to homeassistant/components/zha/core/cluster_handlers/closures.py index de2dcaf38e..4080e95748 100644 --- a/homeassistant/components/zha/core/channels/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -1,16 +1,16 @@ -"""Closures channels module for Zigbee Home Automation.""" +"""Closures cluster handlers module for Zigbee Home Automation.""" from zigpy.zcl.clusters import closures from homeassistant.core import callback +from . import AttrReportConfig, ClientClusterHandler, ClusterHandler from .. import registries from ..const import REPORT_CONFIG_IMMEDIATE, SIGNAL_ATTR_UPDATED -from .base import AttrReportConfig, ClientChannel, ZigbeeChannel -@registries.ZIGBEE_CHANNEL_REGISTRY.register(closures.DoorLock.cluster_id) -class DoorLockChannel(ZigbeeChannel): - """Door lock channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.DoorLock.cluster_id) +class DoorLockClusterHandler(ClusterHandler): + """Door lock cluster handler.""" _value_attribute = 0 REPORT_CONFIG = ( @@ -107,19 +107,19 @@ class DoorLockChannel(ZigbeeChannel): return result -@registries.ZIGBEE_CHANNEL_REGISTRY.register(closures.Shade.cluster_id) -class Shade(ZigbeeChannel): - """Shade channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.Shade.cluster_id) +class Shade(ClusterHandler): + """Shade cluster handler.""" -@registries.CLIENT_CHANNELS_REGISTRY.register(closures.WindowCovering.cluster_id) -class WindowCoveringClient(ClientChannel): - """Window client channel.""" +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(closures.WindowCovering.cluster_id) +class WindowCoveringClient(ClientClusterHandler): + """Window client cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(closures.WindowCovering.cluster_id) -class WindowCovering(ZigbeeChannel): - """Window channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.WindowCovering.cluster_id) +class WindowCovering(ClusterHandler): + """Window cluster handler.""" _value_attribute = 8 REPORT_CONFIG = ( diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py similarity index 67% rename from homeassistant/components/zha/core/channels/general.py rename to homeassistant/components/zha/core/cluster_handlers/general.py index 47d0cafb01..12b1f63686 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -1,4 +1,4 @@ -"""General channels module for Zigbee Home Automation.""" +"""General cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations import asyncio @@ -14,6 +14,12 @@ from zigpy.zcl.foundation import Status from homeassistant.core import callback from homeassistant.helpers.event import async_call_later +from . import ( + AttrReportConfig, + ClientClusterHandler, + ClusterHandler, + parse_and_log_command, +) from .. import registries from ..const import ( REPORT_CONFIG_ASAP, @@ -27,21 +33,20 @@ from ..const import ( SIGNAL_SET_LEVEL, SIGNAL_UPDATE_DEVICE, ) -from .base import AttrReportConfig, ClientChannel, ZigbeeChannel, parse_and_log_command from .helpers import is_hue_motion_sensor if TYPE_CHECKING: - from . import ChannelPool + from ..endpoint import Endpoint -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Alarms.cluster_id) -class Alarms(ZigbeeChannel): - """Alarms channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Alarms.cluster_id) +class Alarms(ClusterHandler): + """Alarms cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogInput.cluster_id) -class AnalogInput(ZigbeeChannel): - """Analog Input channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogInput.cluster_id) +class AnalogInput(ClusterHandler): + """Analog Input cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), @@ -49,9 +54,9 @@ class AnalogInput(ZigbeeChannel): @registries.BINDABLE_CLUSTERS.register(general.AnalogOutput.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogOutput.cluster_id) -class AnalogOutput(ZigbeeChannel): - """Analog Output channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogOutput.cluster_id) +class AnalogOutput(ClusterHandler): + """Analog Output cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), @@ -120,24 +125,26 @@ class AnalogOutput(ZigbeeChannel): return False -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogValue.cluster_id) -class AnalogValue(ZigbeeChannel): - """Analog Value channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogValue.cluster_id) +class AnalogValue(ClusterHandler): + """Analog Value cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.ApplianceControl.cluster_id) -class ApplianceContorl(ZigbeeChannel): - """Appliance Control channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + general.ApplianceControl.cluster_id +) +class ApplianceContorl(ClusterHandler): + """Appliance Control cluster handler.""" -@registries.CHANNEL_ONLY_CLUSTERS.register(general.Basic.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Basic.cluster_id) -class BasicChannel(ZigbeeChannel): - """Channel to interact with the basic cluster.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(general.Basic.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Basic.cluster_id) +class BasicClusterHandler(ClusterHandler): + """Cluster handler to interact with the basic cluster.""" UNKNOWN = 0 BATTERY = 3 @@ -153,9 +160,9 @@ class BasicChannel(ZigbeeChannel): 6: "Emergency mains and transfer switch", } - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize Basic channel.""" - super().__init__(cluster, ch_pool) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize Basic cluster handler.""" + super().__init__(cluster, endpoint) if is_hue_motion_sensor(self) and self.cluster.endpoint.endpoint_id == 2: self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name self.ZCL_INIT_ATTRS.copy() @@ -169,41 +176,43 @@ class BasicChannel(ZigbeeChannel): self.ZCL_INIT_ATTRS["transmit_power"] = True -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryInput.cluster_id) -class BinaryInput(ZigbeeChannel): - """Binary Input channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.BinaryInput.cluster_id) +class BinaryInput(ClusterHandler): + """Binary Input cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryOutput.cluster_id) -class BinaryOutput(ZigbeeChannel): - """Binary Output channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.BinaryOutput.cluster_id) +class BinaryOutput(ClusterHandler): + """Binary Output cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryValue.cluster_id) -class BinaryValue(ZigbeeChannel): - """Binary Value channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.BinaryValue.cluster_id) +class BinaryValue(ClusterHandler): + """Binary Value cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Commissioning.cluster_id) -class Commissioning(ZigbeeChannel): - """Commissioning channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Commissioning.cluster_id) +class Commissioning(ClusterHandler): + """Commissioning cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.DeviceTemperature.cluster_id) -class DeviceTemperature(ZigbeeChannel): - """Device Temperature channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + general.DeviceTemperature.cluster_id +) +class DeviceTemperature(ClusterHandler): + """Device Temperature cluster handler.""" REPORT_CONFIG = ( { @@ -213,23 +222,23 @@ class DeviceTemperature(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.GreenPowerProxy.cluster_id) -class GreenPowerProxy(ZigbeeChannel): - """Green Power Proxy channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.GreenPowerProxy.cluster_id) +class GreenPowerProxy(ClusterHandler): + """Green Power Proxy cluster handler.""" BIND: bool = False -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Groups.cluster_id) -class Groups(ZigbeeChannel): - """Groups channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Groups.cluster_id) +class Groups(ClusterHandler): + """Groups cluster handler.""" BIND: bool = False -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Identify.cluster_id) -class Identify(ZigbeeChannel): - """Identify channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Identify.cluster_id) +class Identify(ClusterHandler): + """Identify cluster handler.""" BIND: bool = False @@ -242,15 +251,15 @@ class Identify(ZigbeeChannel): self.async_send_signal(f"{self.unique_id}_{cmd}", args[0]) -@registries.CLIENT_CHANNELS_REGISTRY.register(general.LevelControl.cluster_id) -class LevelControlClientChannel(ClientChannel): +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.LevelControl.cluster_id) +class LevelControlClientClusterHandler(ClientClusterHandler): """LevelControl client cluster.""" @registries.BINDABLE_CLUSTERS.register(general.LevelControl.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.LevelControl.cluster_id) -class LevelControlChannel(ZigbeeChannel): - """Channel for the LevelControl Zigbee cluster.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.LevelControl.cluster_id) +class LevelControlClusterHandler(ClusterHandler): + """Cluster handler for the LevelControl Zigbee cluster.""" CURRENT_LEVEL = 0 REPORT_CONFIG = (AttrReportConfig(attr="current_level", config=REPORT_CONFIG_ASAP),) @@ -299,42 +308,44 @@ class LevelControlChannel(ZigbeeChannel): self.async_send_signal(f"{self.unique_id}_{command}", level) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateInput.cluster_id) -class MultistateInput(ZigbeeChannel): - """Multistate Input channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.MultistateInput.cluster_id) +class MultistateInput(ClusterHandler): + """Multistate Input cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateOutput.cluster_id) -class MultistateOutput(ZigbeeChannel): - """Multistate Output channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + general.MultistateOutput.cluster_id +) +class MultistateOutput(ClusterHandler): + """Multistate Output cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateValue.cluster_id) -class MultistateValue(ZigbeeChannel): - """Multistate Value channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.MultistateValue.cluster_id) +class MultistateValue(ClusterHandler): + """Multistate Value cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.CLIENT_CHANNELS_REGISTRY.register(general.OnOff.cluster_id) -class OnOffClientChannel(ClientChannel): - """OnOff client channel.""" +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.OnOff.cluster_id) +class OnOffClientClusterHandler(ClientClusterHandler): + """OnOff client cluster handler.""" @registries.BINDABLE_CLUSTERS.register(general.OnOff.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.OnOff.cluster_id) -class OnOffChannel(ZigbeeChannel): - """Channel for the OnOff Zigbee cluster.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.OnOff.cluster_id) +class OnOffClusterHandler(ClusterHandler): + """Cluster handler for the OnOff Zigbee cluster.""" ON_OFF = 0 REPORT_CONFIG = (AttrReportConfig(attr="on_off", config=REPORT_CONFIG_IMMEDIATE),) @@ -342,9 +353,9 @@ class OnOffChannel(ZigbeeChannel): "start_up_on_off": True, } - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize OnOffChannel.""" - super().__init__(cluster, ch_pool) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize OnOffClusterHandler.""" + super().__init__(cluster, endpoint) self._off_listener = None if self.cluster.endpoint.model in ( @@ -404,7 +415,7 @@ class OnOffChannel(ZigbeeChannel): self.cluster.update_attribute(self.ON_OFF, t.Bool.true) if on_time > 0: self._off_listener = async_call_later( - self._ch_pool.hass, + self._endpoint.device.hass, (on_time / 10), # value is in 10ths of a second self.set_to_off, ) @@ -426,24 +437,26 @@ class OnOffChannel(ZigbeeChannel): ) async def async_update(self): - """Initialize channel.""" + """Initialize cluster handler.""" if self.cluster.is_client: return - from_cache = not self._ch_pool.is_mains_powered + from_cache = not self._endpoint.device.is_mains_powered self.debug("attempting to update onoff state - from cache: %s", from_cache) await self.get_attribute_value(self.ON_OFF, from_cache=from_cache) await super().async_update() -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.OnOffConfiguration.cluster_id) -class OnOffConfiguration(ZigbeeChannel): - """OnOff Configuration channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + general.OnOffConfiguration.cluster_id +) +class OnOffConfiguration(ClusterHandler): + """OnOff Configuration cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Ota.cluster_id) -@registries.CLIENT_CHANNELS_REGISTRY.register(general.Ota.cluster_id) -class Ota(ClientChannel): - """OTA Channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Ota.cluster_id) +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.Ota.cluster_id) +class Ota(ClientClusterHandler): + """OTA cluster handler.""" BIND: bool = False @@ -457,21 +470,21 @@ class Ota(ClientChannel): else: cmd_name = command_id - signal_id = self._ch_pool.unique_id.split("-")[0] + signal_id = self._endpoint.unique_id.split("-")[0] if cmd_name == "query_next_image": assert args self.async_send_signal(SIGNAL_UPDATE_DEVICE.format(signal_id), args[3]) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Partition.cluster_id) -class Partition(ZigbeeChannel): - """Partition channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Partition.cluster_id) +class Partition(ClusterHandler): + """Partition cluster handler.""" -@registries.CHANNEL_ONLY_CLUSTERS.register(general.PollControl.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.PollControl.cluster_id) -class PollControl(ZigbeeChannel): - """Poll Control channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(general.PollControl.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.PollControl.cluster_id) +class PollControl(ClusterHandler): + """Poll Control cluster handler.""" CHECKIN_INTERVAL = 55 * 60 * 4 # 55min CHECKIN_FAST_POLL_TIMEOUT = 2 * 4 # 2s @@ -480,8 +493,8 @@ class PollControl(ZigbeeChannel): 4476, } # IKEA - async def async_configure_channel_specific(self) -> None: - """Configure channel: set check-in interval.""" + async def async_configure_cluster_handler_specific(self) -> None: + """Configure cluster handler: set check-in interval.""" try: res = await self.cluster.write_attributes( {"checkin_interval": self.CHECKIN_INTERVAL} @@ -508,7 +521,7 @@ class PollControl(ZigbeeChannel): async def check_in_response(self, tsn: int) -> None: """Respond to checkin command.""" await self.checkin_response(True, self.CHECKIN_FAST_POLL_TIMEOUT, tsn=tsn) - if self._ch_pool.manufacturer_code not in self._IGNORED_MANUFACTURER_ID: + if self._endpoint.device.manufacturer_code not in self._IGNORED_MANUFACTURER_ID: await self.set_long_poll_interval(self.LONG_POLL) await self.fast_poll_stop() @@ -518,9 +531,11 @@ class PollControl(ZigbeeChannel): self._IGNORED_MANUFACTURER_ID.add(manufacturer_code) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.PowerConfiguration.cluster_id) -class PowerConfigurationChannel(ZigbeeChannel): - """Channel for the zigbee power configuration cluster.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + general.PowerConfiguration.cluster_id +) +class PowerConfigurationClusterHandler(ClusterHandler): + """Cluster handler for the zigbee power configuration cluster.""" REPORT_CONFIG = ( AttrReportConfig(attr="battery_voltage", config=REPORT_CONFIG_BATTERY_SAVE), @@ -529,8 +544,8 @@ class PowerConfigurationChannel(ZigbeeChannel): ), ) - def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: - """Initialize channel specific attrs.""" + def async_initialize_cluster_handler_specific(self, from_cache: bool) -> Coroutine: + """Initialize cluster handler specific attrs.""" attributes = [ "battery_size", "battery_quantity", @@ -540,26 +555,26 @@ class PowerConfigurationChannel(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.PowerProfile.cluster_id) -class PowerProfile(ZigbeeChannel): - """Power Profile channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.PowerProfile.cluster_id) +class PowerProfile(ClusterHandler): + """Power Profile cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.RSSILocation.cluster_id) -class RSSILocation(ZigbeeChannel): - """RSSI Location channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.RSSILocation.cluster_id) +class RSSILocation(ClusterHandler): + """RSSI Location cluster handler.""" -@registries.CLIENT_CHANNELS_REGISTRY.register(general.Scenes.cluster_id) -class ScenesClientChannel(ClientChannel): - """Scenes channel.""" +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.Scenes.cluster_id) +class ScenesClientClusterHandler(ClientClusterHandler): + """Scenes cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Scenes.cluster_id) -class Scenes(ZigbeeChannel): - """Scenes channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Scenes.cluster_id) +class Scenes(ClusterHandler): + """Scenes cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Time.cluster_id) -class Time(ZigbeeChannel): - """Time channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Time.cluster_id) +class Time(ClusterHandler): + """Time cluster handler.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/helpers.py b/homeassistant/components/zha/core/cluster_handlers/helpers.py new file mode 100644 index 0000000000..17bc576397 --- /dev/null +++ b/homeassistant/components/zha/core/cluster_handlers/helpers.py @@ -0,0 +1,15 @@ +"""Helpers for use with ZHA Zigbee cluster handlers.""" +from . import ClusterHandler + + +def is_hue_motion_sensor(cluster_handler: ClusterHandler) -> bool: + """Return true if the manufacturer and model match known Hue motion sensor models.""" + return cluster_handler.cluster.endpoint.manufacturer in ( + "Philips", + "Signify Netherlands B.V.", + ) and cluster_handler.cluster.endpoint.model in ( + "SML001", + "SML002", + "SML003", + "SML004", + ) diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py similarity index 78% rename from homeassistant/components/zha/core/channels/homeautomation.py rename to homeassistant/components/zha/core/cluster_handlers/homeautomation.py index 69295ef6f8..981ed08ba0 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py @@ -1,53 +1,55 @@ -"""Home automation channels module for Zigbee Home Automation.""" +"""Home automation cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations import enum from zigpy.zcl.clusters import homeautomation +from . import AttrReportConfig, ClusterHandler from .. import registries from ..const import ( - CHANNEL_ELECTRICAL_MEASUREMENT, + CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) -from .base import AttrReportConfig, ZigbeeChannel -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( homeautomation.ApplianceEventAlerts.cluster_id ) -class ApplianceEventAlerts(ZigbeeChannel): - """Appliance Event Alerts channel.""" +class ApplianceEventAlerts(ClusterHandler): + """Appliance Event Alerts cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( homeautomation.ApplianceIdentification.cluster_id ) -class ApplianceIdentification(ZigbeeChannel): - """Appliance Identification channel.""" +class ApplianceIdentification(ClusterHandler): + """Appliance Identification cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( homeautomation.ApplianceStatistics.cluster_id ) -class ApplianceStatistics(ZigbeeChannel): - """Appliance Statistics channel.""" +class ApplianceStatistics(ClusterHandler): + """Appliance Statistics cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(homeautomation.Diagnostic.cluster_id) -class Diagnostic(ZigbeeChannel): - """Diagnostic channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + homeautomation.Diagnostic.cluster_id +) +class Diagnostic(ClusterHandler): + """Diagnostic cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( homeautomation.ElectricalMeasurement.cluster_id ) -class ElectricalMeasurementChannel(ZigbeeChannel): - """Channel that polls active power level.""" +class ElectricalMeasurementClusterHandler(ClusterHandler): + """Cluster handler that polls active power level.""" - CHANNEL_NAME = CHANNEL_ELECTRICAL_MEASUREMENT + CLUSTER_HANDLER_NAME = CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT class MeasurementType(enum.IntFlag): """Measurement types.""" @@ -91,7 +93,7 @@ class ElectricalMeasurementChannel(ZigbeeChannel): """Retrieve latest state.""" self.debug("async_update") - # This is a polling channel. Don't allow cache. + # This is a polling cluster handler. Don't allow cache. attrs = [ a["attr"] for a in self.REPORT_CONFIG @@ -165,8 +167,8 @@ class ElectricalMeasurementChannel(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( homeautomation.MeterIdentification.cluster_id ) -class MeterIdentification(ZigbeeChannel): - """Metering Identification channel.""" +class MeterIdentification(ClusterHandler): + """Metering Identification cluster handler.""" diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py similarity index 91% rename from homeassistant/components/zha/core/channels/hvac.py rename to homeassistant/components/zha/core/cluster_handlers/hvac.py index 4a73a643b7..94154564e8 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -1,4 +1,4 @@ -"""HVAC channels module for Zigbee Home Automation. +"""HVAC cluster handlers module for Zigbee Home Automation. For more details about this component, please refer to the documentation at https://home-assistant.io/integrations/zha/ @@ -14,6 +14,7 @@ from zigpy.zcl.foundation import Status from homeassistant.core import callback +from . import AttrReportConfig, ClusterHandler from .. import registries from ..const import ( REPORT_CONFIG_MAX_INT, @@ -21,7 +22,6 @@ from ..const import ( REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) -from .base import AttrReportConfig, ZigbeeChannel AttributeUpdateRecord = namedtuple("AttributeUpdateRecord", "attr_id, attr_name, value") REPORT_CONFIG_CLIMATE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 25) @@ -29,14 +29,14 @@ REPORT_CONFIG_CLIMATE_DEMAND = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 5) REPORT_CONFIG_CLIMATE_DISCRETE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 1) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.Dehumidification.cluster_id) -class Dehumidification(ZigbeeChannel): - """Dehumidification channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Dehumidification.cluster_id) +class Dehumidification(ClusterHandler): + """Dehumidification cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.Fan.cluster_id) -class FanChannel(ZigbeeChannel): - """Fan channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Fan.cluster_id) +class FanClusterHandler(ClusterHandler): + """Fan cluster handler.""" _value_attribute = 0 @@ -79,14 +79,14 @@ class FanChannel(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.Pump.cluster_id) -class Pump(ZigbeeChannel): - """Pump channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Pump.cluster_id) +class Pump(ClusterHandler): + """Pump cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.Thermostat.cluster_id) -class ThermostatChannel(ZigbeeChannel): - """Thermostat channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Thermostat.cluster_id) +class ThermostatClusterHandler(ClusterHandler): + """Thermostat cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="local_temperature", config=REPORT_CONFIG_CLIMATE), @@ -314,6 +314,6 @@ class ThermostatChannel(ZigbeeChannel): return all(record.status == Status.SUCCESS for record in res[0]) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.UserInterface.cluster_id) -class UserInterface(ZigbeeChannel): - """User interface (thermostat) channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.UserInterface.cluster_id) +class UserInterface(ClusterHandler): + """User interface (thermostat) cluster handler.""" diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/cluster_handlers/lighting.py similarity index 80% rename from homeassistant/components/zha/core/channels/lighting.py rename to homeassistant/components/zha/core/cluster_handlers/lighting.py index 55d77d507f..56f3c701aa 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/cluster_handlers/lighting.py @@ -1,29 +1,29 @@ -"""Lighting channels module for Zigbee Home Automation.""" +"""Lighting cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations from functools import cached_property from zigpy.zcl.clusters import lighting +from . import AttrReportConfig, ClientClusterHandler, ClusterHandler from .. import registries from ..const import REPORT_CONFIG_DEFAULT -from .base import AttrReportConfig, ClientChannel, ZigbeeChannel -@registries.ZIGBEE_CHANNEL_REGISTRY.register(lighting.Ballast.cluster_id) -class Ballast(ZigbeeChannel): - """Ballast channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(lighting.Ballast.cluster_id) +class Ballast(ClusterHandler): + """Ballast cluster handler.""" -@registries.CLIENT_CHANNELS_REGISTRY.register(lighting.Color.cluster_id) -class ColorClientChannel(ClientChannel): - """Color client channel.""" +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(lighting.Color.cluster_id) +class ColorClientClusterHandler(ClientClusterHandler): + """Color client cluster handler.""" @registries.BINDABLE_CLUSTERS.register(lighting.Color.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(lighting.Color.cluster_id) -class ColorChannel(ZigbeeChannel): - """Color channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(lighting.Color.cluster_id) +class ColorClusterHandler(ClusterHandler): + """Color cluster handler.""" CAPABILITIES_COLOR_XY = 0x08 CAPABILITIES_COLOR_TEMP = 0x10 @@ -98,7 +98,7 @@ class ColorChannel(ZigbeeChannel): @property def min_mireds(self) -> int: - """Return the coldest color_temp that this channel supports.""" + """Return the coldest color_temp that this cluster handler supports.""" min_mireds = self.cluster.get("color_temp_physical_min", self.MIN_MIREDS) if min_mireds == 0: self.warning( @@ -113,7 +113,7 @@ class ColorChannel(ZigbeeChannel): @property def max_mireds(self) -> int: - """Return the warmest color_temp that this channel supports.""" + """Return the warmest color_temp that this cluster handler supports.""" max_mireds = self.cluster.get("color_temp_physical_max", self.MAX_MIREDS) if max_mireds == 0: self.warning( @@ -128,7 +128,7 @@ class ColorChannel(ZigbeeChannel): @property def hs_supported(self) -> bool: - """Return True if the channel supports hue and saturation.""" + """Return True if the cluster handler supports hue and saturation.""" return ( self.color_capabilities is not None and lighting.Color.ColorCapabilities.Hue_and_saturation @@ -137,7 +137,7 @@ class ColorChannel(ZigbeeChannel): @property def enhanced_hue_supported(self) -> bool: - """Return True if the channel supports enhanced hue and saturation.""" + """Return True if the cluster handler supports enhanced hue and saturation.""" return ( self.color_capabilities is not None and lighting.Color.ColorCapabilities.Enhanced_hue in self.color_capabilities @@ -145,7 +145,7 @@ class ColorChannel(ZigbeeChannel): @property def xy_supported(self) -> bool: - """Return True if the channel supports xy.""" + """Return True if the cluster handler supports xy.""" return ( self.color_capabilities is not None and lighting.Color.ColorCapabilities.XY_attributes @@ -154,7 +154,7 @@ class ColorChannel(ZigbeeChannel): @property def color_temp_supported(self) -> bool: - """Return True if the channel supports color temperature.""" + """Return True if the cluster handler supports color temperature.""" return ( self.color_capabilities is not None and lighting.Color.ColorCapabilities.Color_temperature @@ -163,7 +163,7 @@ class ColorChannel(ZigbeeChannel): @property def color_loop_supported(self) -> bool: - """Return True if the channel supports color loop.""" + """Return True if the cluster handler supports color loop.""" return ( self.color_capabilities is not None and lighting.Color.ColorCapabilities.Color_loop in self.color_capabilities @@ -171,10 +171,10 @@ class ColorChannel(ZigbeeChannel): @property def options(self) -> lighting.Color.Options: - """Return ZCL options of the channel.""" + """Return ZCL options of the cluster handler.""" return lighting.Color.Options(self.cluster.get("options", 0)) @property def execute_if_off_supported(self) -> bool: - """Return True if the channel can execute commands when off.""" + """Return True if the cluster handler can execute commands when off.""" return lighting.Color.Options.Execute_if_off in self.options diff --git a/homeassistant/components/zha/core/channels/lightlink.py b/homeassistant/components/zha/core/cluster_handlers/lightlink.py similarity index 65% rename from homeassistant/components/zha/core/channels/lightlink.py rename to homeassistant/components/zha/core/cluster_handlers/lightlink.py index 2884769d10..437a4b4ecf 100644 --- a/homeassistant/components/zha/core/channels/lightlink.py +++ b/homeassistant/components/zha/core/cluster_handlers/lightlink.py @@ -1,31 +1,31 @@ -"""Lightlink channels module for Zigbee Home Automation.""" +"""Lightlink cluster handlers module for Zigbee Home Automation.""" import asyncio import zigpy.exceptions from zigpy.zcl.clusters import lightlink from zigpy.zcl.foundation import GENERAL_COMMANDS, GeneralCommand +from . import ClusterHandler, ClusterHandlerStatus from .. import registries -from .base import ChannelStatus, ZigbeeChannel -@registries.CHANNEL_ONLY_CLUSTERS.register(lightlink.LightLink.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(lightlink.LightLink.cluster_id) -class LightLink(ZigbeeChannel): - """Lightlink channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(lightlink.LightLink.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(lightlink.LightLink.cluster_id) +class LightLink(ClusterHandler): + """Lightlink cluster handler.""" BIND: bool = False async def async_configure(self) -> None: """Add Coordinator to LightLink group.""" - if self._ch_pool.skip_configuration: - self._status = ChannelStatus.CONFIGURED + if self._endpoint.device.skip_configuration: + self._status = ClusterHandlerStatus.CONFIGURED return - application = self._ch_pool.endpoint.device.application + application = self._endpoint.zigpy_endpoint.device.application try: - coordinator = application.get_device(application.ieee) + coordinator = application.get_device(application.state.node_info.ieee) except KeyError: self.warning("Aborting - unable to locate required coordinator device.") return diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py similarity index 66% rename from homeassistant/components/zha/core/channels/manufacturerspecific.py rename to homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index 85a478b0d4..d20888e1f5 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -1,4 +1,4 @@ -"""Manufacturer specific channels module for Zigbee Home Automation.""" +"""Manufacturer specific cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations import logging @@ -10,6 +10,7 @@ import zigpy.zcl from homeassistant.core import callback +from . import AttrReportConfig, ClientClusterHandler, ClusterHandler from .. import registries from ..const import ( ATTR_ATTRIBUTE_ID, @@ -23,17 +24,18 @@ from ..const import ( SIGNAL_ATTR_UPDATED, UNKNOWN, ) -from .base import AttrReportConfig, ClientChannel, ZigbeeChannel if TYPE_CHECKING: - from . import ChannelPool + from ..endpoint import Endpoint _LOGGER = logging.getLogger(__name__) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(registries.SMARTTHINGS_HUMIDITY_CLUSTER) -class SmartThingsHumidity(ZigbeeChannel): - """Smart Things Humidity channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + registries.SMARTTHINGS_HUMIDITY_CLUSTER +) +class SmartThingsHumidity(ClusterHandler): + """Smart Things Humidity cluster handler.""" REPORT_CONFIG = ( { @@ -43,32 +45,34 @@ class SmartThingsHumidity(ZigbeeChannel): ) -@registries.CHANNEL_ONLY_CLUSTERS.register(0xFD00) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFD00) -class OsramButton(ZigbeeChannel): - """Osram button channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFD00) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFD00) +class OsramButton(ClusterHandler): + """Osram button cluster handler.""" REPORT_CONFIG = () -@registries.CHANNEL_ONLY_CLUSTERS.register(registries.PHILLIPS_REMOTE_CLUSTER) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(registries.PHILLIPS_REMOTE_CLUSTER) -class PhillipsRemote(ZigbeeChannel): - """Phillips remote channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(registries.PHILLIPS_REMOTE_CLUSTER) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(registries.PHILLIPS_REMOTE_CLUSTER) +class PhillipsRemote(ClusterHandler): + """Phillips remote cluster handler.""" REPORT_CONFIG = () -@registries.CHANNEL_ONLY_CLUSTERS.register(registries.TUYA_MANUFACTURER_CLUSTER) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(registries.TUYA_MANUFACTURER_CLUSTER) -class TuyaChannel(ZigbeeChannel): - """Channel for the Tuya manufacturer Zigbee cluster.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(registries.TUYA_MANUFACTURER_CLUSTER) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + registries.TUYA_MANUFACTURER_CLUSTER +) +class TuyaClusterHandler(ClusterHandler): + """Cluster handler for the Tuya manufacturer Zigbee cluster.""" REPORT_CONFIG = () - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize TuyaChannel.""" - super().__init__(cluster, ch_pool) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize TuyaClusterHandler.""" + super().__init__(cluster, endpoint) if self.cluster.endpoint.manufacturer in ( "_TZE200_7tdtqgwv", @@ -94,16 +98,16 @@ class TuyaChannel(ZigbeeChannel): } -@registries.CHANNEL_ONLY_CLUSTERS.register(0xFCC0) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFCC0) -class OppleRemote(ZigbeeChannel): - """Opple channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFCC0) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFCC0) +class OppleRemote(ClusterHandler): + """Opple cluster handler.""" REPORT_CONFIG = () - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize Opple channel.""" - super().__init__(cluster, ch_pool) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize Opple cluster handler.""" + super().__init__(cluster, endpoint) if self.cluster.endpoint.model == "lumi.motion.ac02": self.ZCL_INIT_ATTRS = { # pylint: disable=invalid-name "detection_interval": True, @@ -138,9 +142,32 @@ class OppleRemote(ZigbeeChannel): "serving_size": True, "portion_weight": True, } + elif self.cluster.endpoint.model == "lumi.airrtc.agl001": + self.ZCL_INIT_ATTRS = { + "system_mode": True, + "preset": True, + "window_detection": True, + "valve_detection": True, + "valve_alarm": True, + "child_lock": True, + "away_preset_temperature": True, + "window_open": True, + "calibrated": True, + "schedule": True, + "sensor": True, + } + elif self.cluster.endpoint.model == "lumi.sensor_smoke.acn03": + self.ZCL_INIT_ATTRS = { + "buzzer_manual_mute": True, + "smoke_density": True, + "heartbeat_indicator": True, + "buzzer_manual_alarm": True, + "buzzer": True, + "linkage_alarm": True, + } - async def async_initialize_channel_specific(self, from_cache: bool) -> None: - """Initialize channel specific.""" + async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: + """Initialize cluster handler specific.""" if self.cluster.endpoint.model in ("lumi.motion.ac02", "lumi.motion.agl04"): interval = self.cluster.get("detection_interval", self.cluster.get(0x0102)) if interval is not None: @@ -148,11 +175,11 @@ class OppleRemote(ZigbeeChannel): self.cluster.endpoint.ias_zone.reset_s = int(interval) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( registries.SMARTTHINGS_ACCELERATION_CLUSTER ) -class SmartThingsAcceleration(ZigbeeChannel): - """Smart Things Acceleration channel.""" +class SmartThingsAcceleration(ClusterHandler): + """Smart Things Acceleration cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="acceleration", config=REPORT_CONFIG_ASAP), @@ -161,14 +188,28 @@ class SmartThingsAcceleration(ZigbeeChannel): AttrReportConfig(attr="z_axis", config=REPORT_CONFIG_ASAP), ) + @classmethod + def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: + """Filter the cluster match for specific devices.""" + return cluster.endpoint.device.manufacturer in ( + "CentraLite", + "Samjin", + "SmartThings", + ) + @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" + try: + attr_name = self._cluster.attributes[attrid].name + except KeyError: + attr_name = UNKNOWN + if attrid == self.value_attribute: self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, - self._cluster.attributes.get(attrid, [UNKNOWN])[0], + attr_name, value, ) return @@ -177,15 +218,15 @@ class SmartThingsAcceleration(ZigbeeChannel): SIGNAL_ATTR_UPDATED, { ATTR_ATTRIBUTE_ID: attrid, - ATTR_ATTRIBUTE_NAME: self._cluster.attributes.get(attrid, [UNKNOWN])[0], + ATTR_ATTRIBUTE_NAME: attr_name, ATTR_VALUE: value, }, ) -@registries.CLIENT_CHANNELS_REGISTRY.register(0xFC31) -class InovelliNotificationChannel(ClientChannel): - """Inovelli Notification channel.""" +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(0xFC31) +class InovelliNotificationClusterHandler(ClientClusterHandler): + """Inovelli Notification cluster handler.""" @callback def attribute_updated(self, attrid, value): @@ -196,9 +237,9 @@ class InovelliNotificationChannel(ClientChannel): """Handle a cluster command received on this cluster.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFC31) -class InovelliConfigEntityChannel(ZigbeeChannel): - """Inovelli Configuration Entity channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFC31) +class InovelliConfigEntityClusterHandler(ClusterHandler): + """Inovelli Configuration Entity cluster handler.""" REPORT_CONFIG = () ZCL_INIT_ATTRS = { @@ -223,14 +264,20 @@ class InovelliConfigEntityChannel(ZigbeeChannel): "active_energy_reports": True, "power_type": False, "switch_type": False, + "increased_non_neutral_output": True, "button_delay": False, "smart_bulb_mode": False, - "double_tap_up_for_max_brightness": True, - "double_tap_down_for_min_brightness": True, + "double_tap_up_enabled": True, + "double_tap_down_enabled": True, + "double_tap_up_level": True, + "double_tap_down_level": True, "led_color_when_on": True, "led_color_when_off": True, "led_intensity_when_on": True, "led_intensity_when_off": True, + "led_scaling_mode": True, + "aux_switch_scenes": True, + "binding_off_to_on_sync_level": True, "local_protection": False, "output_mode": False, "on_off_led_mode": True, @@ -273,10 +320,12 @@ class InovelliConfigEntityChannel(ZigbeeChannel): ) -@registries.CHANNEL_ONLY_CLUSTERS.register(registries.IKEA_AIR_PURIFIER_CLUSTER) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(registries.IKEA_AIR_PURIFIER_CLUSTER) -class IkeaAirPurifierChannel(ZigbeeChannel): - """IKEA Air Purifier channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(registries.IKEA_AIR_PURIFIER_CLUSTER) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + registries.IKEA_AIR_PURIFIER_CLUSTER +) +class IkeaAirPurifierClusterHandler(ClusterHandler): + """IKEA Air Purifier cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="filter_run_time", config=REPORT_CONFIG_DEFAULT), @@ -324,3 +373,11 @@ class IkeaAirPurifierChannel(ZigbeeChannel): self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value ) + + +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFC80) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFC80) +class IkeaRemote(ClusterHandler): + """Ikea Matter remote cluster handler.""" + + REPORT_CONFIG = () diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/cluster_handlers/measurement.py similarity index 52% rename from homeassistant/components/zha/core/channels/measurement.py rename to homeassistant/components/zha/core/cluster_handlers/measurement.py index be61a75962..8b882a299f 100644 --- a/homeassistant/components/zha/core/channels/measurement.py +++ b/homeassistant/components/zha/core/cluster_handlers/measurement.py @@ -1,4 +1,4 @@ -"""Measurement channels module for Zigbee Home Automation.""" +"""Measurement cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations from typing import TYPE_CHECKING @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING import zigpy.zcl from zigpy.zcl.clusters import measurement +from . import AttrReportConfig, ClusterHandler from .. import registries from ..const import ( REPORT_CONFIG_DEFAULT, @@ -13,55 +14,58 @@ from ..const import ( REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, ) -from .base import AttrReportConfig, ZigbeeChannel from .helpers import is_hue_motion_sensor if TYPE_CHECKING: - from . import ChannelPool + from ..endpoint import Endpoint -@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.FlowMeasurement.cluster_id) -class FlowMeasurement(ZigbeeChannel): - """Flow Measurement channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + measurement.FlowMeasurement.cluster_id +) +class FlowMeasurement(ClusterHandler): + """Flow Measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( measurement.IlluminanceLevelSensing.cluster_id ) -class IlluminanceLevelSensing(ZigbeeChannel): - """Illuminance Level Sensing channel.""" +class IlluminanceLevelSensing(ClusterHandler): + """Illuminance Level Sensing cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="level_status", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( measurement.IlluminanceMeasurement.cluster_id ) -class IlluminanceMeasurement(ZigbeeChannel): - """Illuminance Measurement channel.""" +class IlluminanceMeasurement(ClusterHandler): + """Illuminance Measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.OccupancySensing.cluster_id) -class OccupancySensing(ZigbeeChannel): - """Occupancy Sensing channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + measurement.OccupancySensing.cluster_id +) +class OccupancySensing(ClusterHandler): + """Occupancy Sensing cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="occupancy", config=REPORT_CONFIG_IMMEDIATE), ) - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize Occupancy channel.""" - super().__init__(cluster, ch_pool) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize Occupancy cluster handler.""" + super().__init__(cluster, endpoint) if is_hue_motion_sensor(self): self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name self.ZCL_INIT_ATTRS.copy() @@ -69,18 +73,22 @@ class OccupancySensing(ZigbeeChannel): self.ZCL_INIT_ATTRS["sensitivity"] = True -@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.PressureMeasurement.cluster_id) -class PressureMeasurement(ZigbeeChannel): - """Pressure measurement channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + measurement.PressureMeasurement.cluster_id +) +class PressureMeasurement(ClusterHandler): + """Pressure measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.RelativeHumidity.cluster_id) -class RelativeHumidity(ZigbeeChannel): - """Relative Humidity measurement channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + measurement.RelativeHumidity.cluster_id +) +class RelativeHumidity(ClusterHandler): + """Relative Humidity measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( @@ -90,9 +98,11 @@ class RelativeHumidity(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.SoilMoisture.cluster_id) -class SoilMoisture(ZigbeeChannel): - """Soil Moisture measurement channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + measurement.SoilMoisture.cluster_id +) +class SoilMoisture(ClusterHandler): + """Soil Moisture measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( @@ -102,9 +112,9 @@ class SoilMoisture(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.LeafWetness.cluster_id) -class LeafWetness(ZigbeeChannel): - """Leaf Wetness measurement channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(measurement.LeafWetness.cluster_id) +class LeafWetness(ClusterHandler): + """Leaf Wetness measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( @@ -114,11 +124,11 @@ class LeafWetness(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( measurement.TemperatureMeasurement.cluster_id ) -class TemperatureMeasurement(ZigbeeChannel): - """Temperature measurement channel.""" +class TemperatureMeasurement(ClusterHandler): + """Temperature measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( @@ -128,11 +138,11 @@ class TemperatureMeasurement(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( measurement.CarbonMonoxideConcentration.cluster_id ) -class CarbonMonoxideConcentration(ZigbeeChannel): - """Carbon Monoxide measurement channel.""" +class CarbonMonoxideConcentration(ClusterHandler): + """Carbon Monoxide measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( @@ -142,11 +152,11 @@ class CarbonMonoxideConcentration(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( measurement.CarbonDioxideConcentration.cluster_id ) -class CarbonDioxideConcentration(ZigbeeChannel): - """Carbon Dioxide measurement channel.""" +class CarbonDioxideConcentration(ClusterHandler): + """Carbon Dioxide measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( @@ -156,9 +166,9 @@ class CarbonDioxideConcentration(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.PM25.cluster_id) -class PM25(ZigbeeChannel): - """Particulate Matter 2.5 microns or less measurement channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(measurement.PM25.cluster_id) +class PM25(ClusterHandler): + """Particulate Matter 2.5 microns or less measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( @@ -168,11 +178,11 @@ class PM25(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( measurement.FormaldehydeConcentration.cluster_id ) -class FormaldehydeConcentration(ZigbeeChannel): - """Formaldehyde measurement channel.""" +class FormaldehydeConcentration(ClusterHandler): + """Formaldehyde measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( diff --git a/homeassistant/components/zha/core/cluster_handlers/protocol.py b/homeassistant/components/zha/core/cluster_handlers/protocol.py new file mode 100644 index 0000000000..6398a8875b --- /dev/null +++ b/homeassistant/components/zha/core/cluster_handlers/protocol.py @@ -0,0 +1,143 @@ +"""Protocol cluster handlers module for Zigbee Home Automation.""" +from zigpy.zcl.clusters import protocol + +from . import ClusterHandler +from .. import registries + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.AnalogInputExtended.cluster_id +) +class AnalogInputExtended(ClusterHandler): + """Analog Input Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.AnalogInputRegular.cluster_id +) +class AnalogInputRegular(ClusterHandler): + """Analog Input Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.AnalogOutputExtended.cluster_id +) +class AnalogOutputExtended(ClusterHandler): + """Analog Output Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.AnalogOutputRegular.cluster_id +) +class AnalogOutputRegular(ClusterHandler): + """Analog Output Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.AnalogValueExtended.cluster_id +) +class AnalogValueExtended(ClusterHandler): + """Analog Value Extended edition cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.AnalogValueRegular.cluster_id +) +class AnalogValueRegular(ClusterHandler): + """Analog Value Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.BacnetProtocolTunnel.cluster_id +) +class BacnetProtocolTunnel(ClusterHandler): + """Bacnet Protocol Tunnel cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.BinaryInputExtended.cluster_id +) +class BinaryInputExtended(ClusterHandler): + """Binary Input Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.BinaryInputRegular.cluster_id +) +class BinaryInputRegular(ClusterHandler): + """Binary Input Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.BinaryOutputExtended.cluster_id +) +class BinaryOutputExtended(ClusterHandler): + """Binary Output Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.BinaryOutputRegular.cluster_id +) +class BinaryOutputRegular(ClusterHandler): + """Binary Output Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.BinaryValueExtended.cluster_id +) +class BinaryValueExtended(ClusterHandler): + """Binary Value Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.BinaryValueRegular.cluster_id +) +class BinaryValueRegular(ClusterHandler): + """Binary Value Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(protocol.GenericTunnel.cluster_id) +class GenericTunnel(ClusterHandler): + """Generic Tunnel cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.MultistateInputExtended.cluster_id +) +class MultiStateInputExtended(ClusterHandler): + """Multistate Input Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.MultistateInputRegular.cluster_id +) +class MultiStateInputRegular(ClusterHandler): + """Multistate Input Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.MultistateOutputExtended.cluster_id +) +class MultiStateOutputExtended(ClusterHandler): + """Multistate Output Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.MultistateOutputRegular.cluster_id +) +class MultiStateOutputRegular(ClusterHandler): + """Multistate Output Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.MultistateValueExtended.cluster_id +) +class MultiStateValueExtended(ClusterHandler): + """Multistate Value Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.MultistateValueRegular.cluster_id +) +class MultiStateValueRegular(ClusterHandler): + """Multistate Value Regular cluster handler.""" diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/cluster_handlers/security.py similarity index 86% rename from homeassistant/components/zha/core/channels/security.py rename to homeassistant/components/zha/core/cluster_handlers/security.py index b5a8d5d8cf..7e4951ad67 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/cluster_handlers/security.py @@ -1,4 +1,4 @@ -"""Security channels module for Zigbee Home Automation. +"""Security cluster handlers module for Zigbee Home Automation. For more details about this component, please refer to the documentation at https://home-assistant.io/integrations/zha/ @@ -11,10 +11,11 @@ from typing import TYPE_CHECKING, Any from zigpy.exceptions import ZigbeeException import zigpy.zcl from zigpy.zcl.clusters import security -from zigpy.zcl.clusters.security import IasAce as AceCluster +from zigpy.zcl.clusters.security import IasAce as AceCluster, IasZone from homeassistant.core import callback +from . import ClusterHandler, ClusterHandlerStatus from .. import registries from ..const import ( SIGNAL_ATTR_UPDATED, @@ -24,10 +25,9 @@ from ..const import ( WARNING_DEVICE_STROBE_HIGH, WARNING_DEVICE_STROBE_YES, ) -from .base import ChannelStatus, ZigbeeChannel if TYPE_CHECKING: - from . import ChannelPool + from ..endpoint import Endpoint IAS_ACE_ARM = 0x0000 # ("arm", (t.enum8, t.CharacterString, t.uint8_t), False), IAS_ACE_BYPASS = 0x0001 # ("bypass", (t.LVList(t.uint8_t), t.CharacterString), False), @@ -46,13 +46,13 @@ SIGNAL_ARMED_STATE_CHANGED = "zha_armed_state_changed" SIGNAL_ALARM_TRIGGERED = "zha_armed_triggered" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(AceCluster.cluster_id) -class IasAce(ZigbeeChannel): - """IAS Ancillary Control Equipment channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AceCluster.cluster_id) +class IasAce(ClusterHandler): + """IAS Ancillary Control Equipment cluster handler.""" - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize IAS Ancillary Control Equipment channel.""" - super().__init__(cluster, ch_pool) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize IAS Ancillary Control Equipment cluster handler.""" + super().__init__(cluster, endpoint) self.command_map: dict[int, Callable[..., Any]] = { IAS_ACE_ARM: self.arm, IAS_ACE_BYPASS: self._bypass, @@ -105,7 +105,7 @@ class IasAce(ZigbeeChannel): ) zigbee_reply = self.arm_map[mode](code) - self._ch_pool.hass.async_create_task(zigbee_reply) + self._endpoint.device.hass.async_create_task(zigbee_reply) if self.invalid_tries >= self.max_invalid_tries: self.alarm_status = AceCluster.AlarmStatus.Emergency @@ -228,7 +228,7 @@ class IasAce(ZigbeeChannel): AceCluster.AudibleNotification.Default_Sound, self.alarm_status, ) - self._ch_pool.hass.async_create_task(response) + self._endpoint.device.hass.async_create_task(response) def _send_panel_status_changed(self) -> None: """Handle the IAS ACE panel status changed command.""" @@ -238,7 +238,7 @@ class IasAce(ZigbeeChannel): AceCluster.AudibleNotification.Default_Sound, self.alarm_status, ) - self._ch_pool.hass.async_create_task(response) + self._endpoint.device.hass.async_create_task(response) def _get_bypassed_zone_list(self): """Handle the IAS ACE bypassed zone list command.""" @@ -249,10 +249,10 @@ class IasAce(ZigbeeChannel): """Handle the IAS ACE zone status command.""" -@registries.CHANNEL_ONLY_CLUSTERS.register(security.IasWd.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(security.IasWd.cluster_id) -class IasWd(ZigbeeChannel): - """IAS Warning Device channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(security.IasWd.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(security.IasWd.cluster_id) +class IasWd(ClusterHandler): + """IAS Warning Device cluster handler.""" @staticmethod def set_bit(destination_value, destination_bit, source_value, source_bit): @@ -332,21 +332,22 @@ class IasWd(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(security.IasZone.cluster_id) -class IASZoneChannel(ZigbeeChannel): - """Channel for the IASZone Zigbee cluster.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IasZone.cluster_id) +class IASZoneClusterHandler(ClusterHandler): + """Cluster handler for the IASZone Zigbee cluster.""" - ZCL_INIT_ATTRS = {"zone_status": True, "zone_state": False, "zone_type": True} + ZCL_INIT_ATTRS = {"zone_status": False, "zone_state": True, "zone_type": True} @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" if command_id == 0: - state = args[0] & 3 - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 2, "zone_status", state + zone_status = args[0] + # update attribute cache with new zone status + self.cluster.update_attribute( + IasZone.attributes_by_name["zone_status"].id, zone_status ) - self.debug("Updated alarm state: %s", state) + self.debug("Updated alarm state: %s", zone_status) elif command_id == 1: self.debug("Enroll requested") res = self._cluster.enroll_response(0, 0) @@ -355,14 +356,14 @@ class IASZoneChannel(ZigbeeChannel): async def async_configure(self): """Configure IAS device.""" await self.get_attribute_value("zone_type", from_cache=False) - if self._ch_pool.skip_configuration: - self.debug("skipping IASZoneChannel configuration") + if self._endpoint.device.skip_configuration: + self.debug("skipping IASZoneClusterHandler configuration") return - self.debug("started IASZoneChannel configuration") + self.debug("started IASZoneClusterHandler configuration") await self.bind() - ieee = self.cluster.endpoint.device.application.ieee + ieee = self.cluster.endpoint.device.application.state.node_info.ieee try: res = await self._cluster.write_attributes({"cie_addr": ieee}) @@ -383,17 +384,16 @@ class IASZoneChannel(ZigbeeChannel): self.debug("Sending pro-active IAS enroll response") self._cluster.create_catching_task(self._cluster.enroll_response(0, 0)) - self._status = ChannelStatus.CONFIGURED - self.debug("finished IASZoneChannel configuration") + self._status = ClusterHandlerStatus.CONFIGURED + self.debug("finished IASZoneClusterHandler configuration") @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" - if attrid == 2: - value = value & 3 + if attrid == IasZone.attributes_by_name["zone_status"].id: self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, - self.cluster.attributes.get(attrid, [attrid])[0], + "zone_status", value, ) diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py similarity index 74% rename from homeassistant/components/zha/core/channels/smartenergy.py rename to homeassistant/components/zha/core/cluster_handlers/smartenergy.py index 03d11356f0..1cb647ea31 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py @@ -1,4 +1,4 @@ -"""Smart energy channels module for Zigbee Home Automation.""" +"""Smart energy cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations import enum @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING import zigpy.zcl from zigpy.zcl.clusters import smartenergy +from . import AttrReportConfig, ClusterHandler from .. import registries from ..const import ( REPORT_CONFIG_ASAP, @@ -15,55 +16,60 @@ from ..const import ( REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) -from .base import AttrReportConfig, ZigbeeChannel if TYPE_CHECKING: - from . import ChannelPool + from ..endpoint import Endpoint -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Calendar.cluster_id) -class Calendar(ZigbeeChannel): - """Calendar channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Calendar.cluster_id) +class Calendar(ClusterHandler): + """Calendar cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.DeviceManagement.cluster_id) -class DeviceManagement(ZigbeeChannel): - """Device Management channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + smartenergy.DeviceManagement.cluster_id +) +class DeviceManagement(ClusterHandler): + """Device Management cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Drlc.cluster_id) -class Drlc(ZigbeeChannel): - """Demand Response and Load Control channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Drlc.cluster_id) +class Drlc(ClusterHandler): + """Demand Response and Load Control cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.EnergyManagement.cluster_id) -class EnergyManagement(ZigbeeChannel): - """Energy Management channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + smartenergy.EnergyManagement.cluster_id +) +class EnergyManagement(ClusterHandler): + """Energy Management cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Events.cluster_id) -class Events(ZigbeeChannel): - """Event channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Events.cluster_id) +class Events(ClusterHandler): + """Event cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.KeyEstablishment.cluster_id) -class KeyEstablishment(ZigbeeChannel): - """Key Establishment channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + smartenergy.KeyEstablishment.cluster_id +) +class KeyEstablishment(ClusterHandler): + """Key Establishment cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.MduPairing.cluster_id) -class MduPairing(ZigbeeChannel): - """Pairing channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.MduPairing.cluster_id) +class MduPairing(ClusterHandler): + """Pairing cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Messaging.cluster_id) -class Messaging(ZigbeeChannel): - """Messaging channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Messaging.cluster_id) +class Messaging(ClusterHandler): + """Messaging cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Metering.cluster_id) -class Metering(ZigbeeChannel): - """Metering channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Metering.cluster_id) +class Metering(ClusterHandler): + """Metering cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="instantaneous_demand", config=REPORT_CONFIG_OP), @@ -137,9 +143,9 @@ class Metering(ZigbeeChannel): DEMAND = 0 SUMMATION = 1 - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: """Initialize Metering.""" - super().__init__(cluster, ch_pool) + super().__init__(cluster, endpoint) self._format_spec: str | None = None self._summa_format: str | None = None @@ -176,7 +182,7 @@ class Metering(ZigbeeChannel): """Return unit of measurement.""" return self.cluster.get("unit_of_measure") - async def async_initialize_channel_specific(self, from_cache: bool) -> None: + async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: """Fetch config from device and updates format specifier.""" fmting = self.cluster.get( @@ -249,16 +255,16 @@ class Metering(ZigbeeChannel): summa_formatter = partialmethod(_formatter_function, FormatSelector.SUMMATION) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Prepayment.cluster_id) -class Prepayment(ZigbeeChannel): - """Prepayment channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Prepayment.cluster_id) +class Prepayment(ClusterHandler): + """Prepayment cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Price.cluster_id) -class Price(ZigbeeChannel): - """Price channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Price.cluster_id) +class Price(ClusterHandler): + """Price cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Tunneling.cluster_id) -class Tunneling(ZigbeeChannel): - """Tunneling channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Tunneling.cluster_id) +class Tunneling(ClusterHandler): + """Tunneling cluster handler.""" diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 4c10a2328a..de4272032d 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -64,39 +64,39 @@ ATTR_WARNING_DEVICE_STROBE_INTENSITY = "intensity" BAUD_RATES = [2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000] BINDINGS = "bindings" -CHANNEL_ACCELEROMETER = "accelerometer" -CHANNEL_BINARY_INPUT = "binary_input" -CHANNEL_ANALOG_INPUT = "analog_input" -CHANNEL_ANALOG_OUTPUT = "analog_output" -CHANNEL_ATTRIBUTE = "attribute" -CHANNEL_BASIC = "basic" -CHANNEL_COLOR = "light_color" -CHANNEL_COVER = "window_covering" -CHANNEL_DEVICE_TEMPERATURE = "device_temperature" -CHANNEL_DOORLOCK = "door_lock" -CHANNEL_ELECTRICAL_MEASUREMENT = "electrical_measurement" -CHANNEL_EVENT_RELAY = "event_relay" -CHANNEL_FAN = "fan" -CHANNEL_HUMIDITY = "humidity" -CHANNEL_SOIL_MOISTURE = "soil_moisture" -CHANNEL_LEAF_WETNESS = "leaf_wetness" -CHANNEL_IAS_ACE = "ias_ace" -CHANNEL_IAS_WD = "ias_wd" -CHANNEL_IDENTIFY = "identify" -CHANNEL_ILLUMINANCE = "illuminance" -CHANNEL_LEVEL = ATTR_LEVEL -CHANNEL_MULTISTATE_INPUT = "multistate_input" -CHANNEL_OCCUPANCY = "occupancy" -CHANNEL_ON_OFF = "on_off" -CHANNEL_POWER_CONFIGURATION = "power" -CHANNEL_PRESSURE = "pressure" -CHANNEL_SHADE = "shade" -CHANNEL_SMARTENERGY_METERING = "smartenergy_metering" -CHANNEL_TEMPERATURE = "temperature" -CHANNEL_THERMOSTAT = "thermostat" -CHANNEL_ZDO = "zdo" -CHANNEL_ZONE = ZONE = "ias_zone" -CHANNEL_INOVELLI = "inovelli_vzm31sn_cluster" +CLUSTER_HANDLER_ACCELEROMETER = "accelerometer" +CLUSTER_HANDLER_BINARY_INPUT = "binary_input" +CLUSTER_HANDLER_ANALOG_INPUT = "analog_input" +CLUSTER_HANDLER_ANALOG_OUTPUT = "analog_output" +CLUSTER_HANDLER_ATTRIBUTE = "attribute" +CLUSTER_HANDLER_BASIC = "basic" +CLUSTER_HANDLER_COLOR = "light_color" +CLUSTER_HANDLER_COVER = "window_covering" +CLUSTER_HANDLER_DEVICE_TEMPERATURE = "device_temperature" +CLUSTER_HANDLER_DOORLOCK = "door_lock" +CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT = "electrical_measurement" +CLUSTER_HANDLER_EVENT_RELAY = "event_relay" +CLUSTER_HANDLER_FAN = "fan" +CLUSTER_HANDLER_HUMIDITY = "humidity" +CLUSTER_HANDLER_SOIL_MOISTURE = "soil_moisture" +CLUSTER_HANDLER_LEAF_WETNESS = "leaf_wetness" +CLUSTER_HANDLER_IAS_ACE = "ias_ace" +CLUSTER_HANDLER_IAS_WD = "ias_wd" +CLUSTER_HANDLER_IDENTIFY = "identify" +CLUSTER_HANDLER_ILLUMINANCE = "illuminance" +CLUSTER_HANDLER_LEVEL = ATTR_LEVEL +CLUSTER_HANDLER_MULTISTATE_INPUT = "multistate_input" +CLUSTER_HANDLER_OCCUPANCY = "occupancy" +CLUSTER_HANDLER_ON_OFF = "on_off" +CLUSTER_HANDLER_POWER_CONFIGURATION = "power" +CLUSTER_HANDLER_PRESSURE = "pressure" +CLUSTER_HANDLER_SHADE = "shade" +CLUSTER_HANDLER_SMARTENERGY_METERING = "smartenergy_metering" +CLUSTER_HANDLER_TEMPERATURE = "temperature" +CLUSTER_HANDLER_THERMOSTAT = "thermostat" +CLUSTER_HANDLER_ZDO = "zdo" +CLUSTER_HANDLER_ZONE = ZONE = "ias_zone" +CLUSTER_HANDLER_INOVELLI = "inovelli_vzm31sn_cluster" CLUSTER_COMMAND_SERVER = "server" CLUSTER_COMMANDS_CLIENT = "client_commands" @@ -137,6 +137,8 @@ CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_FLOWCONTROL = "flow_control" +CONF_NWK = "network" +CONF_NWK_CHANNEL = "channel" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_USE_THREAD = "use_thread" @@ -328,15 +330,15 @@ REPORT_CONFIG_OP = ( SENSOR_ACCELERATION = "acceleration" SENSOR_BATTERY = "battery" -SENSOR_ELECTRICAL_MEASUREMENT = CHANNEL_ELECTRICAL_MEASUREMENT +SENSOR_ELECTRICAL_MEASUREMENT = CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT SENSOR_GENERIC = "generic" -SENSOR_HUMIDITY = CHANNEL_HUMIDITY -SENSOR_ILLUMINANCE = CHANNEL_ILLUMINANCE +SENSOR_HUMIDITY = CLUSTER_HANDLER_HUMIDITY +SENSOR_ILLUMINANCE = CLUSTER_HANDLER_ILLUMINANCE SENSOR_METERING = "metering" -SENSOR_OCCUPANCY = CHANNEL_OCCUPANCY +SENSOR_OCCUPANCY = CLUSTER_HANDLER_OCCUPANCY SENSOR_OPENING = "opening" -SENSOR_PRESSURE = CHANNEL_PRESSURE -SENSOR_TEMPERATURE = CHANNEL_TEMPERATURE +SENSOR_PRESSURE = CLUSTER_HANDLER_PRESSURE +SENSOR_TEMPERATURE = CLUSTER_HANDLER_TEMPERATURE SENSOR_TYPE = "sensor_type" SIGNAL_ADD_ENTITIES = "zha_add_new_entities" @@ -379,12 +381,12 @@ WARNING_DEVICE_SQUAWK_MODE_ARMED = 0 WARNING_DEVICE_SQUAWK_MODE_DISARMED = 1 ZHA_DISCOVERY_NEW = "zha_discovery_new_{}" -ZHA_CHANNEL_MSG = "zha_channel_message" -ZHA_CHANNEL_MSG_BIND = "zha_channel_bind" -ZHA_CHANNEL_MSG_CFG_RPT = "zha_channel_configure_reporting" -ZHA_CHANNEL_MSG_DATA = "zha_channel_msg_data" -ZHA_CHANNEL_CFG_DONE = "zha_channel_cfg_done" -ZHA_CHANNEL_READS_PER_REQ = 5 +ZHA_CLUSTER_HANDLER_MSG = "zha_channel_message" +ZHA_CLUSTER_HANDLER_MSG_BIND = "zha_channel_bind" +ZHA_CLUSTER_HANDLER_MSG_CFG_RPT = "zha_channel_configure_reporting" +ZHA_CLUSTER_HANDLER_MSG_DATA = "zha_channel_msg_data" +ZHA_CLUSTER_HANDLER_CFG_DONE = "zha_channel_cfg_done" +ZHA_CLUSTER_HANDLER_READS_PER_REQ = 5 ZHA_EVENT = "zha_event" ZHA_GW_MSG = "zha_gateway_message" ZHA_GW_MSG_DEVICE_FULL_INIT = "device_fully_initialized" diff --git a/homeassistant/components/zha/core/decorators.py b/homeassistant/components/zha/core/decorators.py index 5cf9322170..71bfd510be 100644 --- a/homeassistant/components/zha/core/decorators.py +++ b/homeassistant/components/zha/core/decorators.py @@ -13,10 +13,10 @@ class DictRegistry(dict[int | str, _TypeT]): def register(self, name: int | str) -> Callable[[_TypeT], _TypeT]: """Return decorator to register item with a specific name.""" - def decorator(channel: _TypeT) -> _TypeT: - """Register decorated channel or item.""" - self[name] = channel - return channel + def decorator(cluster_handler: _TypeT) -> _TypeT: + """Register decorated cluster handler or item.""" + self[name] = cluster_handler + return cluster_handler return decorator @@ -27,9 +27,9 @@ class SetRegistry(set[int | str]): def register(self, name: int | str) -> Callable[[_TypeT], _TypeT]: """Return decorator to register item with a specific name.""" - def decorator(channel: _TypeT) -> _TypeT: - """Register decorated channel or item.""" + def decorator(cluster_handler: _TypeT) -> _TypeT: + """Register decorated cluster handler or item.""" self.add(name) - return channel + return cluster_handler return decorator diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 17ec04fa9e..139acb2392 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -23,7 +23,7 @@ from zigpy.zcl.clusters.general import Groups, Identify from zigpy.zcl.foundation import Status as ZclStatus, ZCLCommandDef import zigpy.zdo.types as zdo_types -from homeassistant.const import ATTR_COMMAND, ATTR_NAME +from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID, ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import ( @@ -32,7 +32,8 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.event import async_track_time_interval -from . import channels +from . import const +from .cluster_handlers import ClusterHandler, ZDOClusterHandler from .const import ( ATTR_ACTIVE_COORDINATOR, ATTR_ARGS, @@ -81,10 +82,11 @@ from .const import ( UNKNOWN_MODEL, ZHA_OPTIONS, ) +from .endpoint import Endpoint from .helpers import LogMixin, async_get_zha_config_value, convert_to_zcl_values if TYPE_CHECKING: - from ..api import ClusterBinding + from ..websocket_api import ClusterBinding from .gateway import ZHAGateway _LOGGER = logging.getLogger(__name__) @@ -139,14 +141,26 @@ class ZHADevice(LogMixin): CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY, ) - keep_alive_interval = random.randint(*_UPDATE_ALIVE_INTERVAL) - self.unsubs.append( - async_track_time_interval( - self.hass, self._check_available, timedelta(seconds=keep_alive_interval) - ) - ) + self._zdo_handler: ZDOClusterHandler = ZDOClusterHandler(self) + self._power_config_ch: ClusterHandler | None = None + self._identify_ch: ClusterHandler | None = None + self._basic_ch: ClusterHandler | None = None self.status: DeviceStatus = DeviceStatus.CREATED - self._channels = channels.Channels(self) + + self._endpoints: dict[int, Endpoint] = {} + for ep_id, endpoint in zigpy_device.endpoints.items(): + if ep_id != 0: + self._endpoints[ep_id] = Endpoint.new(endpoint, self) + + if not self.is_coordinator: + keep_alive_interval = random.randint(*_UPDATE_ALIVE_INTERVAL) + self.unsubs.append( + async_track_time_interval( + self.hass, + self._check_available, + timedelta(seconds=keep_alive_interval), + ) + ) @property def device_id(self) -> str: @@ -162,17 +176,6 @@ class ZHADevice(LogMixin): """Return underlying Zigpy device.""" return self._zigpy_device - @property - def channels(self) -> channels.Channels: - """Return ZHA channels.""" - return self._channels - - @channels.setter - def channels(self, value: channels.Channels) -> None: - """Channels setter.""" - assert isinstance(value, channels.Channels) - self._channels = value - @property def name(self) -> str: """Return device name.""" @@ -335,12 +338,62 @@ class ZHADevice(LogMixin): """Set device availability.""" self._available = new_availability + @property + def power_configuration_ch(self) -> ClusterHandler | None: + """Return power configuration cluster handler.""" + return self._power_config_ch + + @power_configuration_ch.setter + def power_configuration_ch(self, cluster_handler: ClusterHandler) -> None: + """Power configuration cluster handler setter.""" + if self._power_config_ch is None: + self._power_config_ch = cluster_handler + + @property + def basic_ch(self) -> ClusterHandler | None: + """Return basic cluster handler.""" + return self._basic_ch + + @basic_ch.setter + def basic_ch(self, cluster_handler: ClusterHandler) -> None: + """Set the basic cluster handler.""" + if self._basic_ch is None: + self._basic_ch = cluster_handler + + @property + def identify_ch(self) -> ClusterHandler | None: + """Return power configuration cluster handler.""" + return self._identify_ch + + @identify_ch.setter + def identify_ch(self, cluster_handler: ClusterHandler) -> None: + """Power configuration cluster handler setter.""" + if self._identify_ch is None: + self._identify_ch = cluster_handler + + @property + def zdo_cluster_handler(self) -> ZDOClusterHandler: + """Return ZDO cluster handler.""" + return self._zdo_handler + + @property + def endpoints(self) -> dict[int, Endpoint]: + """Return the endpoints for this device.""" + return self._endpoints + @property def zigbee_signature(self) -> dict[str, Any]: """Get zigbee signature for this device.""" return { ATTR_NODE_DESCRIPTOR: str(self._zigpy_device.node_desc), - ATTR_ENDPOINTS: self._channels.zigbee_signature, + ATTR_ENDPOINTS: { + signature[0]: signature[1] + for signature in [ + endpoint.zigbee_signature for endpoint in self._endpoints.values() + ] + }, + ATTR_MANUFACTURER: self.manufacturer, + ATTR_MODEL: self.model, } @classmethod @@ -353,11 +406,10 @@ class ZHADevice(LogMixin): ) -> Self: """Create new device.""" zha_dev = cls(hass, zigpy_dev, gateway) - zha_dev.channels = channels.Channels.new(zha_dev) zha_dev.unsubs.append( async_dispatcher_connect( hass, - SIGNAL_UPDATE_DEVICE.format(zha_dev.channels.unique_id), + SIGNAL_UPDATE_DEVICE.format(str(zha_dev.ieee)), zha_dev.async_update_sw_build_id, ) ) @@ -393,7 +445,7 @@ class ZHADevice(LogMixin): if ( self._checkins_missed_count >= _CHECKIN_GRACE_PERIODS or self.manufacturer == "LUMI" - or not self._channels.pools + or not self._endpoints ): self.debug( ( @@ -410,14 +462,13 @@ class ZHADevice(LogMixin): "Attempting to checkin with device - missed checkins: %s", self._checkins_missed_count, ) - try: - pool = self._channels.pools[0] - basic_ch = pool.all_channels[f"{pool.id}:0x0000"] - except KeyError: + if not self.basic_ch: self.debug("does not have a mandatory basic cluster") self.update_available(False) return - res = await basic_ch.get_attribute_value(ATTR_MANUFACTURER, from_cache=False) + res = await self.basic_ch.get_attribute_value( + ATTR_MANUFACTURER, from_cache=False + ) if res is not None: self._checkins_missed_count = 0 @@ -435,22 +486,35 @@ class ZHADevice(LogMixin): availability_changed = self.available ^ available self.available = available if availability_changed and available: - # reinit channels then signal entities + # reinit cluster handlers then signal entities self.debug( "Device availability changed and device became available," - " reinitializing channels" + " reinitializing cluster handlers" ) self.hass.async_create_task(self._async_became_available()) return if availability_changed and not available: self.debug("Device availability changed and device became unavailable") - self._channels.zha_send_event( + self.zha_send_event( { "device_event_type": "device_offline", }, ) async_dispatcher_send(self.hass, f"{self._available_signal}_entity") + @callback + def zha_send_event(self, event_data: dict[str, str | int]) -> None: + """Relay events to hass.""" + self.hass.bus.async_fire( + const.ZHA_EVENT, + { + const.ATTR_DEVICE_IEEE: str(self.ieee), + const.ATTR_UNIQUE_ID: str(self.ieee), + ATTR_DEVICE_ID: self.device_id, + **event_data, + }, + ) + async def _async_became_available(self) -> None: """Update device availability and signal entities.""" await self.async_initialize(False) @@ -489,23 +553,41 @@ class ZHADevice(LogMixin): True, ) self.debug("started configuration") - await self._channels.async_configure() + await self._zdo_handler.async_configure() + self._zdo_handler.debug("'async_configure' stage succeeded") + await asyncio.gather( + *(endpoint.async_configure() for endpoint in self._endpoints.values()) + ) + async_dispatcher_send( + self.hass, + const.ZHA_CLUSTER_HANDLER_MSG, + { + const.ATTR_TYPE: const.ZHA_CLUSTER_HANDLER_CFG_DONE, + }, + ) self.debug("completed configuration") if ( should_identify - and self._channels.identify_ch is not None + and self.identify_ch is not None and not self.skip_configuration ): - await self._channels.identify_ch.trigger_effect( + await self.identify_ch.trigger_effect( effect_id=Identify.EffectIdentifier.Okay, effect_variant=Identify.EffectVariant.Default, ) async def async_initialize(self, from_cache: bool = False) -> None: - """Initialize channels.""" + """Initialize cluster handlers.""" self.debug("started initialization") - await self._channels.async_initialize(from_cache) + await self._zdo_handler.async_initialize(from_cache) + self._zdo_handler.debug("'async_initialize' stage succeeded") + await asyncio.gather( + *( + endpoint.async_initialize(from_cache) + for endpoint in self._endpoints.values() + ) + ) self.debug("power source: %s", self.power_source) self.status = DeviceStatus.INITIALIZED self.debug("completed initialization") diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index d256b98cfb..e8b6f5f830 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -33,12 +33,27 @@ from .. import ( # noqa: F401 pylint: disable=unused-import, siren, switch, ) -from .channels import base + +# importing cluster handlers updates registries +from .cluster_handlers import ( # noqa: F401 pylint: disable=unused-import, + ClusterHandler, + closures, + general, + homeautomation, + hvac, + lighting, + lightlink, + manufacturerspecific, + measurement, + protocol, + security, + smartenergy, +) if TYPE_CHECKING: from ..entity import ZhaEntity - from .channels import ChannelPool from .device import ZHADevice + from .endpoint import Endpoint from .gateway import ZHAGateway from .group import ZHAGroup @@ -51,7 +66,7 @@ async def async_add_entities( entities: list[ tuple[ type[ZhaEntity], - tuple[str, ZHADevice, list[base.ZigbeeChannel]], + tuple[str, ZHADevice, list[ClusterHandler]], ] ], ) -> None: @@ -65,49 +80,56 @@ async def async_add_entities( class ProbeEndpoint: - """All discovered channels and entities of an endpoint.""" + """All discovered cluster handlers and entities of an endpoint.""" def __init__(self) -> None: """Initialize instance.""" self._device_configs: ConfigType = {} @callback - def discover_entities(self, channel_pool: ChannelPool) -> None: + def discover_entities(self, endpoint: Endpoint) -> None: """Process an endpoint on a zigpy device.""" - self.discover_by_device_type(channel_pool) - self.discover_multi_entities(channel_pool) - self.discover_by_cluster_id(channel_pool) - self.discover_multi_entities(channel_pool, config_diagnostic_entities=True) + _LOGGER.debug( + "Discovering entities for endpoint: %s-%s", + str(endpoint.device.ieee), + endpoint.id, + ) + self.discover_by_device_type(endpoint) + self.discover_multi_entities(endpoint) + self.discover_by_cluster_id(endpoint) + self.discover_multi_entities(endpoint, config_diagnostic_entities=True) zha_regs.ZHA_ENTITIES.clean_up() @callback - def discover_by_device_type(self, channel_pool: ChannelPool) -> None: + def discover_by_device_type(self, endpoint: Endpoint) -> None: """Process an endpoint on a zigpy device.""" - unique_id = channel_pool.unique_id + unique_id = endpoint.unique_id - component: str | None = self._device_configs.get(unique_id, {}).get(CONF_TYPE) - if component is None: - ep_profile_id = channel_pool.endpoint.profile_id - ep_device_type = channel_pool.endpoint.device_type - component = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) + platform: str | None = self._device_configs.get(unique_id, {}).get(CONF_TYPE) + if platform is None: + ep_profile_id = endpoint.zigpy_endpoint.profile_id + ep_device_type = endpoint.zigpy_endpoint.device_type + platform = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) - if component and component in zha_const.PLATFORMS: - channels = channel_pool.unclaimed_channels() - entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( - component, - channel_pool.manufacturer, - channel_pool.model, - channels, - channel_pool.quirk_class, + if platform and platform in zha_const.PLATFORMS: + cluster_handlers = endpoint.unclaimed_cluster_handlers() + platform_entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( + platform, + endpoint.device.manufacturer, + endpoint.device.model, + cluster_handlers, + endpoint.device.quirk_class, ) - if entity_class is None: + if platform_entity_class is None: return - channel_pool.claim_channels(claimed) - channel_pool.async_new_entity(component, entity_class, unique_id, claimed) + endpoint.claim_cluster_handlers(claimed) + endpoint.async_new_entity( + platform, platform_entity_class, unique_id, claimed + ) @callback - def discover_by_cluster_id(self, channel_pool: ChannelPool) -> None: + def discover_by_cluster_id(self, endpoint: Endpoint) -> None: """Process an endpoint on a zigpy device.""" items = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.items() @@ -116,124 +138,127 @@ class ProbeEndpoint: for cluster_class, match in items if not isinstance(cluster_class, int) } - remaining_channels = channel_pool.unclaimed_channels() - for channel in remaining_channels: - if channel.cluster.cluster_id in zha_regs.CHANNEL_ONLY_CLUSTERS: - channel_pool.claim_channels([channel]) + remaining_cluster_handlers = endpoint.unclaimed_cluster_handlers() + for cluster_handler in remaining_cluster_handlers: + if ( + cluster_handler.cluster.cluster_id + in zha_regs.CLUSTER_HANDLER_ONLY_CLUSTERS + ): + endpoint.claim_cluster_handlers([cluster_handler]) continue - component = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.get( - channel.cluster.cluster_id + platform = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.get( + cluster_handler.cluster.cluster_id ) - if component is None: + if platform is None: for cluster_class, match in single_input_clusters.items(): - if isinstance(channel.cluster, cluster_class): - component = match + if isinstance(cluster_handler.cluster, cluster_class): + platform = match break - self.probe_single_cluster(component, channel, channel_pool) + self.probe_single_cluster(platform, cluster_handler, endpoint) # until we can get rid of registries - self.handle_on_off_output_cluster_exception(channel_pool) + self.handle_on_off_output_cluster_exception(endpoint) @staticmethod def probe_single_cluster( - component: Platform | None, - channel: base.ZigbeeChannel, - ep_channels: ChannelPool, + platform: Platform | None, + cluster_handler: ClusterHandler, + endpoint: Endpoint, ) -> None: """Probe specified cluster for specific component.""" - if component is None or component not in zha_const.PLATFORMS: + if platform is None or platform not in zha_const.PLATFORMS: return - channel_list = [channel] - unique_id = f"{ep_channels.unique_id}-{channel.cluster.cluster_id}" + cluster_handler_list = [cluster_handler] + unique_id = f"{endpoint.unique_id}-{cluster_handler.cluster.cluster_id}" entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( - component, - ep_channels.manufacturer, - ep_channels.model, - channel_list, - ep_channels.quirk_class, + platform, + endpoint.device.manufacturer, + endpoint.device.model, + cluster_handler_list, + endpoint.device.quirk_class, ) if entity_class is None: return - ep_channels.claim_channels(claimed) - ep_channels.async_new_entity(component, entity_class, unique_id, claimed) + endpoint.claim_cluster_handlers(claimed) + endpoint.async_new_entity(platform, entity_class, unique_id, claimed) - def handle_on_off_output_cluster_exception(self, ep_channels: ChannelPool) -> None: + def handle_on_off_output_cluster_exception(self, endpoint: Endpoint) -> None: """Process output clusters of the endpoint.""" - profile_id = ep_channels.endpoint.profile_id - device_type = ep_channels.endpoint.device_type + profile_id = endpoint.zigpy_endpoint.profile_id + device_type = endpoint.zigpy_endpoint.device_type if device_type in zha_regs.REMOTE_DEVICE_TYPES.get(profile_id, []): return - for cluster_id, cluster in ep_channels.endpoint.out_clusters.items(): - component = zha_regs.SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.get( + for cluster_id, cluster in endpoint.zigpy_endpoint.out_clusters.items(): + platform = zha_regs.SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.get( cluster.cluster_id ) - if component is None: + if platform is None: continue - channel_class = zha_regs.ZIGBEE_CHANNEL_REGISTRY.get( - cluster_id, base.ZigbeeChannel + cluster_handler_class = zha_regs.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id, ClusterHandler ) - channel = channel_class(cluster, ep_channels) - self.probe_single_cluster(component, channel, ep_channels) + cluster_handler = cluster_handler_class(cluster, endpoint) + self.probe_single_cluster(platform, cluster_handler, endpoint) @staticmethod @callback def discover_multi_entities( - channel_pool: ChannelPool, + endpoint: Endpoint, config_diagnostic_entities: bool = False, ) -> None: """Process an endpoint on and discover multiple entities.""" - ep_profile_id = channel_pool.endpoint.profile_id - ep_device_type = channel_pool.endpoint.device_type + ep_profile_id = endpoint.zigpy_endpoint.profile_id + ep_device_type = endpoint.zigpy_endpoint.device_type cmpt_by_dev_type = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) if config_diagnostic_entities: matches, claimed = zha_regs.ZHA_ENTITIES.get_config_diagnostic_entity( - channel_pool.manufacturer, - channel_pool.model, - list(channel_pool.all_channels.values()), - channel_pool.quirk_class, + endpoint.device.manufacturer, + endpoint.device.model, + list(endpoint.all_cluster_handlers.values()), + endpoint.device.quirk_class, ) else: matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity( - channel_pool.manufacturer, - channel_pool.model, - channel_pool.unclaimed_channels(), - channel_pool.quirk_class, + endpoint.device.manufacturer, + endpoint.device.model, + endpoint.unclaimed_cluster_handlers(), + endpoint.device.quirk_class, ) - channel_pool.claim_channels(claimed) - for component, ent_n_chan_list in matches.items(): - for entity_and_channel in ent_n_chan_list: + endpoint.claim_cluster_handlers(claimed) + for platform, ent_n_handler_list in matches.items(): + for entity_and_handler in ent_n_handler_list: _LOGGER.debug( "'%s' component -> '%s' using %s", - component, - entity_and_channel.entity_class.__name__, - [ch.name for ch in entity_and_channel.claimed_channel], + platform, + entity_and_handler.entity_class.__name__, + [ch.name for ch in entity_and_handler.claimed_cluster_handlers], ) - for component, ent_n_chan_list in matches.items(): - for entity_and_channel in ent_n_chan_list: - if component == cmpt_by_dev_type: + for platform, ent_n_handler_list in matches.items(): + for entity_and_handler in ent_n_handler_list: + if platform == cmpt_by_dev_type: # for well known device types, like thermostats we'll take only 1st class - channel_pool.async_new_entity( - component, - entity_and_channel.entity_class, - channel_pool.unique_id, - entity_and_channel.claimed_channel, + endpoint.async_new_entity( + platform, + entity_and_handler.entity_class, + endpoint.unique_id, + entity_and_handler.claimed_cluster_handlers, ) break - first_ch = entity_and_channel.claimed_channel[0] - channel_pool.async_new_entity( - component, - entity_and_channel.entity_class, - f"{channel_pool.unique_id}-{first_ch.cluster.cluster_id}", - entity_and_channel.claimed_channel, + first_ch = entity_and_handler.claimed_cluster_handlers[0] + endpoint.async_new_entity( + platform, + entity_and_handler.entity_class, + f"{endpoint.unique_id}-{first_ch.cluster.cluster_id}", + entity_and_handler.claimed_cluster_handlers, ) def initialize(self, hass: HomeAssistant) -> None: diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py new file mode 100644 index 0000000000..c0a727414b --- /dev/null +++ b/homeassistant/components/zha/core/endpoint.py @@ -0,0 +1,225 @@ +"""Representation of a Zigbee endpoint for zha.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import logging +from typing import TYPE_CHECKING, Any, Final, TypeVar + +import zigpy +from zigpy.typing import EndpointType as ZigpyEndpointType + +from homeassistant.const import Platform +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from . import const, discovery, registries +from .cluster_handlers import ClusterHandler +from .cluster_handlers.general import MultistateInput + +if TYPE_CHECKING: + from .cluster_handlers import ClientClusterHandler + from .device import ZHADevice + +ATTR_DEVICE_TYPE: Final[str] = "device_type" +ATTR_PROFILE_ID: Final[str] = "profile_id" +ATTR_IN_CLUSTERS: Final[str] = "input_clusters" +ATTR_OUT_CLUSTERS: Final[str] = "output_clusters" + +_LOGGER = logging.getLogger(__name__) +CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) # pylint: disable=invalid-name + + +class Endpoint: + """Endpoint for a zha device.""" + + def __init__(self, zigpy_endpoint: ZigpyEndpointType, device: ZHADevice) -> None: + """Initialize instance.""" + assert zigpy_endpoint is not None + assert device is not None + self._zigpy_endpoint: ZigpyEndpointType = zigpy_endpoint + self._device: ZHADevice = device + self._all_cluster_handlers: dict[str, ClusterHandler] = {} + self._claimed_cluster_handlers: dict[str, ClusterHandler] = {} + self._client_cluster_handlers: dict[str, ClientClusterHandler] = {} + self._unique_id: str = f"{str(device.ieee)}-{zigpy_endpoint.endpoint_id}" + + @property + def device(self) -> ZHADevice: + """Return the device this endpoint belongs to.""" + return self._device + + @property + def all_cluster_handlers(self) -> dict[str, ClusterHandler]: + """All server cluster handlers of an endpoint.""" + return self._all_cluster_handlers + + @property + def claimed_cluster_handlers(self) -> dict[str, ClusterHandler]: + """Cluster handlers in use.""" + return self._claimed_cluster_handlers + + @property + def client_cluster_handlers(self) -> dict[str, ClientClusterHandler]: + """Return a dict of client cluster handlers.""" + return self._client_cluster_handlers + + @property + def zigpy_endpoint(self) -> ZigpyEndpointType: + """Return endpoint of zigpy device.""" + return self._zigpy_endpoint + + @property + def id(self) -> int: + """Return endpoint id.""" + return self._zigpy_endpoint.endpoint_id + + @property + def unique_id(self) -> str: + """Return the unique id for this endpoint.""" + return self._unique_id + + @property + def zigbee_signature(self) -> tuple[int, dict[str, Any]]: + """Get the zigbee signature for the endpoint this pool represents.""" + return ( + self.id, + { + ATTR_PROFILE_ID: f"0x{self._zigpy_endpoint.profile_id:04x}" + if self._zigpy_endpoint.profile_id is not None + else "", + ATTR_DEVICE_TYPE: f"0x{self._zigpy_endpoint.device_type:04x}" + if self._zigpy_endpoint.device_type is not None + else "", + ATTR_IN_CLUSTERS: [ + f"0x{cluster_id:04x}" + for cluster_id in sorted(self._zigpy_endpoint.in_clusters) + ], + ATTR_OUT_CLUSTERS: [ + f"0x{cluster_id:04x}" + for cluster_id in sorted(self._zigpy_endpoint.out_clusters) + ], + }, + ) + + @classmethod + def new(cls, zigpy_endpoint: ZigpyEndpointType, device: ZHADevice) -> Endpoint: + """Create new endpoint and populate cluster handlers.""" + endpoint = cls(zigpy_endpoint, device) + endpoint.add_all_cluster_handlers() + endpoint.add_client_cluster_handlers() + if not device.is_coordinator: + discovery.PROBE.discover_entities(endpoint) + return endpoint + + def add_all_cluster_handlers(self) -> None: + """Create and add cluster handlers for all input clusters.""" + for cluster_id, cluster in self.zigpy_endpoint.in_clusters.items(): + cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id, ClusterHandler + ) + + # Allow cluster handler to filter out bad matches + if not cluster_handler_class.matches(cluster, self): + cluster_handler_class = ClusterHandler + + _LOGGER.info( + "Creating cluster handler for cluster id: %s class: %s", + cluster_id, + cluster_handler_class, + ) + # really ugly hack to deal with xiaomi using the door lock cluster + # incorrectly. + if ( + hasattr(cluster, "ep_attribute") + and cluster_id == zigpy.zcl.clusters.closures.DoorLock.cluster_id + and cluster.ep_attribute == "multistate_input" + ): + cluster_handler_class = MultistateInput + # end of ugly hack + cluster_handler = cluster_handler_class(cluster, self) + if cluster_handler.name == const.CLUSTER_HANDLER_POWER_CONFIGURATION: + self._device.power_configuration_ch = cluster_handler + elif cluster_handler.name == const.CLUSTER_HANDLER_IDENTIFY: + self._device.identify_ch = cluster_handler + elif cluster_handler.name == const.CLUSTER_HANDLER_BASIC: + self._device.basic_ch = cluster_handler + self._all_cluster_handlers[cluster_handler.id] = cluster_handler + + def add_client_cluster_handlers(self) -> None: + """Create client cluster handlers for all output clusters if in the registry.""" + for ( + cluster_id, + cluster_handler_class, + ) in registries.CLIENT_CLUSTER_HANDLER_REGISTRY.items(): + cluster = self.zigpy_endpoint.out_clusters.get(cluster_id) + if cluster is not None: + cluster_handler = cluster_handler_class(cluster, self) + self.client_cluster_handlers[cluster_handler.id] = cluster_handler + + async def async_initialize(self, from_cache: bool = False) -> None: + """Initialize claimed cluster handlers.""" + await self._execute_handler_tasks("async_initialize", from_cache) + + async def async_configure(self) -> None: + """Configure claimed cluster handlers.""" + await self._execute_handler_tasks("async_configure") + + async def _execute_handler_tasks(self, func_name: str, *args: Any) -> None: + """Add a throttled cluster handler task and swallow exceptions.""" + cluster_handlers = [ + *self.claimed_cluster_handlers.values(), + *self.client_cluster_handlers.values(), + ] + tasks = [getattr(ch, func_name)(*args) for ch in cluster_handlers] + results = await asyncio.gather(*tasks, return_exceptions=True) + for cluster_handler, outcome in zip(cluster_handlers, results): + if isinstance(outcome, Exception): + cluster_handler.warning( + "'%s' stage failed: %s", func_name, str(outcome), exc_info=outcome + ) + continue + cluster_handler.debug("'%s' stage succeeded", func_name) + + def async_new_entity( + self, + platform: Platform | str, + entity_class: CALLABLE_T, + unique_id: str, + cluster_handlers: list[ClusterHandler], + ) -> None: + """Create a new entity.""" + from .device import DeviceStatus # pylint: disable=import-outside-toplevel + + if self.device.status == DeviceStatus.INITIALIZED: + return + + self.device.hass.data[const.DATA_ZHA][platform].append( + (entity_class, (unique_id, self.device, cluster_handlers)) + ) + + @callback + def async_send_signal(self, signal: str, *args: Any) -> None: + """Send a signal through hass dispatcher.""" + async_dispatcher_send(self.device.hass, signal, *args) + + def send_event(self, signal: dict[str, Any]) -> None: + """Broadcast an event from this endpoint.""" + signal["endpoint"] = { + "id": self.id, + "unique_id": self.unique_id, + } + self.device.zha_send_event(signal) + + def claim_cluster_handlers(self, cluster_handlers: list[ClusterHandler]) -> None: + """Claim cluster handlers.""" + self.claimed_cluster_handlers.update({ch.id: ch for ch in cluster_handlers}) + + def unclaimed_cluster_handlers(self) -> list[ClusterHandler]: + """Return a list of available (unclaimed) cluster handlers.""" + claimed = set(self.claimed_cluster_handlers) + available = set(self.all_cluster_handlers) + return [ + self.all_cluster_handlers[cluster_id] + for cluster_id in (available - claimed) + ] diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 1bc77d3f36..02c16930d5 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -41,6 +41,8 @@ from .const import ( ATTR_TYPE, CONF_DATABASE, CONF_DEVICE_PATH, + CONF_NWK, + CONF_NWK_CHANNEL, CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, @@ -91,7 +93,7 @@ if TYPE_CHECKING: from logging import Filter, LogRecord from ..entity import ZhaEntity - from .channels.base import ZigbeeChannel + from .cluster_handlers import ClusterHandler _LogFilterType = Filter | Callable[[LogRecord], bool] @@ -103,7 +105,7 @@ class EntityReference(NamedTuple): reference_id: str zha_device: ZHADevice - cluster_channels: dict[str, ZigbeeChannel] + cluster_handlers: dict[str, ClusterHandler] device_info: DeviceInfo remove_future: asyncio.Future[Any] @@ -148,14 +150,8 @@ class ZHAGateway: self._unsubs: list[Callable[[], None]] = [] self.initialized: bool = False - async def async_initialize(self) -> None: - """Initialize controller and connect radio.""" - discovery.PROBE.initialize(self._hass) - discovery.GROUP_PROBE.initialize(self._hass) - - self.ha_device_registry = dr.async_get(self._hass) - self.ha_entity_registry = er.async_get(self._hass) - + def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: + """Get an uninitialized instance of a zigpy `ControllerApplication`.""" radio_type = self.config_entry.data[CONF_RADIO_TYPE] app_controller_cls = RadioType[radio_type].controller @@ -178,7 +174,31 @@ class ZHAGateway: ): app_config[CONF_USE_THREAD] = False - app_config = app_controller_cls.SCHEMA(app_config) + # Local import to avoid circular dependencies + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, + ) + + # Until we have a way to coordinate channels with the Thread half of multi-PAN, + # stick to the old zigpy default of channel 15 instead of dynamically scanning + if ( + is_multiprotocol_url(app_config[CONF_DEVICE][CONF_DEVICE_PATH]) + and app_config.get(CONF_NWK, {}).get(CONF_NWK_CHANNEL) is None + ): + app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15 + + return app_controller_cls, app_controller_cls.SCHEMA(app_config) + + async def async_initialize(self) -> None: + """Initialize controller and connect radio.""" + discovery.PROBE.initialize(self._hass) + discovery.GROUP_PROBE.initialize(self._hass) + + self.ha_device_registry = dr.async_get(self._hass) + self.ha_entity_registry = er.async_get(self._hass) + + app_controller_cls, app_config = self.get_application_controller_data() for attempt in range(STARTUP_RETRIES): try: @@ -500,7 +520,7 @@ class ZHAGateway: ieee: EUI64, reference_id: str, zha_device: ZHADevice, - cluster_channels: dict[str, ZigbeeChannel], + cluster_handlers: dict[str, ClusterHandler], device_info: DeviceInfo, remove_future: asyncio.Future[Any], ): @@ -509,7 +529,7 @@ class ZHAGateway: EntityReference( reference_id=reference_id, zha_device=zha_device, - cluster_channels=cluster_channels, + cluster_handlers=cluster_handlers, device_info=device_info, remove_future=remove_future, ) @@ -713,6 +733,8 @@ class ZHAGateway: _LOGGER.debug("Shutting down ZHA ControllerApplication") for unsubscribe in self._unsubs: unsubscribe() + for device in self.devices.values(): + device.async_cleanup_handles() await self.application_controller.shutdown() def handle_message( diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py index 82997dc2a5..ebea2f4ac4 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -89,7 +89,7 @@ class ZHAGroupMember(LogMixin): entity_ref.reference_id, )._asdict() for entity_ref in zha_device_registry.get(self.device.ieee) - if list(entity_ref.cluster_channels.values())[ + if list(entity_ref.cluster_handlers.values())[ 0 ].cluster.endpoint.endpoint_id == self.endpoint_id diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 526af1a7e4..ac7c15d3ec 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -309,19 +309,19 @@ class LogMixin: def debug(self, msg, *args, **kwargs): """Debug level log.""" - return self.log(logging.DEBUG, msg, *args) + return self.log(logging.DEBUG, msg, *args, **kwargs) def info(self, msg, *args, **kwargs): """Info level log.""" - return self.log(logging.INFO, msg, *args) + return self.log(logging.INFO, msg, *args, **kwargs) def warning(self, msg, *args, **kwargs): """Warning method log.""" - return self.log(logging.WARNING, msg, *args) + return self.log(logging.WARNING, msg, *args, **kwargs) def error(self, msg, *args, **kwargs): """Error level log.""" - return self.log(logging.ERROR, msg, *args) + return self.log(logging.ERROR, msg, *args, **kwargs) def retryable_req( @@ -336,17 +336,17 @@ def retryable_req( def decorator(func): @functools.wraps(func) - async def wrapper(channel, *args, **kwargs): + async def wrapper(cluster_handler, *args, **kwargs): exceptions = (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) try_count, errors = 1, [] for delay in itertools.chain(delays, [None]): try: - return await func(channel, *args, **kwargs) + return await func(cluster_handler, *args, **kwargs) except exceptions as ex: errors.append(ex) if delay: delay = uniform(delay * 0.75, delay * 1.25) - channel.debug( + cluster_handler.debug( "%s: retryable request #%d failed: %s. Retrying in %ss", func.__name__, try_count, @@ -356,7 +356,7 @@ def retryable_req( try_count += 1 await asyncio.sleep(delay) else: - channel.warning( + cluster_handler.warning( "%s: all attempts have failed: %s", func.__name__, errors ) if raise_: diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index a7504ae7a9..0c7369f15e 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -14,13 +14,11 @@ from zigpy.types.named import EUI64 from homeassistant.const import Platform -# importing channels updates registries -from . import channels as zha_channels # noqa: F401 pylint: disable=unused-import from .decorators import DictRegistry, SetRegistry if TYPE_CHECKING: from ..entity import ZhaEntity, ZhaGroupEntity - from .channels.base import ClientChannel, ZigbeeChannel + from .cluster_handlers import ClientClusterHandler, ClusterHandler _ZhaEntityT = TypeVar("_ZhaEntityT", bound=type["ZhaEntity"]) @@ -75,7 +73,6 @@ SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = { } BINDABLE_CLUSTERS = SetRegistry() -CHANNEL_ONLY_CLUSTERS = SetRegistry() DEVICE_CLASS = { zigpy.profiles.zha.PROFILE_ID: { @@ -108,8 +105,11 @@ DEVICE_CLASS = { } DEVICE_CLASS = collections.defaultdict(dict, DEVICE_CLASS) -CLIENT_CHANNELS_REGISTRY: DictRegistry[type[ClientChannel]] = DictRegistry() -ZIGBEE_CHANNEL_REGISTRY: DictRegistry[type[ZigbeeChannel]] = DictRegistry() +CLUSTER_HANDLER_ONLY_CLUSTERS = SetRegistry() +CLIENT_CLUSTER_HANDLER_REGISTRY: DictRegistry[ + type[ClientClusterHandler] +] = DictRegistry() +ZIGBEE_CLUSTER_HANDLER_REGISTRY: DictRegistry[type[ClusterHandler]] = DictRegistry() def set_or_callable(value) -> frozenset[str] | Callable: @@ -129,9 +129,9 @@ def _get_empty_frozenset() -> frozenset[str]: @attr.s(frozen=True) class MatchRule: - """Match a ZHA Entity to a channel name or generic id.""" + """Match a ZHA Entity to a cluster handler name or generic id.""" - channel_names: frozenset[str] = attr.ib( + cluster_handler_names: frozenset[str] = attr.ib( factory=frozenset, converter=set_or_callable ) generic_ids: frozenset[str] = attr.ib(factory=frozenset, converter=set_or_callable) @@ -141,7 +141,7 @@ class MatchRule: models: frozenset[str] | Callable = attr.ib( factory=_get_empty_frozenset, converter=set_or_callable ) - aux_channels: frozenset[str] | Callable = attr.ib( + aux_cluster_handlers: frozenset[str] | Callable = attr.ib( factory=_get_empty_frozenset, converter=set_or_callable ) quirk_classes: frozenset[str] | Callable = attr.ib( @@ -157,9 +157,9 @@ class MatchRule: and have a priority over manufacturer matching rules and rules matching a single model/manufacturer get a better priority over rules matching multiple models/manufacturers. And any model or manufacturers matching rules get better - priority over rules matching only channels. - But in case of a channel name/channel id matching, we give rules matching - multiple channels a better priority over rules matching a single channel. + priority over rules matching only cluster handlers. + But in case of a cluster handler name/cluster handler id matching, we give rules matching + multiple cluster handlers a better priority over rules matching a single cluster handler. """ weight = 0 if self.quirk_classes: @@ -175,51 +175,57 @@ class MatchRule: 1 if callable(self.manufacturers) else len(self.manufacturers) ) - weight += 10 * len(self.channel_names) + weight += 10 * len(self.cluster_handler_names) weight += 5 * len(self.generic_ids) - if isinstance(self.aux_channels, frozenset): - weight += 1 * len(self.aux_channels) + if isinstance(self.aux_cluster_handlers, frozenset): + weight += 1 * len(self.aux_cluster_handlers) return weight - def claim_channels(self, channel_pool: list[ZigbeeChannel]) -> list[ZigbeeChannel]: - """Return a list of channels this rule matches + aux channels.""" + def claim_cluster_handlers( + self, cluster_handlers: list[ClusterHandler] + ) -> list[ClusterHandler]: + """Return a list of cluster handlers this rule matches + aux cluster handlers.""" claimed = [] - if isinstance(self.channel_names, frozenset): - claimed.extend([ch for ch in channel_pool if ch.name in self.channel_names]) + if isinstance(self.cluster_handler_names, frozenset): + claimed.extend( + [ch for ch in cluster_handlers if ch.name in self.cluster_handler_names] + ) if isinstance(self.generic_ids, frozenset): claimed.extend( - [ch for ch in channel_pool if ch.generic_id in self.generic_ids] + [ch for ch in cluster_handlers if ch.generic_id in self.generic_ids] + ) + if isinstance(self.aux_cluster_handlers, frozenset): + claimed.extend( + [ch for ch in cluster_handlers if ch.name in self.aux_cluster_handlers] ) - if isinstance(self.aux_channels, frozenset): - claimed.extend([ch for ch in channel_pool if ch.name in self.aux_channels]) return claimed def strict_matched( - self, manufacturer: str, model: str, channels: list, quirk_class: str + self, manufacturer: str, model: str, cluster_handlers: list, quirk_class: str ) -> bool: """Return True if this device matches the criteria.""" - return all(self._matched(manufacturer, model, channels, quirk_class)) + return all(self._matched(manufacturer, model, cluster_handlers, quirk_class)) def loose_matched( - self, manufacturer: str, model: str, channels: list, quirk_class: str + self, manufacturer: str, model: str, cluster_handlers: list, quirk_class: str ) -> bool: """Return True if this device matches the criteria.""" - return any(self._matched(manufacturer, model, channels, quirk_class)) + return any(self._matched(manufacturer, model, cluster_handlers, quirk_class)) def _matched( - self, manufacturer: str, model: str, channels: list, quirk_class: str + self, manufacturer: str, model: str, cluster_handlers: list, quirk_class: str ) -> list: """Return a list of field matches.""" if not any(attr.asdict(self).values()): return [False] matches = [] - if self.channel_names: - channel_names = {ch.name for ch in channels} - matches.append(self.channel_names.issubset(channel_names)) + if self.cluster_handler_names: + cluster_handler_names = {ch.name for ch in cluster_handlers} + matches.append(self.cluster_handler_names.issubset(cluster_handler_names)) if self.generic_ids: - all_generic_ids = {ch.generic_id for ch in channels} + all_generic_ids = {ch.generic_id for ch in cluster_handlers} matches.append(self.generic_ids.issubset(all_generic_ids)) if self.manufacturers: @@ -244,15 +250,15 @@ class MatchRule: @dataclasses.dataclass -class EntityClassAndChannels: - """Container for entity class and corresponding channels.""" +class EntityClassAndClusterHandlers: + """Container for entity class and corresponding cluster handlers.""" entity_class: type[ZhaEntity] - claimed_channel: list[ZigbeeChannel] + claimed_cluster_handlers: list[ClusterHandler] class ZHAEntityRegistry: - """Channel to ZHA Entity mapping.""" + """Cluster handler to ZHA Entity mapping.""" def __init__(self) -> None: """Initialize Registry instance.""" @@ -279,15 +285,15 @@ class ZHAEntityRegistry: component: str, manufacturer: str, model: str, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], quirk_class: str, default: type[ZhaEntity] | None = None, - ) -> tuple[type[ZhaEntity] | None, list[ZigbeeChannel]]: - """Match a ZHA Channels to a ZHA Entity class.""" + ) -> tuple[type[ZhaEntity] | None, list[ClusterHandler]]: + """Match a ZHA ClusterHandler to a ZHA Entity class.""" matches = self._strict_registry[component] for match in sorted(matches, key=lambda x: x.weight, reverse=True): - if match.strict_matched(manufacturer, model, channels, quirk_class): - claimed = match.claim_channels(channels) + if match.strict_matched(manufacturer, model, cluster_handlers, quirk_class): + claimed = match.claim_cluster_handlers(cluster_handlers) return self._strict_registry[component][match], claimed return default, [] @@ -296,21 +302,27 @@ class ZHAEntityRegistry: self, manufacturer: str, model: str, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], quirk_class: str, - ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ZigbeeChannel]]: - """Match ZHA Channels to potentially multiple ZHA Entity classes.""" - result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list) - all_claimed: set[ZigbeeChannel] = set() + ) -> tuple[dict[str, list[EntityClassAndClusterHandlers]], list[ClusterHandler]]: + """Match ZHA cluster handlers to potentially multiple ZHA Entity classes.""" + result: dict[ + str, list[EntityClassAndClusterHandlers] + ] = collections.defaultdict(list) + all_claimed: set[ClusterHandler] = set() for component, stop_match_groups in self._multi_entity_registry.items(): for stop_match_grp, matches in stop_match_groups.items(): sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) for match in sorted_matches: - if match.strict_matched(manufacturer, model, channels, quirk_class): - claimed = match.claim_channels(channels) + if match.strict_matched( + manufacturer, model, cluster_handlers, quirk_class + ): + claimed = match.claim_cluster_handlers(cluster_handlers) for ent_class in stop_match_groups[stop_match_grp][match]: - ent_n_channels = EntityClassAndChannels(ent_class, claimed) - result[component].append(ent_n_channels) + ent_n_cluster_handlers = EntityClassAndClusterHandlers( + ent_class, claimed + ) + result[component].append(ent_n_cluster_handlers) all_claimed |= set(claimed) if stop_match_grp: break @@ -321,12 +333,14 @@ class ZHAEntityRegistry: self, manufacturer: str, model: str, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], quirk_class: str, - ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ZigbeeChannel]]: - """Match ZHA Channels to potentially multiple ZHA Entity classes.""" - result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list) - all_claimed: set[ZigbeeChannel] = set() + ) -> tuple[dict[str, list[EntityClassAndClusterHandlers]], list[ClusterHandler]]: + """Match ZHA cluster handlers to potentially multiple ZHA Entity classes.""" + result: dict[ + str, list[EntityClassAndClusterHandlers] + ] = collections.defaultdict(list) + all_claimed: set[ClusterHandler] = set() for ( component, stop_match_groups, @@ -334,11 +348,15 @@ class ZHAEntityRegistry: for stop_match_grp, matches in stop_match_groups.items(): sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) for match in sorted_matches: - if match.strict_matched(manufacturer, model, channels, quirk_class): - claimed = match.claim_channels(channels) + if match.strict_matched( + manufacturer, model, cluster_handlers, quirk_class + ): + claimed = match.claim_cluster_handlers(cluster_handlers) for ent_class in stop_match_groups[stop_match_grp][match]: - ent_n_channels = EntityClassAndChannels(ent_class, claimed) - result[component].append(ent_n_channels) + ent_n_cluster_handlers = EntityClassAndClusterHandlers( + ent_class, claimed + ) + result[component].append(ent_n_cluster_handlers) all_claimed |= set(claimed) if stop_match_grp: break @@ -352,21 +370,21 @@ class ZHAEntityRegistry: def strict_match( self, component: str, - channel_names: set[str] | str | None = None, + cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, models: Callable | set[str] | str | None = None, - aux_channels: Callable | set[str] | str | None = None, + aux_cluster_handlers: Callable | set[str] | str | None = None, quirk_classes: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a strict match rule.""" rule = MatchRule( - channel_names, + cluster_handler_names, generic_ids, manufacturers, models, - aux_channels, + aux_cluster_handlers, quirk_classes, ) @@ -383,22 +401,22 @@ class ZHAEntityRegistry: def multipass_match( self, component: str, - channel_names: set[str] | str | None = None, + cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, models: Callable | set[str] | str | None = None, - aux_channels: Callable | set[str] | str | None = None, + aux_cluster_handlers: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, quirk_classes: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" rule = MatchRule( - channel_names, + cluster_handler_names, generic_ids, manufacturers, models, - aux_channels, + aux_cluster_handlers, quirk_classes, ) @@ -407,7 +425,7 @@ class ZHAEntityRegistry: All non empty fields of a match rule must match. """ - # group the rules by channels + # group the rules by cluster handlers self._multi_entity_registry[component][stop_on_match_group][rule].append( zha_entity ) @@ -418,22 +436,22 @@ class ZHAEntityRegistry: def config_diagnostic_match( self, component: str, - channel_names: set[str] | str | None = None, + cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, models: Callable | set[str] | str | None = None, - aux_channels: Callable | set[str] | str | None = None, + aux_cluster_handlers: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, quirk_classes: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" rule = MatchRule( - channel_names, + cluster_handler_names, generic_ids, manufacturers, models, - aux_channels, + aux_cluster_handlers, quirk_classes, ) @@ -442,7 +460,7 @@ class ZHAEntityRegistry: All non-empty fields of a match rule must match. """ - # group the rules by channels + # group the rules by cluster handlers self._config_diagnostic_entity_registry[component][stop_on_match_group][ rule ].append(zha_entity) diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index f6c67e6981..fce3790412 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -28,10 +28,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( - CHANNEL_COVER, - CHANNEL_LEVEL, - CHANNEL_ON_OFF, - CHANNEL_SHADE, + CLUSTER_HANDLER_COVER, + CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_ON_OFF, + CLUSTER_HANDLER_SHADE, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -41,7 +41,7 @@ from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice _LOGGER = logging.getLogger(__name__) @@ -67,21 +67,23 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -@MULTI_MATCH(channel_names=CHANNEL_COVER) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_COVER) class ZhaCover(ZhaEntity, CoverEntity): """Representation of a ZHA cover.""" - def __init__(self, unique_id, zha_device, channels, **kwargs): + _attr_name: str = "Cover" + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Init this sensor.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._cover_channel = self.cluster_channels.get(CHANNEL_COVER) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cover_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COVER) self._current_position = None async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._cover_channel, SIGNAL_ATTR_UPDATED, self.async_set_position + self._cover_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_position ) @callback @@ -118,7 +120,7 @@ class ZhaCover(ZhaEntity, CoverEntity): @callback def async_set_position(self, attr_id, attr_name, value): - """Handle position update from channel.""" + """Handle position update from cluster handler.""" _LOGGER.debug("setting position: %s", value) self._current_position = 100 - value if self._current_position == 0: @@ -129,27 +131,27 @@ class ZhaCover(ZhaEntity, CoverEntity): @callback def async_update_state(self, state): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" _LOGGER.debug("state=%s", state) self._state = state self.async_write_ha_state() async def async_open_cover(self, **kwargs: Any) -> None: """Open the window cover.""" - res = await self._cover_channel.up_open() + res = await self._cover_cluster_handler.up_open() if not isinstance(res, Exception) and res[1] is Status.SUCCESS: self.async_update_state(STATE_OPENING) async def async_close_cover(self, **kwargs: Any) -> None: """Close the window cover.""" - res = await self._cover_channel.down_close() + res = await self._cover_cluster_handler.down_close() if not isinstance(res, Exception) and res[1] is Status.SUCCESS: self.async_update_state(STATE_CLOSING) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the roller shutter to a specific position.""" new_pos = kwargs[ATTR_POSITION] - res = await self._cover_channel.go_to_lift_percentage(100 - new_pos) + res = await self._cover_cluster_handler.go_to_lift_percentage(100 - new_pos) if not isinstance(res, Exception) and res[1] is Status.SUCCESS: self.async_update_state( STATE_CLOSING if new_pos < self._current_position else STATE_OPENING @@ -157,7 +159,7 @@ class ZhaCover(ZhaEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the window cover.""" - res = await self._cover_channel.stop() + res = await self._cover_cluster_handler.stop() if not isinstance(res, Exception) and res[1] is Status.SUCCESS: self._state = STATE_OPEN if self._current_position > 0 else STATE_CLOSED self.async_write_ha_state() @@ -170,8 +172,8 @@ class ZhaCover(ZhaEntity, CoverEntity): async def async_get_state(self, from_cache=True): """Fetch the current state.""" _LOGGER.debug("polling current state") - if self._cover_channel: - pos = await self._cover_channel.get_attribute_value( + if self._cover_cluster_handler: + pos = await self._cover_cluster_handler.get_attribute_value( "current_position_lift_percentage", from_cache=from_cache ) _LOGGER.debug("read pos=%s", pos) @@ -186,23 +188,30 @@ class ZhaCover(ZhaEntity, CoverEntity): self._state = None -@MULTI_MATCH(channel_names={CHANNEL_LEVEL, CHANNEL_ON_OFF, CHANNEL_SHADE}) +@MULTI_MATCH( + cluster_handler_names={ + CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_ON_OFF, + CLUSTER_HANDLER_SHADE, + } +) class Shade(ZhaEntity, CoverEntity): """ZHA Shade.""" _attr_device_class = CoverDeviceClass.SHADE + _attr_name: str = "Shade" def __init__( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs, ) -> None: """Initialize the ZHA light.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._on_off_channel = self.cluster_channels[CHANNEL_ON_OFF] - self._level_channel = self.cluster_channels[CHANNEL_LEVEL] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._on_off_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_ON_OFF] + self._level_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_LEVEL] self._position: int | None = None self._is_open: bool | None = None @@ -225,10 +234,12 @@ class Shade(ZhaEntity, CoverEntity): """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_open_closed + self._on_off_cluster_handler, + SIGNAL_ATTR_UPDATED, + self.async_set_open_closed, ) self.async_accept_signal( - self._level_channel, SIGNAL_SET_LEVEL, self.async_set_level + self._level_cluster_handler, SIGNAL_SET_LEVEL, self.async_set_level ) @callback @@ -253,7 +264,7 @@ class Shade(ZhaEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the window cover.""" - res = await self._on_off_channel.on() + res = await self._on_off_cluster_handler.on() if isinstance(res, Exception) or res[1] != Status.SUCCESS: self.debug("couldn't open cover: %s", res) return @@ -263,7 +274,7 @@ class Shade(ZhaEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close the window cover.""" - res = await self._on_off_channel.off() + res = await self._on_off_cluster_handler.off() if isinstance(res, Exception) or res[1] != Status.SUCCESS: self.debug("couldn't open cover: %s", res) return @@ -274,7 +285,7 @@ class Shade(ZhaEntity, CoverEntity): async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the roller shutter to a specific position.""" new_pos = kwargs[ATTR_POSITION] - res = await self._level_channel.move_to_level_with_on_off( + res = await self._level_cluster_handler.move_to_level_with_on_off( new_pos * 255 / 100, 1 ) @@ -287,26 +298,31 @@ class Shade(ZhaEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - res = await self._level_channel.stop() + res = await self._level_cluster_handler.stop() if isinstance(res, Exception) or res[1] != Status.SUCCESS: self.debug("couldn't stop cover: %s", res) return @MULTI_MATCH( - channel_names={CHANNEL_LEVEL, CHANNEL_ON_OFF}, manufacturers="Keen Home Inc" + cluster_handler_names={CLUSTER_HANDLER_LEVEL, CLUSTER_HANDLER_ON_OFF}, + manufacturers="Keen Home Inc", ) class KeenVent(Shade): """Keen vent cover.""" + _attr_name: str = "Keen vent" + _attr_device_class = CoverDeviceClass.DAMPER async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" position = self._position or 100 tasks = [ - self._level_channel.move_to_level_with_on_off(position * 255 / 100, 1), - self._on_off_channel.on(), + self._level_cluster_handler.move_to_level_with_on_off( + position * 255 / 100, 1 + ), + self._on_off_cluster_handler.on(), ] results = await asyncio.gather(*tasks, return_exceptions=True) if any(isinstance(result, Exception) for result in results): diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 9867bc5cfb..d393cfb147 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -12,10 +12,13 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN -from .api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN -from .core.channels.manufacturerspecific import AllLEDEffectType, SingleLEDEffectType -from .core.const import CHANNEL_IAS_WD, CHANNEL_INOVELLI +from .core.cluster_handlers.manufacturerspecific import ( + AllLEDEffectType, + SingleLEDEffectType, +) +from .core.const import CLUSTER_HANDLER_IAS_WD, CLUSTER_HANDLER_INOVELLI from .core.helpers import async_get_zha_device +from .websocket_api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN # mypy: disallow-any-generics @@ -25,7 +28,7 @@ ATTR_DATA = "data" ATTR_IEEE = "ieee" CONF_ZHA_ACTION_TYPE = "zha_action_type" ZHA_ACTION_TYPE_SERVICE_CALL = "service_call" -ZHA_ACTION_TYPE_CHANNEL_COMMAND = "channel_command" +ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND = "cluster_handler_command" INOVELLI_ALL_LED_EFFECT = "issue_all_led_effect" INOVELLI_INDIVIDUAL_LED_EFFECT = "issue_individual_led_effect" @@ -67,11 +70,11 @@ ACTION_SCHEMA = vol.Any( ) DEVICE_ACTIONS = { - CHANNEL_IAS_WD: [ + CLUSTER_HANDLER_IAS_WD: [ {CONF_TYPE: ACTION_SQUAWK, CONF_DOMAIN: DOMAIN}, {CONF_TYPE: ACTION_WARN, CONF_DOMAIN: DOMAIN}, ], - CHANNEL_INOVELLI: [ + CLUSTER_HANDLER_INOVELLI: [ {CONF_TYPE: INOVELLI_ALL_LED_EFFECT, CONF_DOMAIN: DOMAIN}, {CONF_TYPE: INOVELLI_INDIVIDUAL_LED_EFFECT, CONF_DOMAIN: DOMAIN}, ], @@ -80,8 +83,8 @@ DEVICE_ACTIONS = { DEVICE_ACTION_TYPES = { ACTION_SQUAWK: ZHA_ACTION_TYPE_SERVICE_CALL, ACTION_WARN: ZHA_ACTION_TYPE_SERVICE_CALL, - INOVELLI_ALL_LED_EFFECT: ZHA_ACTION_TYPE_CHANNEL_COMMAND, - INOVELLI_INDIVIDUAL_LED_EFFECT: ZHA_ACTION_TYPE_CHANNEL_COMMAND, + INOVELLI_ALL_LED_EFFECT: ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND, + INOVELLI_INDIVIDUAL_LED_EFFECT: ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND, } DEVICE_ACTION_SCHEMAS = { @@ -109,9 +112,9 @@ SERVICE_NAMES = { ACTION_WARN: SERVICE_WARNING_DEVICE_WARN, } -CHANNEL_MAPPINGS = { - INOVELLI_ALL_LED_EFFECT: CHANNEL_INOVELLI, - INOVELLI_INDIVIDUAL_LED_EFFECT: CHANNEL_INOVELLI, +CLUSTER_HANDLER_MAPPINGS = { + INOVELLI_ALL_LED_EFFECT: CLUSTER_HANDLER_INOVELLI, + INOVELLI_INDIVIDUAL_LED_EFFECT: CLUSTER_HANDLER_INOVELLI, } @@ -144,16 +147,16 @@ async def async_get_actions( zha_device = async_get_zha_device(hass, device_id) except (KeyError, AttributeError): return [] - cluster_channels = [ + cluster_handlers = [ ch.name - for pool in zha_device.channels.pools - for ch in pool.claimed_channels.values() + for endpoint in zha_device.endpoints.values() + for ch in endpoint.claimed_cluster_handlers.values() ] actions = [ action - for channel, channel_actions in DEVICE_ACTIONS.items() - for action in channel_actions - if channel in cluster_channels + for cluster_handler, cluster_handler_actions in DEVICE_ACTIONS.items() + for action in cluster_handler_actions + if cluster_handler in cluster_handlers ] for action in actions: action[CONF_DEVICE_ID] = device_id @@ -188,42 +191,42 @@ async def _execute_service_based_action( ) -async def _execute_channel_command_based_action( +async def _execute_cluster_handler_command_based_action( hass: HomeAssistant, config: dict[str, Any], variables: TemplateVarsType, context: Context | None, ) -> None: action_type = config[CONF_TYPE] - channel_name = CHANNEL_MAPPINGS[action_type] + cluster_handler_name = CLUSTER_HANDLER_MAPPINGS[action_type] try: zha_device = async_get_zha_device(hass, config[CONF_DEVICE_ID]) except (KeyError, AttributeError): return - action_channel = None - for pool in zha_device.channels.pools: - for channel in pool.all_channels.values(): - if channel.name == channel_name: - action_channel = channel + action_cluster_handler = None + for endpoint in zha_device.endpoints.values(): + for cluster_handler in endpoint.all_cluster_handlers.values(): + if cluster_handler.name == cluster_handler_name: + action_cluster_handler = cluster_handler break - if action_channel is None: + if action_cluster_handler is None: raise InvalidDeviceAutomationConfig( - f"Unable to execute channel action - channel: {channel_name} action:" + f"Unable to execute cluster handler action - cluster handler: {cluster_handler_name} action:" f" {action_type}" ) - if not hasattr(action_channel, action_type): + if not hasattr(action_cluster_handler, action_type): raise InvalidDeviceAutomationConfig( - f"Unable to execute channel action - channel: {channel_name} action:" + f"Unable to execute cluster handler - cluster handler: {cluster_handler_name} action:" f" {action_type}" ) - await getattr(action_channel, action_type)(**config) + await getattr(action_cluster_handler, action_type)(**config) ZHA_ACTION_TYPES = { ZHA_ACTION_TYPE_SERVICE_CALL: _execute_service_based_action, - ZHA_ACTION_TYPE_CHANNEL_COMMAND: _execute_channel_command_based_action, + ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND: _execute_cluster_handler_command_based_action, } diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index 1a636ce65a..d473eadeeb 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( - CHANNEL_POWER_CONFIGURATION, + CLUSTER_HANDLER_POWER_CONFIGURATION, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -44,16 +44,19 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -@STRICT_MATCH(channel_names=CHANNEL_POWER_CONFIGURATION) +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_POWER_CONFIGURATION) class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): """Represent a tracked device.""" _attr_should_poll = True # BaseZhaEntity defaults to False + _attr_name: str = "Device scanner" - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize the ZHA device tracker.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._battery_channel = self.cluster_channels.get(CHANNEL_POWER_CONFIGURATION) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._battery_cluster_handler = self.cluster_handlers.get( + CLUSTER_HANDLER_POWER_CONFIGURATION + ) self._connected = False self._keepalive_interval = 60 self._battery_level = None @@ -61,9 +64,9 @@ class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() - if self._battery_channel: + if self._battery_cluster_handler: self.async_accept_signal( - self._battery_channel, + self._battery_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_battery_percentage_remaining_updated, ) diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 2e0653b47e..966f35fe98 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -7,6 +7,7 @@ from typing import Any from zigpy.config import CONF_NWK_EXTENDED_PAN_ID from zigpy.profiles import PROFILES +from zigpy.types import Channels from zigpy.zcl import Cluster from homeassistant.components.diagnostics.util import async_redact_data @@ -67,11 +68,19 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" config: dict = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + energy_scan = await gateway.application_controller.energy_scan( + channels=Channels.ALL_CHANNELS, duration_exp=4, count=1 + ) + return async_redact_data( { "config": config, "config_entry": config_entry.as_dict(), "application_state": shallow_asdict(gateway.application_controller.state), + "energy_scan": { + channel: 100 * energy / 255 for channel, energy in energy_scan.items() + }, "versions": { "bellows": version("bellows"), "zigpy": version("zigpy"), diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 8e0b58a872..97258a77e2 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -34,7 +34,7 @@ from .core.const import ( from .core.helpers import LogMixin if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice _LOGGER = logging.getLogger(__name__) @@ -52,7 +52,6 @@ class BaseZhaEntity(LogMixin, entity.Entity): def __init__(self, unique_id: str, zha_device: ZHADevice, **kwargs: Any) -> None: """Init ZHA entity.""" - self._name: str = "" self._unique_id: str = unique_id if self.unique_id_suffix: self._unique_id += f"-{self.unique_id_suffix}" @@ -62,13 +61,6 @@ class BaseZhaEntity(LogMixin, entity.Entity): self._unsubs: list[Callable[[], None]] = [] self.remove_future: asyncio.Future[Any] = asyncio.Future() - @property - def name(self) -> str: - """Return Entity's default name.""" - if hasattr(self, "_attr_name") and self._attr_name is not None: - return self._attr_name - return self._name - @property def unique_id(self) -> str: """Return a unique ID.""" @@ -122,19 +114,19 @@ class BaseZhaEntity(LogMixin, entity.Entity): @callback def async_accept_signal( self, - channel: ZigbeeChannel | None, + cluster_handler: ClusterHandler | None, signal: str, func: Callable[..., Any], signal_override=False, ): - """Accept a signal from a channel.""" + """Accept a signal from a cluster handler.""" unsub = None if signal_override: unsub = async_dispatcher_connect(self.hass, signal, func) else: - assert channel + assert cluster_handler unsub = async_dispatcher_connect( - self.hass, f"{channel.unique_id}_{signal}", func + self.hass, f"{cluster_handler.unique_id}_{signal}", func ) self._unsubs.append(unsub) @@ -152,7 +144,7 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): """Initialize subclass. :param id_suffix: suffix to add to the unique_id of the entity. Used for multi - entities using the same channel/cluster id for the entity. + entities using the same cluster handler/cluster id for the entity. """ super().__init_subclass__(**kwargs) if id_suffix: @@ -162,35 +154,29 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init ZHA entity.""" super().__init__(unique_id, zha_device, **kwargs) - self._name: str = ( - self.__class__.__name__.lower() - .replace("zha", "") - .replace("entity", "") - .replace("sensor", "") - .capitalize() - ) - self.cluster_channels: dict[str, ZigbeeChannel] = {} - for channel in channels: - self.cluster_channels[channel.name] = channel + + self.cluster_handlers: dict[str, ClusterHandler] = {} + for cluster_handler in cluster_handlers: + self.cluster_handlers[cluster_handler.name] = cluster_handler @classmethod def create_entity( cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. Return entity if it is a supported configuration, otherwise return None """ - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) @property def available(self) -> bool: @@ -220,7 +206,7 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): self._zha_device.ieee, self.entity_id, self._zha_device, - self.cluster_channels, + self.cluster_handlers, self.device_info, self.remove_future, ) @@ -238,9 +224,9 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): async def async_update(self) -> None: """Retrieve latest state.""" tasks = [ - channel.async_update() - for channel in self.cluster_channels.values() - if hasattr(channel, "async_update") + cluster_handler.async_update() + for cluster_handler in self.cluster_handlers.values() + if hasattr(cluster_handler, "async_update") ] if tasks: await asyncio.gather(*tasks) @@ -249,6 +235,9 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): class ZhaGroupEntity(BaseZhaEntity): """A base class for ZHA group entities.""" + # The group name is set in the initializer + _attr_name: str + def __init__( self, entity_ids: list[str], @@ -261,9 +250,6 @@ class ZhaGroupEntity(BaseZhaEntity): super().__init__(unique_id, zha_device, **kwargs) self._available = False self._group = zha_device.gateway.groups.get(group_id) - self._name = ( - f"{self._group.name}_zha_group_0x{group_id:04x}".lower().capitalize() - ) self._group_id: int = group_id self._entity_ids: list[str] = entity_ids self._async_unsub_state_changed: CALLBACK_TYPE | None = None @@ -271,6 +257,8 @@ class ZhaGroupEntity(BaseZhaEntity): self._change_listener_debouncer: Debouncer | None = None self._update_group_from_child_delay = DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY + self._attr_name = self._group.name + @property def available(self) -> bool: """Return entity availability.""" @@ -320,6 +308,7 @@ class ZhaGroupEntity(BaseZhaEntity): immediate=False, function=functools.partial(self.async_update_ha_state, True), ) + self.async_on_remove(self._change_listener_debouncer.async_cancel) self._async_unsub_state_changed = async_track_state_change_event( self.hass, self._entity_ids, self.async_state_changed_listener ) diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 5153d3c456..82725accfa 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -28,7 +28,12 @@ from homeassistant.util.percentage import ( ) from .core import discovery -from .core.const import CHANNEL_FAN, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED +from .core.const import ( + CLUSTER_HANDLER_FAN, + DATA_ZHA, + SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, +) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity, ZhaGroupEntity @@ -124,50 +129,54 @@ class BaseFan(FanEntity): @callback def async_set_state(self, attr_id, attr_name, value): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" -@STRICT_MATCH(channel_names=CHANNEL_FAN) +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_FAN) class ZhaFan(BaseFan, ZhaEntity): """Representation of a ZHA fan.""" - def __init__(self, unique_id, zha_device, channels, **kwargs): + _attr_name: str = "Fan" + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Init this sensor.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._fan_channel = self.cluster_channels.get(CHANNEL_FAN) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._fan_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_FAN) async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._fan_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._fan_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) @property def percentage(self) -> int | None: """Return the current speed percentage.""" if ( - self._fan_channel.fan_mode is None - or self._fan_channel.fan_mode > SPEED_RANGE[1] + self._fan_cluster_handler.fan_mode is None + or self._fan_cluster_handler.fan_mode > SPEED_RANGE[1] ): return None - if self._fan_channel.fan_mode == 0: + if self._fan_cluster_handler.fan_mode == 0: return 0 - return ranged_value_to_percentage(SPEED_RANGE, self._fan_channel.fan_mode) + return ranged_value_to_percentage( + SPEED_RANGE, self._fan_cluster_handler.fan_mode + ) @property def preset_mode(self) -> str | None: """Return the current preset mode.""" - return PRESET_MODES_TO_NAME.get(self._fan_channel.fan_mode) + return PRESET_MODES_TO_NAME.get(self._fan_cluster_handler.fan_mode) @callback def async_set_state(self, attr_id, attr_name, value): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() async def _async_set_fan_mode(self, fan_mode: int) -> None: """Set the fan mode for the fan.""" - await self._fan_channel.async_set_speed(fan_mode) + await self._fan_cluster_handler.async_set_speed(fan_mode) self.async_set_state(0, "fan_mode", fan_mode) @@ -182,7 +191,7 @@ class FanGroup(BaseFan, ZhaGroupEntity): super().__init__(entity_ids, unique_id, group_id, zha_device, **kwargs) self._available: bool = False group = self.zha_device.gateway.get_group(self._group_id) - self._fan_channel = group.endpoint[hvac.Fan.cluster_id] + self._fan_cluster_handler = group.endpoint[hvac.Fan.cluster_id] self._percentage = None self._preset_mode = None @@ -199,7 +208,7 @@ class FanGroup(BaseFan, ZhaGroupEntity): async def _async_set_fan_mode(self, fan_mode: int) -> None: """Set the fan mode for the group.""" try: - await self._fan_channel.write_attributes({"fan_mode": fan_mode}) + await self._fan_cluster_handler.write_attributes({"fan_mode": fan_mode}) except ZigbeeException as ex: self.error("Could not set fan mode: %s", ex) self.async_set_state(0, "fan_mode", fan_mode) @@ -250,22 +259,24 @@ IKEA_PRESET_MODES = list(IKEA_NAME_TO_PRESET_MODE) @MULTI_MATCH( - channel_names="ikea_airpurifier", + cluster_handler_names="ikea_airpurifier", models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, ) class IkeaFan(BaseFan, ZhaEntity): """Representation of a ZHA fan.""" - def __init__(self, unique_id, zha_device, channels, **kwargs): + _attr_name: str = "IKEA fan" + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Init this sensor.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._fan_channel = self.cluster_channels.get("ikea_airpurifier") + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._fan_cluster_handler = self.cluster_handlers.get("ikea_airpurifier") async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._fan_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._fan_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) @property @@ -296,18 +307,20 @@ class IkeaFan(BaseFan, ZhaEntity): def percentage(self) -> int | None: """Return the current speed percentage.""" if ( - self._fan_channel.fan_mode is None - or self._fan_channel.fan_mode > IKEA_SPEED_RANGE[1] + self._fan_cluster_handler.fan_mode is None + or self._fan_cluster_handler.fan_mode > IKEA_SPEED_RANGE[1] ): return None - if self._fan_channel.fan_mode == 0: + if self._fan_cluster_handler.fan_mode == 0: return 0 - return ranged_value_to_percentage(IKEA_SPEED_RANGE, self._fan_channel.fan_mode) + return ranged_value_to_percentage( + IKEA_SPEED_RANGE, self._fan_cluster_handler.fan_mode + ) @property def preset_mode(self) -> str | None: """Return the current preset mode.""" - return IKEA_PRESET_MODES_TO_NAME.get(self._fan_channel.fan_mode) + return IKEA_PRESET_MODES_TO_NAME.get(self._fan_cluster_handler.fan_mode) async def async_turn_on( self, @@ -328,10 +341,10 @@ class IkeaFan(BaseFan, ZhaEntity): @callback def async_set_state(self, attr_id, attr_name, value): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() async def _async_set_fan_mode(self, fan_mode: int) -> None: """Set the fan mode for the fan.""" - await self._fan_channel.async_set_speed(fan_mode) + await self._fan_cluster_handler.async_set_speed(fan_mode) self.async_set_state(0, "fan_mode", fan_mode) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index e7bc059054..3d3412784f 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -39,9 +39,9 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter from .core import discovery, helpers from .core.const import ( - CHANNEL_COLOR, - CHANNEL_LEVEL, - CHANNEL_ON_OFF, + CLUSTER_HANDLER_COLOR, + CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_ON_OFF, CONF_ALWAYS_PREFER_XY_COLOR_MODE, CONF_DEFAULT_LIGHT_TRANSITION, CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, @@ -130,14 +130,19 @@ class BaseLight(LogMixin, light.LightEntity): self._zha_config_enhanced_light_transition: bool = False self._zha_config_enable_light_transitioning_flag: bool = True self._zha_config_always_prefer_xy_color_mode: bool = True - self._on_off_channel = None - self._level_channel = None - self._color_channel = None - self._identify_channel = None + self._on_off_cluster_handler = None + self._level_cluster_handler = None + self._color_cluster_handler = None + self._identify_cluster_handler = None self._transitioning_individual: bool = False self._transitioning_group: bool = False self._transition_listener: Callable[[], None] | None = None + async def async_will_remove_from_hass(self) -> None: + """Disconnect entity object when removed.""" + self._async_unsub_transition_listener() + await super().async_will_remove_from_hass() + @property def extra_state_attributes(self) -> dict[str, Any]: """Return state attributes.""" @@ -193,7 +198,8 @@ class BaseLight(LogMixin, light.LightEntity): execute_if_off_supported = ( self._GROUP_SUPPORTS_EXECUTE_IF_OFF if isinstance(self, LightGroup) - else self._color_channel and self._color_channel.execute_if_off_supported + else self._color_cluster_handler + and self._color_cluster_handler.execute_if_off_supported ) set_transition_flag = ( @@ -289,7 +295,7 @@ class BaseLight(LogMixin, light.LightEntity): # If the light is currently off, we first need to turn it on at a low # brightness level with no transition. # After that, we set it to the desired color/temperature with no transition. - result = await self._level_channel.move_to_level_with_on_off( + result = await self._level_cluster_handler.move_to_level_with_on_off( level=DEFAULT_MIN_BRIGHTNESS, transition_time=self._DEFAULT_MIN_TRANSITION_TIME, ) @@ -329,7 +335,7 @@ class BaseLight(LogMixin, light.LightEntity): and not new_color_provided_while_off and brightness_supported(self._attr_supported_color_modes) ): - result = await self._level_channel.move_to_level_with_on_off( + result = await self._level_cluster_handler.move_to_level_with_on_off( level=level, transition_time=duration, ) @@ -353,7 +359,7 @@ class BaseLight(LogMixin, light.LightEntity): # since some lights don't always turn on with move_to_level_with_on_off, # we should call the on command on the on_off cluster # if brightness is not 0. - result = await self._on_off_channel.on() + result = await self._on_off_cluster_handler.on() t_log["on_off"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: # 'On' call failed, but as brightness may still transition @@ -383,7 +389,7 @@ class BaseLight(LogMixin, light.LightEntity): if new_color_provided_while_off: # The light is has the correct color, so we can now transition # it to the correct brightness level. - result = await self._level_channel.move_to_level( + result = await self._level_cluster_handler.move_to_level( level=level, transition_time=duration ) t_log["move_to_level_if_color"] = result @@ -400,7 +406,7 @@ class BaseLight(LogMixin, light.LightEntity): self.async_transition_start_timer(transition_time) if effect == light.EFFECT_COLORLOOP: - result = await self._color_channel.color_loop_set( + result = await self._color_cluster_handler.color_loop_set( update_flags=( Color.ColorLoopUpdateFlags.Action | Color.ColorLoopUpdateFlags.Direction @@ -417,7 +423,7 @@ class BaseLight(LogMixin, light.LightEntity): self._attr_effect == light.EFFECT_COLORLOOP and effect != light.EFFECT_COLORLOOP ): - result = await self._color_channel.color_loop_set( + result = await self._color_cluster_handler.color_loop_set( update_flags=Color.ColorLoopUpdateFlags.Action, action=Color.ColorLoopAction.Deactivate, direction=Color.ColorLoopDirection.Decrement, @@ -428,7 +434,7 @@ class BaseLight(LogMixin, light.LightEntity): self._attr_effect = None if flash is not None: - result = await self._identify_channel.trigger_effect( + result = await self._identify_cluster_handler.trigger_effect( effect_id=FLASH_EFFECTS[flash], effect_variant=Identify.EffectVariant.Default, ) @@ -457,12 +463,12 @@ class BaseLight(LogMixin, light.LightEntity): # is not none looks odd here, but it will override built in bulb # transition times if we pass 0 in here if transition is not None and supports_level: - result = await self._level_channel.move_to_level_with_on_off( + result = await self._level_cluster_handler.move_to_level_with_on_off( level=0, transition_time=(transition * 10 or self._DEFAULT_MIN_TRANSITION_TIME), ) else: - result = await self._on_off_channel.off() + result = await self._on_off_cluster_handler.off() # Pause parsing attribute reports until transition is complete if self._zha_config_enable_light_transitioning_flag: @@ -503,7 +509,7 @@ class BaseLight(LogMixin, light.LightEntity): ) if temperature is not None: - result = await self._color_channel.move_to_color_temp( + result = await self._color_cluster_handler.move_to_color_temp( color_temp_mireds=temperature, transition_time=transition_time, ) @@ -518,16 +524,16 @@ class BaseLight(LogMixin, light.LightEntity): if hs_color is not None: if ( not isinstance(self, LightGroup) - and self._color_channel.enhanced_hue_supported + and self._color_cluster_handler.enhanced_hue_supported ): - result = await self._color_channel.enhanced_move_to_hue_and_saturation( + result = await self._color_cluster_handler.enhanced_move_to_hue_and_saturation( enhanced_hue=int(hs_color[0] * 65535 / 360), saturation=int(hs_color[1] * 2.54), transition_time=transition_time, ) t_log["enhanced_move_to_hue_and_saturation"] = result else: - result = await self._color_channel.move_to_hue_and_saturation( + result = await self._color_cluster_handler.move_to_hue_and_saturation( hue=int(hs_color[0] * 254 / 360), saturation=int(hs_color[1] * 2.54), transition_time=transition_time, @@ -542,7 +548,7 @@ class BaseLight(LogMixin, light.LightEntity): xy_color = None # don't set xy_color if it is also present if xy_color is not None: - result = await self._color_channel.move_to_color( + result = await self._color_cluster_handler.move_to_color( color_x=int(xy_color[0] * 65535), color_y=int(xy_color[1] * 65535), transition_time=transition_time, @@ -574,8 +580,7 @@ class BaseLight(LogMixin, light.LightEntity): SIGNAL_LIGHT_GROUP_TRANSITION_START, {"entity_ids": self._entity_ids}, ) - if self._transition_listener is not None: - self._transition_listener() + self._async_unsub_transition_listener() @callback def async_transition_start_timer(self, transition_time) -> None: @@ -595,14 +600,19 @@ class BaseLight(LogMixin, light.LightEntity): self.async_transition_complete, ) + @callback + def _async_unsub_transition_listener(self) -> None: + """Unsubscribe transition listener.""" + if self._transition_listener: + self._transition_listener() + self._transition_listener = None + @callback def async_transition_complete(self, _=None) -> None: """Set _transitioning_individual to False and write HA state.""" self.debug("transition complete - future attribute reports will write HA state") self._transitioning_individual = False - if self._transition_listener: - self._transition_listener() - self._transition_listener = None + self._async_unsub_transition_listener() self.async_write_ha_state() if isinstance(self, LightGroup): async_dispatcher_send( @@ -620,24 +630,30 @@ class BaseLight(LogMixin, light.LightEntity): ) -@STRICT_MATCH(channel_names=CHANNEL_ON_OFF, aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}) +@STRICT_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, +) class Light(BaseLight, ZhaEntity): """Representation of a ZHA or ZLL light.""" + _attr_name: str = "Light" _attr_supported_color_modes: set[ColorMode] _REFRESH_INTERVAL = (45, 75) - def __init__(self, unique_id, zha_device: ZHADevice, channels, **kwargs) -> None: + def __init__( + self, unique_id, zha_device: ZHADevice, cluster_handlers, **kwargs + ) -> None: """Initialize the ZHA light.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._on_off_channel = self.cluster_channels[CHANNEL_ON_OFF] - self._attr_state = bool(self._on_off_channel.on_off) - self._level_channel = self.cluster_channels.get(CHANNEL_LEVEL) - self._color_channel = self.cluster_channels.get(CHANNEL_COLOR) - self._identify_channel = self.zha_device.channels.identify_ch - if self._color_channel: - self._attr_min_mireds: int = self._color_channel.min_mireds - self._attr_max_mireds: int = self._color_channel.max_mireds + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._on_off_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_ON_OFF] + self._attr_state = bool(self._on_off_cluster_handler.on_off) + self._level_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_LEVEL) + self._color_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COLOR) + self._identify_cluster_handler = zha_device.identify_ch + if self._color_cluster_handler: + self._attr_min_mireds: int = self._color_cluster_handler.min_mireds + self._attr_max_mireds: int = self._color_cluster_handler.max_mireds self._cancel_refresh_handle: CALLBACK_TYPE | None = None effect_list = [] @@ -649,44 +665,48 @@ class Light(BaseLight, ZhaEntity): ) self._attr_supported_color_modes = {ColorMode.ONOFF} - if self._level_channel: + if self._level_cluster_handler: self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) self._attr_supported_features |= light.LightEntityFeature.TRANSITION - self._attr_brightness = self._level_channel.current_level + self._attr_brightness = self._level_cluster_handler.current_level - if self._color_channel: - if self._color_channel.color_temp_supported: + if self._color_cluster_handler: + if self._color_cluster_handler.color_temp_supported: self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) - self._attr_color_temp = self._color_channel.color_temperature + self._attr_color_temp = self._color_cluster_handler.color_temperature - if self._color_channel.xy_supported and ( + if self._color_cluster_handler.xy_supported and ( self._zha_config_always_prefer_xy_color_mode - or not self._color_channel.hs_supported + or not self._color_cluster_handler.hs_supported ): self._attr_supported_color_modes.add(ColorMode.XY) - curr_x = self._color_channel.current_x - curr_y = self._color_channel.current_y + curr_x = self._color_cluster_handler.current_x + curr_y = self._color_cluster_handler.current_y if curr_x is not None and curr_y is not None: self._attr_xy_color = (curr_x / 65535, curr_y / 65535) else: self._attr_xy_color = (0, 0) if ( - self._color_channel.hs_supported + self._color_cluster_handler.hs_supported and not self._zha_config_always_prefer_xy_color_mode ): self._attr_supported_color_modes.add(ColorMode.HS) if ( - self._color_channel.enhanced_hue_supported - and self._color_channel.enhanced_current_hue is not None + self._color_cluster_handler.enhanced_hue_supported + and self._color_cluster_handler.enhanced_current_hue is not None ): - curr_hue = self._color_channel.enhanced_current_hue * 65535 / 360 - elif self._color_channel.current_hue is not None: - curr_hue = self._color_channel.current_hue * 254 / 360 + curr_hue = ( + self._color_cluster_handler.enhanced_current_hue * 65535 / 360 + ) + elif self._color_cluster_handler.current_hue is not None: + curr_hue = self._color_cluster_handler.current_hue * 254 / 360 else: curr_hue = 0 - if (curr_saturation := self._color_channel.current_saturation) is None: + if ( + curr_saturation := self._color_cluster_handler.current_saturation + ) is None: curr_saturation = 0 self._attr_hs_color = ( @@ -694,10 +714,10 @@ class Light(BaseLight, ZhaEntity): int(curr_saturation * 2.54), ) - if self._color_channel.color_loop_supported: + if self._color_cluster_handler.color_loop_supported: self._attr_supported_features |= light.LightEntityFeature.EFFECT effect_list.append(light.EFFECT_COLORLOOP) - if self._color_channel.color_loop_active == 1: + if self._color_cluster_handler.color_loop_active == 1: self._attr_effect = light.EFFECT_COLORLOOP self._attr_supported_color_modes = filter_supported_color_modes( self._attr_supported_color_modes @@ -705,13 +725,16 @@ class Light(BaseLight, ZhaEntity): if len(self._attr_supported_color_modes) == 1: self._attr_color_mode = next(iter(self._attr_supported_color_modes)) else: # Light supports color_temp + hs, determine which mode the light is in - assert self._color_channel - if self._color_channel.color_mode == Color.ColorMode.Color_temperature: + assert self._color_cluster_handler + if ( + self._color_cluster_handler.color_mode + == Color.ColorMode.Color_temperature + ): self._attr_color_mode = ColorMode.COLOR_TEMP else: self._attr_color_mode = ColorMode.XY - if self._identify_channel: + if self._identify_cluster_handler: self._attr_supported_features |= light.LightEntityFeature.FLASH if effect_list: @@ -755,11 +778,11 @@ class Light(BaseLight, ZhaEntity): """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._on_off_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) - if self._level_channel: + if self._level_cluster_handler: self.async_accept_signal( - self._level_channel, SIGNAL_SET_LEVEL, self.set_level + self._level_cluster_handler, SIGNAL_SET_LEVEL, self.set_level ) refresh_interval = random.randint(*(x * 60 for x in self._REFRESH_INTERVAL)) self._cancel_refresh_handle = async_track_time_interval( @@ -844,8 +867,8 @@ class Light(BaseLight, ZhaEntity): return self.debug("polling current state") - if self._on_off_channel: - state = await self._on_off_channel.get_attribute_value( + if self._on_off_cluster_handler: + state = await self._on_off_cluster_handler.get_attribute_value( "on_off", from_cache=False ) # check if transition started whilst waiting for polled state @@ -858,8 +881,8 @@ class Light(BaseLight, ZhaEntity): self._off_with_transition = False self._off_brightness = None - if self._level_channel: - level = await self._level_channel.get_attribute_value( + if self._level_cluster_handler: + level = await self._level_cluster_handler.get_attribute_value( "current_level", from_cache=False ) # check if transition started whilst waiting for polled state @@ -868,7 +891,7 @@ class Light(BaseLight, ZhaEntity): if level is not None: self._attr_brightness = level - if self._color_channel: + if self._color_cluster_handler: attributes = [ "color_mode", "current_x", @@ -876,23 +899,23 @@ class Light(BaseLight, ZhaEntity): ] if ( not self._zha_config_always_prefer_xy_color_mode - and self._color_channel.enhanced_hue_supported + and self._color_cluster_handler.enhanced_hue_supported ): attributes.append("enhanced_current_hue") attributes.append("current_saturation") if ( - self._color_channel.hs_supported - and not self._color_channel.enhanced_hue_supported + self._color_cluster_handler.hs_supported + and not self._color_cluster_handler.enhanced_hue_supported and not self._zha_config_always_prefer_xy_color_mode ): attributes.append("current_hue") attributes.append("current_saturation") - if self._color_channel.color_temp_supported: + if self._color_cluster_handler.color_temp_supported: attributes.append("color_temperature") - if self._color_channel.color_loop_supported: + if self._color_cluster_handler.color_loop_supported: attributes.append("color_loop_active") - results = await self._color_channel.get_attributes( + results = await self._color_cluster_handler.get_attributes( attributes, from_cache=False, only_cache=False ) @@ -915,7 +938,7 @@ class Light(BaseLight, ZhaEntity): and not self._zha_config_always_prefer_xy_color_mode ): self._attr_color_mode = ColorMode.HS - if self._color_channel.enhanced_hue_supported: + if self._color_cluster_handler.enhanced_hue_supported: current_hue = results.get("enhanced_current_hue") else: current_hue = results.get("current_hue") @@ -923,7 +946,7 @@ class Light(BaseLight, ZhaEntity): if current_hue is not None and current_saturation is not None: self._attr_hs_color = ( int(current_hue * 360 / 65535) - if self._color_channel.enhanced_hue_supported + if self._color_cluster_handler.enhanced_hue_supported else int(current_hue * 360 / 254), int(current_saturation / 2.54), ) @@ -1036,35 +1059,38 @@ class Light(BaseLight, ZhaEntity): @STRICT_MATCH( - channel_names=CHANNEL_ON_OFF, - aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, manufacturers={"Philips", "Signify Netherlands B.V."}, ) class HueLight(Light): """Representation of a HUE light which does not report attributes.""" + _attr_name: str = "Light" _REFRESH_INTERVAL = (3, 5) @STRICT_MATCH( - channel_names=CHANNEL_ON_OFF, - aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, manufacturers={"Jasco", "Quotra-Vision", "eWeLight", "eWeLink"}, ) class ForceOnLight(Light): """Representation of a light which does not respect move_to_level_with_on_off.""" + _attr_name: str = "Light" _FORCE_ON = True @STRICT_MATCH( - channel_names=CHANNEL_ON_OFF, - aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, manufacturers=DEFAULT_MIN_TRANSITION_MANUFACTURERS, ) class MinTransitionLight(Light): """Representation of a light which does not react to any "move to" calls with 0 as a transition.""" + _attr_name: str = "Light" _DEFAULT_MIN_TRANSITION_TIME = 1 @@ -1089,11 +1115,11 @@ class LightGroup(BaseLight, ZhaGroupEntity): # If at least one member has a color cluster and doesn't support it, # it's not used. for member in group.members: - for pool in member.device.channels.pools: - for channel in pool.all_channels.values(): + for endpoint in member.device._endpoints.values(): + for cluster_handler in endpoint.all_cluster_handlers.values(): if ( - channel.name == CHANNEL_COLOR - and not channel.execute_if_off_supported + cluster_handler.name == CLUSTER_HANDLER_COLOR + and not cluster_handler.execute_if_off_supported ): self._GROUP_SUPPORTS_EXECUTE_IF_OFF = False break @@ -1102,10 +1128,10 @@ class LightGroup(BaseLight, ZhaGroupEntity): member.device.manufacturer in DEFAULT_MIN_TRANSITION_MANUFACTURERS for member in group.members ) - self._on_off_channel = group.endpoint[OnOff.cluster_id] - self._level_channel = group.endpoint[LevelControl.cluster_id] - self._color_channel = group.endpoint[Color.cluster_id] - self._identify_channel = group.endpoint[Identify.cluster_id] + self._on_off_cluster_handler = group.endpoint[OnOff.cluster_id] + self._level_cluster_handler = group.endpoint[LevelControl.cluster_id] + self._color_cluster_handler = group.endpoint[Color.cluster_id] + self._identify_cluster_handler = group.endpoint[Identify.cluster_id] self._debounced_member_refresh: Debouncer | None = None self._zha_config_transition = async_get_zha_config_value( zha_device.gateway.config_entry, @@ -1154,6 +1180,7 @@ class LightGroup(BaseLight, ZhaGroupEntity): function=self._force_member_updates, ) self._debounced_member_refresh = force_refresh_debouncer + self.async_on_remove(force_refresh_debouncer.async_cancel) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 433f662a78..2f6bce0b20 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -19,7 +19,7 @@ from homeassistant.helpers.typing import StateType from .core import discovery from .core.const import ( - CHANNEL_DOORLOCK, + CLUSTER_HANDLER_DOORLOCK, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -92,20 +92,24 @@ async def async_setup_entry( ) -@MULTI_MATCH(channel_names=CHANNEL_DOORLOCK) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_DOORLOCK) class ZhaDoorLock(ZhaEntity, LockEntity): """Representation of a ZHA lock.""" - def __init__(self, unique_id, zha_device, channels, **kwargs): + _attr_name: str = "Door lock" + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Init this sensor.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._doorlock_channel = self.cluster_channels.get(CHANNEL_DOORLOCK) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._doorlock_cluster_handler = self.cluster_handlers.get( + CLUSTER_HANDLER_DOORLOCK + ) async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._doorlock_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._doorlock_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) @callback @@ -127,7 +131,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - result = await self._doorlock_channel.lock_door() + result = await self._doorlock_cluster_handler.lock_door() if isinstance(result, Exception) or result[0] is not Status.SUCCESS: self.error("Error with lock_door: %s", result) return @@ -135,7 +139,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" - result = await self._doorlock_channel.unlock_door() + result = await self._doorlock_cluster_handler.unlock_door() if isinstance(result, Exception) or result[0] is not Status.SUCCESS: self.error("Error with unlock_door: %s", result) return @@ -148,14 +152,14 @@ class ZhaDoorLock(ZhaEntity, LockEntity): @callback def async_set_state(self, attr_id, attr_name, value): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self._state = VALUE_TO_STATE.get(value, self._state) self.async_write_ha_state() async def async_get_state(self, from_cache=True): """Attempt to retrieve state from the lock.""" - if self._doorlock_channel: - state = await self._doorlock_channel.get_attribute_value( + if self._doorlock_cluster_handler: + state = await self._doorlock_cluster_handler.get_attribute_value( "lock_state", from_cache=from_cache ) if state is not None: @@ -167,24 +171,26 @@ class ZhaDoorLock(ZhaEntity, LockEntity): async def async_set_lock_user_code(self, code_slot: int, user_code: str) -> None: """Set the user_code to index X on the lock.""" - if self._doorlock_channel: - await self._doorlock_channel.async_set_user_code(code_slot, user_code) + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_set_user_code( + code_slot, user_code + ) self.debug("User code at slot %s set", code_slot) async def async_enable_lock_user_code(self, code_slot: int) -> None: """Enable user_code at index X on the lock.""" - if self._doorlock_channel: - await self._doorlock_channel.async_enable_user_code(code_slot) + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_enable_user_code(code_slot) self.debug("User code at slot %s enabled", code_slot) async def async_disable_lock_user_code(self, code_slot: int) -> None: """Disable user_code at index X on the lock.""" - if self._doorlock_channel: - await self._doorlock_channel.async_disable_user_code(code_slot) + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_disable_user_code(code_slot) self.debug("User code at slot %s disabled", code_slot) async def async_clear_lock_user_code(self, code_slot: int) -> None: """Clear the user_code at index X on the lock.""" - if self._doorlock_channel: - await self._doorlock_channel.async_clear_user_code(code_slot) + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_clear_user_code(code_slot) self.debug("User code at slot %s cleared", code_slot) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 3061d867b6..2e08b0cc6d 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,15 +20,15 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.34.10", + "bellows==0.35.1", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.94", - "zigpy-deconz==0.19.2", - "zigpy==0.53.2", - "zigpy-xbee==0.16.2", - "zigpy-zigate==0.10.3", - "zigpy-znp==0.9.3" + "zha-quirks==0.0.97", + "zigpy-deconz==0.21.0", + "zigpy==0.55.0", + "zigpy-xbee==0.18.0", + "zigpy-zigate==0.11.0", + "zigpy-znp==0.11.1" ], "usb": [ { diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 334b72dccc..6bc6f30a34 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -11,18 +11,18 @@ from zigpy.zcl.foundation import Status from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform, UnitOfMass +from homeassistant.const import EntityCategory, Platform, UnitOfMass, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( - CHANNEL_ANALOG_OUTPUT, - CHANNEL_BASIC, - CHANNEL_COLOR, - CHANNEL_INOVELLI, - CHANNEL_LEVEL, + CLUSTER_HANDLER_ANALOG_OUTPUT, + CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_COLOR, + CLUSTER_HANDLER_INOVELLI, + CLUSTER_HANDLER_LEVEL, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -31,7 +31,7 @@ from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice _LOGGER = logging.getLogger(__name__) @@ -275,37 +275,43 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -@STRICT_MATCH(channel_names=CHANNEL_ANALOG_OUTPUT) +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ANALOG_OUTPUT) class ZhaNumber(ZhaEntity, NumberEntity): """Representation of a ZHA Number entity.""" + _attr_name: str = "Number" + def __init__( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this entity.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._analog_output_channel = self.cluster_channels[CHANNEL_ANALOG_OUTPUT] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._analog_output_cluster_handler = self.cluster_handlers[ + CLUSTER_HANDLER_ANALOG_OUTPUT + ] async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._analog_output_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._analog_output_cluster_handler, + SIGNAL_ATTR_UPDATED, + self.async_set_state, ) @property def native_value(self) -> float | None: """Return the current value.""" - return self._analog_output_channel.present_value + return self._analog_output_cluster_handler.present_value @property def native_min_value(self) -> float: """Return the minimum value.""" - min_present_value = self._analog_output_channel.min_present_value + min_present_value = self._analog_output_cluster_handler.min_present_value if min_present_value is not None: return min_present_value return 0 @@ -313,7 +319,7 @@ class ZhaNumber(ZhaEntity, NumberEntity): @property def native_max_value(self) -> float: """Return the maximum value.""" - max_present_value = self._analog_output_channel.max_present_value + max_present_value = self._analog_output_cluster_handler.max_present_value if max_present_value is not None: return max_present_value return 1023 @@ -321,15 +327,15 @@ class ZhaNumber(ZhaEntity, NumberEntity): @property def native_step(self) -> float | None: """Return the value step.""" - resolution = self._analog_output_channel.resolution + resolution = self._analog_output_cluster_handler.resolution if resolution is not None: return resolution return super().native_step @property - def name(self) -> str: + def name(self) -> str | None: """Return the name of the number entity.""" - description = self._analog_output_channel.description + description = self._analog_output_cluster_handler.description if description is not None and len(description) > 0: return f"{super().name} {description}" return super().name @@ -337,7 +343,7 @@ class ZhaNumber(ZhaEntity, NumberEntity): @property def icon(self) -> str | None: """Return the icon to be used for this entity.""" - application_type = self._analog_output_channel.application_type + application_type = self._analog_output_cluster_handler.application_type if application_type is not None: return ICONS.get(application_type >> 16, super().icon) return super().icon @@ -345,26 +351,26 @@ class ZhaNumber(ZhaEntity, NumberEntity): @property def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" - engineering_units = self._analog_output_channel.engineering_units + engineering_units = self._analog_output_cluster_handler.engineering_units return UNITS.get(engineering_units) @callback def async_set_state(self, attr_id, attr_name, value): - """Handle value update from channel.""" + """Handle value update from cluster handler.""" self.async_write_ha_state() async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" num_value = float(value) - if await self._analog_output_channel.async_set_present_value(num_value): + if await self._analog_output_cluster_handler.async_set_present_value(num_value): self.async_write_ha_state() async def async_update(self) -> None: """Attempt to retrieve the state of the entity.""" await super().async_update() _LOGGER.debug("polling current state") - if self._analog_output_channel: - value = await self._analog_output_channel.get_attribute_value( + if self._analog_output_cluster_handler: + value = await self._analog_output_cluster_handler.get_attribute_value( "present_value", from_cache=False ) _LOGGER.debug("read value=%s", value) @@ -375,6 +381,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): _attr_entity_category = EntityCategory.CONFIG _attr_native_step: float = 1.0 + _attr_multiplier: float = 1 _zcl_attribute: str @classmethod @@ -382,17 +389,17 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. Return entity if it is a supported configuration, otherwise return None """ - channel = channels[0] + cluster_handler = cluster_handlers[0] if ( - cls._zcl_attribute in channel.cluster.unsupported_attributes - or channel.cluster.get(cls._zcl_attribute) is None + cls._zcl_attribute in cluster_handler.cluster.unsupported_attributes + or cluster_handler.cluster.get(cls._zcl_attribute) is None ): _LOGGER.debug( "%s is not supported - skipping %s entity creation", @@ -401,29 +408,32 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): ) return None - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) def __init__( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this number configuration entity.""" - self._channel: ZigbeeChannel = channels[0] - super().__init__(unique_id, zha_device, channels, **kwargs) + self._cluster_handler: ClusterHandler = cluster_handlers[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) @property def native_value(self) -> float: """Return the current value.""" - return self._channel.cluster.get(self._zcl_attribute) + return ( + self._cluster_handler.cluster.get(self._zcl_attribute) + * self._attr_multiplier + ) async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" try: - res = await self._channel.cluster.write_attributes( - {self._zcl_attribute: int(value)} + res = await self._cluster_handler.cluster.write_attributes( + {self._zcl_attribute: int(value / self._attr_multiplier)} ) except zigpy.exceptions.ZigbeeException as ex: self.error("Could not set value: %s", ex) @@ -437,15 +447,16 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): """Attempt to retrieve the state of the entity.""" await super().async_update() _LOGGER.debug("polling current state") - if self._channel: - value = await self._channel.get_attribute_value( + if self._cluster_handler: + value = await self._cluster_handler.get_attribute_value( self._zcl_attribute, from_cache=False ) _LOGGER.debug("read value=%s", value) @CONFIG_DIAGNOSTIC_MATCH( - channel_names="opple_cluster", models={"lumi.motion.ac02", "lumi.motion.agl04"} + cluster_handler_names="opple_cluster", + models={"lumi.motion.ac02", "lumi.motion.agl04"}, ) class AqaraMotionDetectionInterval( ZHANumberConfigurationEntity, id_suffix="detection_interval" @@ -458,7 +469,7 @@ class AqaraMotionDetectionInterval( _attr_name = "Detection interval" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) class OnOffTransitionTimeConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="on_off_transition_time" ): @@ -470,7 +481,7 @@ class OnOffTransitionTimeConfigurationEntity( _attr_name = "On/Off transition time" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_level"): """Representation of a ZHA on level configuration entity.""" @@ -480,7 +491,7 @@ class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_lev _attr_name = "On level" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) class OnTransitionTimeConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="on_transition_time" ): @@ -492,7 +503,7 @@ class OnTransitionTimeConfigurationEntity( _attr_name = "On transition time" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) class OffTransitionTimeConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="off_transition_time" ): @@ -504,7 +515,7 @@ class OffTransitionTimeConfigurationEntity( _attr_name = "Off transition time" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) class DefaultMoveRateConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="default_move_rate" ): @@ -516,7 +527,7 @@ class DefaultMoveRateConfigurationEntity( _attr_name = "Default move rate" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) class StartUpCurrentLevelConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="start_up_current_level" ): @@ -528,7 +539,7 @@ class StartUpCurrentLevelConfigurationEntity( _attr_name = "Start-up current level" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_COLOR) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COLOR) class StartUpColorTemperatureConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="start_up_color_temperature" ): @@ -543,18 +554,18 @@ class StartUpColorTemperatureConfigurationEntity( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this ZHA startup color temperature entity.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - if self._channel: - self._attr_native_min_value: float = self._channel.min_mireds - self._attr_native_max_value: float = self._channel.max_mireds + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + if self._cluster_handler: + self._attr_native_min_value: float = self._cluster_handler.min_mireds + self._attr_native_max_value: float = self._cluster_handler.max_mireds @CONFIG_DIAGNOSTIC_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_htnnfasr", }, @@ -571,7 +582,7 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_durati _attr_name = "Timer duration" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="ikea_airpurifier") +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names="ikea_airpurifier") class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time"): """Representation of a ZHA filter lifetime configuration entity.""" @@ -585,7 +596,7 @@ class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time") @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_BASIC, + cluster_handler_names=CLUSTER_HANDLER_BASIC, manufacturers={"TexasInstruments"}, models={"ti.router"}, ) @@ -598,7 +609,7 @@ class TiRouterTransmitPower(ZHANumberConfigurationEntity, id_suffix="transmit_po _attr_name = "Transmit power" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliRemoteDimmingUpSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_remote" ): @@ -612,7 +623,7 @@ class InovelliRemoteDimmingUpSpeed( _attr_name: str = "Remote dimming up speed" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliButtonDelay(ZHANumberConfigurationEntity, id_suffix="button_delay"): """Inovelli button delay configuration entity.""" @@ -624,7 +635,7 @@ class InovelliButtonDelay(ZHANumberConfigurationEntity, id_suffix="button_delay" _attr_name: str = "Button delay" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliLocalDimmingUpSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_local" ): @@ -638,7 +649,7 @@ class InovelliLocalDimmingUpSpeed( _attr_name: str = "Local dimming up speed" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliLocalRampRateOffToOn( ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_local" ): @@ -652,7 +663,7 @@ class InovelliLocalRampRateOffToOn( _attr_name: str = "Local ramp rate off to on" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliRemoteDimmingSpeedOffToOn( ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_remote" ): @@ -666,7 +677,7 @@ class InovelliRemoteDimmingSpeedOffToOn( _attr_name: str = "Remote ramp rate off to on" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliRemoteDimmingDownSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_remote" ): @@ -680,7 +691,7 @@ class InovelliRemoteDimmingDownSpeed( _attr_name: str = "Remote dimming down speed" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliLocalDimmingDownSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_local" ): @@ -694,7 +705,7 @@ class InovelliLocalDimmingDownSpeed( _attr_name: str = "Local dimming down speed" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliLocalRampRateOnToOff( ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_local" ): @@ -708,7 +719,7 @@ class InovelliLocalRampRateOnToOff( _attr_name: str = "Local ramp rate on to off" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliRemoteDimmingSpeedOnToOff( ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_remote" ): @@ -722,7 +733,7 @@ class InovelliRemoteDimmingSpeedOnToOff( _attr_name: str = "Remote ramp rate on to off" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliMinimumLoadDimmingLevel( ZHANumberConfigurationEntity, id_suffix="minimum_level" ): @@ -736,7 +747,7 @@ class InovelliMinimumLoadDimmingLevel( _attr_name: str = "Minimum load dimming level" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliMaximumLoadDimmingLevel( ZHANumberConfigurationEntity, id_suffix="maximum_level" ): @@ -750,7 +761,7 @@ class InovelliMaximumLoadDimmingLevel( _attr_name: str = "Maximum load dimming level" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliAutoShutoffTimer( ZHANumberConfigurationEntity, id_suffix="auto_off_timer" ): @@ -764,7 +775,7 @@ class InovelliAutoShutoffTimer( _attr_name: str = "Automatic switch shutoff timer" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliLoadLevelIndicatorTimeout( ZHANumberConfigurationEntity, id_suffix="load_level_indicator_timeout" ): @@ -778,7 +789,7 @@ class InovelliLoadLevelIndicatorTimeout( _attr_name: str = "Load level indicator timeout" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliDefaultAllLEDOnColor( ZHANumberConfigurationEntity, id_suffix="led_color_when_on" ): @@ -792,7 +803,7 @@ class InovelliDefaultAllLEDOnColor( _attr_name: str = "Default all LED on color" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliDefaultAllLEDOffColor( ZHANumberConfigurationEntity, id_suffix="led_color_when_off" ): @@ -806,7 +817,7 @@ class InovelliDefaultAllLEDOffColor( _attr_name: str = "Default all LED off color" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliDefaultAllLEDOnIntensity( ZHANumberConfigurationEntity, id_suffix="led_intensity_when_on" ): @@ -820,7 +831,7 @@ class InovelliDefaultAllLEDOnIntensity( _attr_name: str = "Default all LED on intensity" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliDefaultAllLEDOffIntensity( ZHANumberConfigurationEntity, id_suffix="led_intensity_when_off" ): @@ -834,7 +845,37 @@ class InovelliDefaultAllLEDOffIntensity( _attr_name: str = "Default all LED off intensity" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +class InovelliDoubleTapUpLevel( + ZHANumberConfigurationEntity, id_suffix="double_tap_up_level" +): + """Inovelli double tap up level configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[16] + _attr_native_min_value: float = 2 + _attr_native_max_value: float = 254 + _zcl_attribute: str = "double_tap_up_level" + _attr_name: str = "Double tap up level" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +class InovelliDoubleTapDownLevel( + ZHANumberConfigurationEntity, id_suffix="double_tap_down_level" +): + """Inovelli double tap down level configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[16] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 254 + _zcl_attribute: str = "double_tap_down_level" + _attr_name: str = "Double tap down level" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} +) class AqaraPetFeederServingSize(ZHANumberConfigurationEntity, id_suffix="serving_size"): """Aqara pet feeder serving size configuration entity.""" @@ -847,7 +888,9 @@ class AqaraPetFeederServingSize(ZHANumberConfigurationEntity, id_suffix="serving _attr_icon: str = "mdi:counter" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} +) class AqaraPetFeederPortionWeight( ZHANumberConfigurationEntity, id_suffix="portion_weight" ): @@ -861,3 +904,22 @@ class AqaraPetFeederPortionWeight( _attr_mode: NumberMode = NumberMode.BOX _attr_native_unit_of_measurement: str = UnitOfMass.GRAMS _attr_icon: str = "mdi:weight-gram" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) +class AqaraThermostatAwayTemp( + ZHANumberConfigurationEntity, id_suffix="away_preset_temperature" +): + """Aqara away preset temperature configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value: float = 5 + _attr_native_max_value: float = 30 + _attr_multiplier: float = 0.01 + _zcl_attribute: str = "away_preset_temperature" + _attr_name: str = "Away preset temperature" + _attr_mode: NumberMode = NumberMode.SLIDER + _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS + _attr_icon: str = ICONS[0] diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index b4cbce5540..1bab8a3f2c 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -20,10 +20,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( - CHANNEL_IAS_WD, - CHANNEL_INOVELLI, - CHANNEL_OCCUPANCY, - CHANNEL_ON_OFF, + CLUSTER_HANDLER_IAS_WD, + CLUSTER_HANDLER_INOVELLI, + CLUSTER_HANDLER_OCCUPANCY, + CLUSTER_HANDLER_ON_OFF, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -33,7 +33,7 @@ from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice @@ -74,33 +74,35 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this select entity.""" self._attribute = self._enum.__name__ self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] - self._channel: ZigbeeChannel = channels[0] - super().__init__(unique_id, zha_device, channels, **kwargs) + self._cluster_handler: ClusterHandler = cluster_handlers[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - option = self._channel.data_cache.get(self._attribute) + option = self._cluster_handler.data_cache.get(self._attribute) if option is None: return None return option.name.replace("_", " ") async def async_select_option(self, option: str) -> None: """Change the selected option.""" - self._channel.data_cache[self._attribute] = self._enum[option.replace(" ", "_")] + self._cluster_handler.data_cache[self._attribute] = self._enum[ + option.replace(" ", "_") + ] self.async_write_ha_state() @callback def async_restore_last_state(self, last_state) -> None: """Restore previous state.""" if last_state.state and last_state.state != STATE_UNKNOWN: - self._channel.data_cache[self._attribute] = self._enum[ + self._cluster_handler.data_cache[self._attribute] = self._enum[ last_state.state.replace(" ", "_") ] @@ -114,7 +116,7 @@ class ZHANonZCLSelectEntity(ZHAEnumSelectEntity): return True -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) class ZHADefaultToneSelectEntity( ZHANonZCLSelectEntity, id_suffix=IasWd.Warning.WarningMode.__name__ ): @@ -124,7 +126,7 @@ class ZHADefaultToneSelectEntity( _attr_name = "Default siren tone" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) class ZHADefaultSirenLevelSelectEntity( ZHANonZCLSelectEntity, id_suffix=IasWd.Warning.SirenLevel.__name__ ): @@ -134,7 +136,7 @@ class ZHADefaultSirenLevelSelectEntity( _attr_name = "Default siren level" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) class ZHADefaultStrobeLevelSelectEntity( ZHANonZCLSelectEntity, id_suffix=IasWd.StrobeLevel.__name__ ): @@ -144,7 +146,7 @@ class ZHADefaultStrobeLevelSelectEntity( _attr_name = "Default strobe level" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity, id_suffix=Strobe.__name__): """Representation of a ZHA default siren strobe select entity.""" @@ -164,17 +166,17 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. Return entity if it is a supported configuration, otherwise return None """ - channel = channels[0] + cluster_handler = cluster_handlers[0] if ( - cls._select_attr in channel.cluster.unsupported_attributes - or channel.cluster.get(cls._select_attr) is None + cls._select_attr in cluster_handler.cluster.unsupported_attributes + or cluster_handler.cluster.get(cls._select_attr) is None ): _LOGGER.debug( "%s is not supported - skipping %s entity creation", @@ -183,24 +185,24 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): ) return None - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) def __init__( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this select entity.""" self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] - self._channel: ZigbeeChannel = channels[0] - super().__init__(unique_id, zha_device, channels, **kwargs) + self._cluster_handler: ClusterHandler = cluster_handlers[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - option = self._channel.cluster.get(self._select_attr) + option = self._cluster_handler.cluster.get(self._select_attr) if option is None: return None option = self._enum(option) @@ -208,7 +210,7 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self._channel.cluster.write_attributes( + await self._cluster_handler.cluster.write_attributes( {self._select_attr: self._enum[option.replace(" ", "_")]} ) self.async_write_ha_state() @@ -217,16 +219,16 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) @callback def async_set_state(self, attr_id: int, attr_name: str, value: Any): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_ON_OFF) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) class ZHAStartupOnOffSelectEntity( ZCLEnumSelectEntity, id_suffix=OnOff.StartUpOnOff.__name__ ): @@ -246,11 +248,11 @@ class TuyaPowerOnState(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_ON_OFF, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, models={"TS011F", "TS0121", "TS0001", "TS0002", "TS0003", "TS0004"}, ) @CONFIG_DIAGNOSTIC_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_7tdtqgwv", "_TZE200_amp6tsvy", @@ -287,7 +289,7 @@ class TuyaBacklightMode(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_ON_OFF, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, models={"TS011F", "TS0121", "TS0001", "TS0002", "TS0003", "TS0004"}, ) class TuyaBacklightModeSelectEntity(ZCLEnumSelectEntity, id_suffix="backlight_mode"): @@ -308,7 +310,7 @@ class MoesBacklightMode(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_7tdtqgwv", "_TZE200_amp6tsvy", @@ -345,7 +347,7 @@ class AqaraMotionSensitivities(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names="opple_cluster", + cluster_handler_names="opple_cluster", models={"lumi.motion.ac01", "lumi.motion.ac02", "lumi.motion.agl04"}, ) class AqaraMotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity"): @@ -365,7 +367,7 @@ class HueV1MotionSensitivities(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_OCCUPANCY, + cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY, manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML001"}, ) @@ -388,7 +390,7 @@ class HueV2MotionSensitivities(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_OCCUPANCY, + cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY, manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML002", "SML003", "SML004"}, ) @@ -407,7 +409,9 @@ class AqaraMonitoringModess(types.enum8): Left_Right = 0x01 -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac01"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} +) class AqaraMonitoringMode(ZCLEnumSelectEntity, id_suffix="monitoring_mode"): """Representation of a ZHA monitoring mode configuration entity.""" @@ -424,7 +428,9 @@ class AqaraApproachDistances(types.enum8): Near = 0x02 -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac01"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} +) class AqaraApproachDistance(ZCLEnumSelectEntity, id_suffix="approach_distance"): """Representation of a ZHA approach distance configuration entity.""" @@ -441,7 +447,7 @@ class AqaraE1ReverseDirection(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names="window_covering", models={"lumi.curtain.agl001"} + cluster_handler_names="window_covering", models={"lumi.curtain.agl001"} ) class AqaraCurtainMode(ZCLEnumSelectEntity, id_suffix="window_covering_mode"): """Representation of a ZHA curtain mode configuration entity.""" @@ -459,7 +465,7 @@ class InovelliOutputMode(types.enum1): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliOutputModeEntity(ZCLEnumSelectEntity, id_suffix="output_mode"): """Inovelli output mode control.""" @@ -472,13 +478,14 @@ class InovelliOutputModeEntity(ZCLEnumSelectEntity, id_suffix="output_mode"): class InovelliSwitchType(types.enum8): """Inovelli output mode.""" - Load_Only = 0x00 + Single_Pole = 0x00 Three_Way_Dumb = 0x01 Three_Way_AUX = 0x02 + Single_Pole_Full_Sine = 0x03 @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliSwitchTypeEntity(ZCLEnumSelectEntity, id_suffix="switch_type"): """Inovelli switch type control.""" @@ -488,6 +495,44 @@ class InovelliSwitchTypeEntity(ZCLEnumSelectEntity, id_suffix="switch_type"): _attr_name: str = "Switch type" +class InovelliLedScalingMode(types.enum1): + """Inovelli led mode.""" + + VZM31SN = 0x00 + LZW31SN = 0x01 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliLedScalingModeEntity(ZCLEnumSelectEntity, id_suffix="led_scaling_mode"): + """Inovelli led mode control.""" + + _select_attr = "led_scaling_mode" + _enum = InovelliLedScalingMode + _attr_name: str = "Led scaling mode" + + +class InovelliNonNeutralOutput(types.enum1): + """Inovelli non neutral output selection.""" + + Low = 0x00 + High = 0x01 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliNonNeutralOutputEntity( + ZCLEnumSelectEntity, id_suffix="increased_non_neutral_output" +): + """Inovelli non neutral output control.""" + + _select_attr = "increased_non_neutral_output" + _enum = InovelliNonNeutralOutput + _attr_name: str = "Non neutral output" + + class AqaraFeedingMode(types.enum8): """Feeding mode.""" @@ -495,7 +540,9 @@ class AqaraFeedingMode(types.enum8): Schedule = 0x01 -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} +) class AqaraPetFeederMode(ZCLEnumSelectEntity, id_suffix="feeding_mode"): """Representation of an Aqara pet feeder mode configuration entity.""" @@ -503,3 +550,22 @@ class AqaraPetFeederMode(ZCLEnumSelectEntity, id_suffix="feeding_mode"): _enum = AqaraFeedingMode _attr_name = "Mode" _attr_icon: str = "mdi:wrench-clock" + + +class AqaraThermostatPresetMode(types.enum8): + """Thermostat preset mode.""" + + Manual = 0x00 + Auto = 0x01 + Away = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) +class AqaraThermostatPreset(ZCLEnumSelectEntity, id_suffix="preset"): + """Representation of an Aqara thermostat preset configuration entity.""" + + _select_attr = "preset" + _enum = AqaraThermostatPresetMode + _attr_name = "Preset" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 78ce47c7e5..dda9412b56 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -46,19 +46,19 @@ from homeassistant.helpers.typing import StateType from .core import discovery from .core.const import ( - CHANNEL_ANALOG_INPUT, - CHANNEL_BASIC, - CHANNEL_DEVICE_TEMPERATURE, - CHANNEL_ELECTRICAL_MEASUREMENT, - CHANNEL_HUMIDITY, - CHANNEL_ILLUMINANCE, - CHANNEL_LEAF_WETNESS, - CHANNEL_POWER_CONFIGURATION, - CHANNEL_PRESSURE, - CHANNEL_SMARTENERGY_METERING, - CHANNEL_SOIL_MOISTURE, - CHANNEL_TEMPERATURE, - CHANNEL_THERMOSTAT, + CLUSTER_HANDLER_ANALOG_INPUT, + CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_DEVICE_TEMPERATURE, + CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + CLUSTER_HANDLER_HUMIDITY, + CLUSTER_HANDLER_ILLUMINANCE, + CLUSTER_HANDLER_LEAF_WETNESS, + CLUSTER_HANDLER_POWER_CONFIGURATION, + CLUSTER_HANDLER_PRESSURE, + CLUSTER_HANDLER_SMARTENERGY_METERING, + CLUSTER_HANDLER_SOIL_MOISTURE, + CLUSTER_HANDLER_TEMPERATURE, + CLUSTER_HANDLER_THERMOSTAT, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -67,7 +67,7 @@ from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES from .entity import ZhaEntity if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice PARALLEL_UPDATES = 5 @@ -88,7 +88,9 @@ BATTERY_SIZES = { 255: "Unknown", } -CHANNEL_ST_HUMIDITY_CLUSTER = f"channel_0x{SMARTTHINGS_HUMIDITY_CLUSTER:04x}" +CLUSTER_HANDLER_ST_HUMIDITY_CLUSTER = ( + f"cluster_handler_0x{SMARTTHINGS_HUMIDITY_CLUSTER:04x}" +) STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SENSOR) MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SENSOR) @@ -125,50 +127,50 @@ class Sensor(ZhaEntity, SensorEntity): self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this sensor.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: ZigbeeChannel = channels[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cluster_handler: ClusterHandler = cluster_handlers[0] @classmethod def create_entity( cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. Return entity if it is a supported configuration, otherwise return None """ - channel = channels[0] - if cls.SENSOR_ATTR in channel.cluster.unsupported_attributes: + cluster_handler = cluster_handlers[0] + if cls.SENSOR_ATTR in cluster_handler.cluster.unsupported_attributes: return None - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) @property def native_value(self) -> StateType: """Return the state of the entity.""" assert self.SENSOR_ATTR is not None - raw_state = self._channel.cluster.get(self.SENSOR_ATTR) + raw_state = self._cluster_handler.cluster.get(self.SENSOR_ATTR) if raw_state is None: return None return self.formatter(raw_state) @callback def async_set_state(self, attr_id: int, attr_name: str, value: Any) -> None: - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() def formatter(self, value: int | enum.IntEnum) -> int | float | str | None: @@ -181,17 +183,18 @@ class Sensor(ZhaEntity, SensorEntity): @MULTI_MATCH( - channel_names=CHANNEL_ANALOG_INPUT, + cluster_handler_names=CLUSTER_HANDLER_ANALOG_INPUT, manufacturers="Digi", - stop_on_match_group=CHANNEL_ANALOG_INPUT, + stop_on_match_group=CLUSTER_HANDLER_ANALOG_INPUT, ) class AnalogInput(Sensor): """Sensor that displays analog input values.""" SENSOR_ATTR = "present_value" + _attr_name: str = "Analog input" -@MULTI_MATCH(channel_names=CHANNEL_POWER_CONFIGURATION) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_POWER_CONFIGURATION) class Battery(Sensor): """Battery sensor of power configuration cluster.""" @@ -207,7 +210,7 @@ class Battery(Sensor): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -216,7 +219,9 @@ class Battery(Sensor): battery_percent_remaining attribute, but zha-device-handlers takes care of it so create the entity regardless """ - return cls(unique_id, zha_device, channels, **kwargs) + if zha_device.is_mains_powered: + return None + return cls(unique_id, zha_device, cluster_handlers, **kwargs) @staticmethod def formatter(value: int) -> int | None: # pylint: disable=arguments-differ @@ -231,19 +236,19 @@ class Battery(Sensor): def extra_state_attributes(self) -> dict[str, Any]: """Return device state attrs for battery sensors.""" state_attrs = {} - battery_size = self._channel.cluster.get("battery_size") + battery_size = self._cluster_handler.cluster.get("battery_size") if battery_size is not None: state_attrs["battery_size"] = BATTERY_SIZES.get(battery_size, "Unknown") - battery_quantity = self._channel.cluster.get("battery_quantity") + battery_quantity = self._cluster_handler.cluster.get("battery_quantity") if battery_quantity is not None: state_attrs["battery_quantity"] = battery_quantity - battery_voltage = self._channel.cluster.get("battery_voltage") + battery_voltage = self._cluster_handler.cluster.get("battery_voltage") if battery_voltage is not None: state_attrs["battery_voltage"] = round(battery_voltage / 10, 2) return state_attrs -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurement(Sensor): """Active power measurement.""" @@ -259,19 +264,21 @@ class ElectricalMeasurement(Sensor): def extra_state_attributes(self) -> dict[str, Any]: """Return device state attrs for sensor.""" attrs = {} - if self._channel.measurement_type is not None: - attrs["measurement_type"] = self._channel.measurement_type + if self._cluster_handler.measurement_type is not None: + attrs["measurement_type"] = self._cluster_handler.measurement_type max_attr_name = f"{self.SENSOR_ATTR}_max" - if (max_v := self._channel.cluster.get(max_attr_name)) is not None: + if (max_v := self._cluster_handler.cluster.get(max_attr_name)) is not None: attrs[max_attr_name] = str(self.formatter(max_v)) return attrs def formatter(self, value: int) -> int | float: """Return 'normalized' value.""" - multiplier = getattr(self._channel, f"{self._div_mul_prefix}_multiplier") - divisor = getattr(self._channel, f"{self._div_mul_prefix}_divisor") + multiplier = getattr( + self._cluster_handler, f"{self._div_mul_prefix}_multiplier" + ) + divisor = getattr(self._cluster_handler, f"{self._div_mul_prefix}_divisor") value = float(value * multiplier) / divisor if value < 100 and divisor > 1: return round(value, self._decimals) @@ -284,7 +291,7 @@ class ElectricalMeasurement(Sensor): await super().async_update() -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementApparentPower( ElectricalMeasurement, id_suffix="apparent_power" ): @@ -298,7 +305,7 @@ class ElectricalMeasurementApparentPower( _div_mul_prefix = "ac_power" -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_current"): """RMS current measurement.""" @@ -310,7 +317,7 @@ class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_curr _div_mul_prefix = "ac_current" -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_voltage"): """RMS Voltage measurement.""" @@ -322,7 +329,7 @@ class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_volt _div_mul_prefix = "ac_voltage" -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_frequency"): """Frequency measurement.""" @@ -334,7 +341,7 @@ class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_freque _div_mul_prefix = "ac_frequency" -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_factor"): """Frequency measurement.""" @@ -346,9 +353,13 @@ class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_f @MULTI_MATCH( - generic_ids=CHANNEL_ST_HUMIDITY_CLUSTER, stop_on_match_group=CHANNEL_HUMIDITY + generic_ids=CLUSTER_HANDLER_ST_HUMIDITY_CLUSTER, + stop_on_match_group=CLUSTER_HANDLER_HUMIDITY, +) +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_HUMIDITY, + stop_on_match_group=CLUSTER_HANDLER_HUMIDITY, ) -@MULTI_MATCH(channel_names=CHANNEL_HUMIDITY, stop_on_match_group=CHANNEL_HUMIDITY) class Humidity(Sensor): """Humidity sensor.""" @@ -360,7 +371,7 @@ class Humidity(Sensor): _attr_native_unit_of_measurement = PERCENTAGE -@MULTI_MATCH(channel_names=CHANNEL_SOIL_MOISTURE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_SOIL_MOISTURE) class SoilMoisture(Sensor): """Soil Moisture sensor.""" @@ -372,7 +383,7 @@ class SoilMoisture(Sensor): _attr_native_unit_of_measurement = PERCENTAGE -@MULTI_MATCH(channel_names=CHANNEL_LEAF_WETNESS) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEAF_WETNESS) class LeafWetness(Sensor): """Leaf Wetness sensor.""" @@ -384,7 +395,7 @@ class LeafWetness(Sensor): _attr_native_unit_of_measurement = PERCENTAGE -@MULTI_MATCH(channel_names=CHANNEL_ILLUMINANCE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ILLUMINANCE) class Illuminance(Sensor): """Illuminance Sensor.""" @@ -400,8 +411,8 @@ class Illuminance(Sensor): @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, - stop_on_match_group=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, + stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) class SmartEnergyMetering(Sensor): """Metering sensor.""" @@ -428,21 +439,21 @@ class SmartEnergyMetering(Sensor): } def formatter(self, value: int) -> int | float: - """Pass through channel formatter.""" - return self._channel.demand_formatter(value) + """Pass through cluster handler formatter.""" + return self._cluster_handler.demand_formatter(value) @property def native_unit_of_measurement(self) -> str | None: """Return Unit of measurement.""" - return self.unit_of_measure_map.get(self._channel.unit_of_measurement) + return self.unit_of_measure_map.get(self._cluster_handler.unit_of_measurement) @property def extra_state_attributes(self) -> dict[str, Any]: """Return device state attrs for battery sensors.""" attrs = {} - if self._channel.device_type is not None: - attrs["device_type"] = self._channel.device_type - if (status := self._channel.status) is not None: + if self._cluster_handler.device_type is not None: + attrs["device_type"] = self._cluster_handler.device_type + if (status := self._cluster_handler.status) is not None: if isinstance(status, enum.IntFlag) and sys.version_info >= (3, 11): attrs["status"] = str( status.name if status.name is not None else status.value @@ -453,8 +464,8 @@ class SmartEnergyMetering(Sensor): @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, - stop_on_match_group=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, + stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered"): """Smart Energy Metering summation sensor.""" @@ -482,17 +493,20 @@ class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered") def formatter(self, value: int) -> int | float: """Numeric pass-through formatter.""" - if self._channel.unit_of_measurement != 0: - return self._channel.summa_formatter(value) + if self._cluster_handler.unit_of_measurement != 0: + return self._cluster_handler.summa_formatter(value) - cooked = float(self._channel.multiplier * value) / self._channel.divisor + cooked = ( + float(self._cluster_handler.multiplier * value) + / self._cluster_handler.divisor + ) return round(cooked, 3) @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"TS011F", "ZLinky_TIC"}, - stop_on_match_group=CHANNEL_SMARTENERGY_METERING, + stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) class PolledSmartEnergySummation(SmartEnergySummation): """Polled Smart Energy Metering summation sensor.""" @@ -503,11 +517,11 @@ class PolledSmartEnergySummation(SmartEnergySummation): """Retrieve latest state.""" if not self.available: return - await self._channel.async_force_update() + await self._cluster_handler.async_force_update() @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier1SmartEnergySummation( @@ -520,7 +534,7 @@ class Tier1SmartEnergySummation( @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier2SmartEnergySummation( @@ -533,7 +547,7 @@ class Tier2SmartEnergySummation( @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier3SmartEnergySummation( @@ -546,7 +560,7 @@ class Tier3SmartEnergySummation( @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier4SmartEnergySummation( @@ -559,7 +573,7 @@ class Tier4SmartEnergySummation( @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier5SmartEnergySummation( @@ -572,7 +586,7 @@ class Tier5SmartEnergySummation( @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier6SmartEnergySummation( @@ -584,7 +598,7 @@ class Tier6SmartEnergySummation( _attr_name: str = "Tier 6 summation delivered" -@MULTI_MATCH(channel_names=CHANNEL_PRESSURE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_PRESSURE) class Pressure(Sensor): """Pressure sensor.""" @@ -596,7 +610,7 @@ class Pressure(Sensor): _attr_native_unit_of_measurement = UnitOfPressure.HPA -@MULTI_MATCH(channel_names=CHANNEL_TEMPERATURE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_TEMPERATURE) class Temperature(Sensor): """Temperature Sensor.""" @@ -608,7 +622,7 @@ class Temperature(Sensor): _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS -@MULTI_MATCH(channel_names=CHANNEL_DEVICE_TEMPERATURE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_DEVICE_TEMPERATURE) class DeviceTemperature(Sensor): """Device Temperature Sensor.""" @@ -621,7 +635,7 @@ class DeviceTemperature(Sensor): _attr_entity_category = EntityCategory.DIAGNOSTIC -@MULTI_MATCH(channel_names="carbon_dioxide_concentration") +@MULTI_MATCH(cluster_handler_names="carbon_dioxide_concentration") class CarbonDioxideConcentration(Sensor): """Carbon Dioxide Concentration sensor.""" @@ -634,7 +648,7 @@ class CarbonDioxideConcentration(Sensor): _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION -@MULTI_MATCH(channel_names="carbon_monoxide_concentration") +@MULTI_MATCH(cluster_handler_names="carbon_monoxide_concentration") class CarbonMonoxideConcentration(Sensor): """Carbon Monoxide Concentration sensor.""" @@ -647,8 +661,8 @@ class CarbonMonoxideConcentration(Sensor): _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION -@MULTI_MATCH(generic_ids="channel_0x042e", stop_on_match_group="voc_level") -@MULTI_MATCH(channel_names="voc_level", stop_on_match_group="voc_level") +@MULTI_MATCH(generic_ids="cluster_handler_0x042e", stop_on_match_group="voc_level") +@MULTI_MATCH(cluster_handler_names="voc_level", stop_on_match_group="voc_level") class VOCLevel(Sensor): """VOC Level sensor.""" @@ -662,7 +676,7 @@ class VOCLevel(Sensor): @MULTI_MATCH( - channel_names="voc_level", + cluster_handler_names="voc_level", models="lumi.airmonitor.acn01", stop_on_match_group="voc_level", ) @@ -678,7 +692,7 @@ class PPBVOCLevel(Sensor): _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_BILLION -@MULTI_MATCH(channel_names="pm25") +@MULTI_MATCH(cluster_handler_names="pm25") class PM25(Sensor): """Particulate Matter 2.5 microns or less sensor.""" @@ -690,7 +704,7 @@ class PM25(Sensor): _attr_native_unit_of_measurement = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER -@MULTI_MATCH(channel_names="formaldehyde_concentration") +@MULTI_MATCH(cluster_handler_names="formaldehyde_concentration") class FormaldehydeConcentration(Sensor): """Formaldehyde Concentration sensor.""" @@ -702,7 +716,10 @@ class FormaldehydeConcentration(Sensor): _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION -@MULTI_MATCH(channel_names=CHANNEL_THERMOSTAT, stop_on_match_group=CHANNEL_THERMOSTAT) +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): """Thermostat HVAC action sensor.""" @@ -713,7 +730,7 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -721,14 +738,14 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): Return entity if it is a supported configuration, otherwise return None """ - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) @property def native_value(self) -> str | None: """Return the current HVAC action.""" if ( - self._channel.pi_heating_demand is None - and self._channel.pi_cooling_demand is None + self._cluster_handler.pi_heating_demand is None + and self._cluster_handler.pi_cooling_demand is None ): return self._rm_rs_action return self._pi_demand_action @@ -737,36 +754,36 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): def _rm_rs_action(self) -> HVACAction | None: """Return the current HVAC action based on running mode and running state.""" - if (running_state := self._channel.running_state) is None: + if (running_state := self._cluster_handler.running_state) is None: return None rs_heat = ( - self._channel.RunningState.Heat_State_On - | self._channel.RunningState.Heat_2nd_Stage_On + self._cluster_handler.RunningState.Heat_State_On + | self._cluster_handler.RunningState.Heat_2nd_Stage_On ) if running_state & rs_heat: return HVACAction.HEATING rs_cool = ( - self._channel.RunningState.Cool_State_On - | self._channel.RunningState.Cool_2nd_Stage_On + self._cluster_handler.RunningState.Cool_State_On + | self._cluster_handler.RunningState.Cool_2nd_Stage_On ) if running_state & rs_cool: return HVACAction.COOLING - running_state = self._channel.running_state + running_state = self._cluster_handler.running_state if running_state and running_state & ( - self._channel.RunningState.Fan_State_On - | self._channel.RunningState.Fan_2nd_Stage_On - | self._channel.RunningState.Fan_3rd_Stage_On + self._cluster_handler.RunningState.Fan_State_On + | self._cluster_handler.RunningState.Fan_2nd_Stage_On + | self._cluster_handler.RunningState.Fan_3rd_Stage_On ): return HVACAction.FAN - running_state = self._channel.running_state - if running_state and running_state & self._channel.RunningState.Idle: + running_state = self._cluster_handler.running_state + if running_state and running_state & self._cluster_handler.RunningState.Idle: return HVACAction.IDLE - if self._channel.system_mode != self._channel.SystemMode.Off: + if self._cluster_handler.system_mode != self._cluster_handler.SystemMode.Off: return HVACAction.IDLE return HVACAction.OFF @@ -774,27 +791,27 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): def _pi_demand_action(self) -> HVACAction: """Return the current HVAC action based on pi_demands.""" - heating_demand = self._channel.pi_heating_demand + heating_demand = self._cluster_handler.pi_heating_demand if heating_demand is not None and heating_demand > 0: return HVACAction.HEATING - cooling_demand = self._channel.pi_cooling_demand + cooling_demand = self._cluster_handler.pi_cooling_demand if cooling_demand is not None and cooling_demand > 0: return HVACAction.COOLING - if self._channel.system_mode != self._channel.SystemMode.Off: + if self._cluster_handler.system_mode != self._cluster_handler.SystemMode.Off: return HVACAction.IDLE return HVACAction.OFF @callback def async_set_state(self, *args, **kwargs) -> None: - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() @MULTI_MATCH( - channel_names={CHANNEL_THERMOSTAT}, + cluster_handler_names={CLUSTER_HANDLER_THERMOSTAT}, manufacturers="Sinope Technologies", - stop_on_match_group=CHANNEL_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) class SinopeHVACAction(ThermostatHVACAction): """Sinope Thermostat HVAC action sensor.""" @@ -803,28 +820,28 @@ class SinopeHVACAction(ThermostatHVACAction): def _rm_rs_action(self) -> HVACAction: """Return the current HVAC action based on running mode and running state.""" - running_mode = self._channel.running_mode - if running_mode == self._channel.RunningMode.Heat: + running_mode = self._cluster_handler.running_mode + if running_mode == self._cluster_handler.RunningMode.Heat: return HVACAction.HEATING - if running_mode == self._channel.RunningMode.Cool: + if running_mode == self._cluster_handler.RunningMode.Cool: return HVACAction.COOLING - running_state = self._channel.running_state + running_state = self._cluster_handler.running_state if running_state and running_state & ( - self._channel.RunningState.Fan_State_On - | self._channel.RunningState.Fan_2nd_Stage_On - | self._channel.RunningState.Fan_3rd_Stage_On + self._cluster_handler.RunningState.Fan_State_On + | self._cluster_handler.RunningState.Fan_2nd_Stage_On + | self._cluster_handler.RunningState.Fan_3rd_Stage_On ): return HVACAction.FAN if ( - self._channel.system_mode != self._channel.SystemMode.Off - and running_mode == self._channel.SystemMode.Off + self._cluster_handler.system_mode != self._cluster_handler.SystemMode.Off + and running_mode == self._cluster_handler.SystemMode.Off ): return HVACAction.IDLE return HVACAction.OFF -@MULTI_MATCH(channel_names=CHANNEL_BASIC) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) class RSSISensor(Sensor, id_suffix="rssi"): """RSSI sensor for a device.""" @@ -842,17 +859,17 @@ class RSSISensor(Sensor, id_suffix="rssi"): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. Return entity if it is a supported configuration, otherwise return None """ - key = f"{CHANNEL_BASIC}_{cls.unique_id_suffix}" + key = f"{CLUSTER_HANDLER_BASIC}_{cls.unique_id_suffix}" if ZHA_ENTITIES.prevent_entity_creation(Platform.SENSOR, zha_device.ieee, key): return None - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) @property def native_value(self) -> StateType: @@ -860,7 +877,7 @@ class RSSISensor(Sensor, id_suffix="rssi"): return getattr(self._zha_device.device, self.unique_id_suffix) -@MULTI_MATCH(channel_names=CHANNEL_BASIC) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) class LQISensor(RSSISensor, id_suffix="lqi"): """LQI sensor for a device.""" @@ -870,7 +887,7 @@ class LQISensor(RSSISensor, id_suffix="lqi"): @MULTI_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_htnnfasr", }, @@ -885,7 +902,7 @@ class TimeLeft(Sensor, id_suffix="time_left"): _attr_native_unit_of_measurement = UnitOfTime.MINUTES -@MULTI_MATCH(channel_names="ikea_airpurifier") +@MULTI_MATCH(cluster_handler_names="ikea_airpurifier") class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): """Sensor that displays device run time (in minutes).""" @@ -896,7 +913,7 @@ class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): _attr_native_unit_of_measurement = UnitOfTime.MINUTES -@MULTI_MATCH(channel_names="ikea_airpurifier") +@MULTI_MATCH(cluster_handler_names="ikea_airpurifier") class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"): """Sensor that displays run time of the current filter (in minutes).""" @@ -914,7 +931,7 @@ class AqaraFeedingSource(types.enum8): HomeAssistant = 0x02 -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"): """Sensor that displays the last feeding source of pet feeder.""" @@ -927,7 +944,7 @@ class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"): return AqaraFeedingSource(value).name -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederLastFeedingSize(Sensor, id_suffix="last_feeding_size"): """Sensor that displays the last feeding size of the pet feeder.""" @@ -936,7 +953,7 @@ class AqaraPetFeederLastFeedingSize(Sensor, id_suffix="last_feeding_size"): _attr_icon: str = "mdi:counter" -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederPortionsDispensed(Sensor, id_suffix="portions_dispensed"): """Sensor that displays the number of portions dispensed by the pet feeder.""" @@ -946,7 +963,7 @@ class AqaraPetFeederPortionsDispensed(Sensor, id_suffix="portions_dispensed"): _attr_icon: str = "mdi:counter" -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): """Sensor that displays the weight dispensed by the pet feeder.""" @@ -955,3 +972,15 @@ class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): _attr_native_unit_of_measurement = UnitOfMass.GRAMS _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING _attr_icon: str = "mdi:weight-gram" + + +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) +class AqaraSmokeDensityDbm(Sensor, id_suffix="smoke_density_dbm"): + """Sensor that displays the smoke density of an Aqara smoke sensor in dB/m.""" + + SENSOR_ATTR = "smoke_density_dbm" + _attr_name: str = "Smoke density" + _attr_native_unit_of_measurement = "dB/m" + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_icon: str = "mdi:google-circles-communities" + _attr_suggested_display_precision: int = 3 diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index dedb339292..a4c699d515 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -22,9 +22,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from .core import discovery -from .core.channels.security import IasWd +from .core.cluster_handlers.security import IasWd from .core.const import ( - CHANNEL_IAS_WD, + CLUSTER_HANDLER_IAS_WD, DATA_ZHA, SIGNAL_ADD_ENTITIES, WARNING_DEVICE_MODE_BURGLAR, @@ -43,7 +43,7 @@ from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SIREN) @@ -70,15 +70,17 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -@MULTI_MATCH(channel_names=CHANNEL_IAS_WD) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) class ZHASiren(ZhaEntity, SirenEntity): """Representation of a ZHA siren.""" + _attr_name: str = "Siren" + def __init__( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs, ) -> None: """Init this siren.""" @@ -97,8 +99,8 @@ class ZHASiren(ZhaEntity, SirenEntity): WARNING_DEVICE_MODE_FIRE_PANIC: "Fire Panic", WARNING_DEVICE_MODE_EMERGENCY_PANIC: "Emergency Panic", } - super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: IasWd = cast(IasWd, channels[0]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cluster_handler: IasWd = cast(IasWd, cluster_handlers[0]) self._attr_is_on: bool = False self._off_listener: Callable[[], None] | None = None @@ -107,22 +109,28 @@ class ZHASiren(ZhaEntity, SirenEntity): if self._off_listener: self._off_listener() self._off_listener = None - tone_cache = self._channel.data_cache.get(WD.Warning.WarningMode.__name__) + tone_cache = self._cluster_handler.data_cache.get( + WD.Warning.WarningMode.__name__ + ) siren_tone = ( tone_cache.value if tone_cache is not None else WARNING_DEVICE_MODE_EMERGENCY ) siren_duration = DEFAULT_DURATION - level_cache = self._channel.data_cache.get(WD.Warning.SirenLevel.__name__) + level_cache = self._cluster_handler.data_cache.get( + WD.Warning.SirenLevel.__name__ + ) siren_level = ( level_cache.value if level_cache is not None else WARNING_DEVICE_SOUND_HIGH ) - strobe_cache = self._channel.data_cache.get(Strobe.__name__) + strobe_cache = self._cluster_handler.data_cache.get(Strobe.__name__) should_strobe = ( strobe_cache.value if strobe_cache is not None else Strobe.No_Strobe ) - strobe_level_cache = self._channel.data_cache.get(WD.StrobeLevel.__name__) + strobe_level_cache = self._cluster_handler.data_cache.get( + WD.StrobeLevel.__name__ + ) strobe_level = ( strobe_level_cache.value if strobe_level_cache is not None @@ -134,7 +142,7 @@ class ZHASiren(ZhaEntity, SirenEntity): siren_tone = tone if (level := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: siren_level = int(level) - await self._channel.issue_start_warning( + await self._cluster_handler.issue_start_warning( mode=siren_tone, warning_duration=siren_duration, siren_level=siren_level, @@ -150,7 +158,7 @@ class ZHASiren(ZhaEntity, SirenEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off siren.""" - await self._channel.issue_start_warning( + await self._cluster_handler.issue_start_warning( mode=WARNING_DEVICE_MODE_STOP, strobe=WARNING_DEVICE_STROBE_NO ) self._attr_is_on = False diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 09cebc8f4c..99db68760a 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -19,9 +19,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( - CHANNEL_BASIC, - CHANNEL_INOVELLI, - CHANNEL_ON_OFF, + CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_INOVELLI, + CLUSTER_HANDLER_ON_OFF, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -30,7 +30,7 @@ from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity, ZhaGroupEntity if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SWITCH) @@ -60,59 +60,63 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -@STRICT_MATCH(channel_names=CHANNEL_ON_OFF) +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) class Switch(ZhaEntity, SwitchEntity): """ZHA switch.""" + _attr_name: str = "Switch" + def __init__( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Initialize the ZHA switch.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._on_off_channel = self.cluster_channels[CHANNEL_ON_OFF] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._on_off_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_ON_OFF] @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" - if self._on_off_channel.on_off is None: + if self._on_off_cluster_handler.on_off is None: return False - return self._on_off_channel.on_off + return self._on_off_cluster_handler.on_off async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - result = await self._on_off_channel.turn_on() + result = await self._on_off_cluster_handler.turn_on() if not result: return self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - result = await self._on_off_channel.turn_off() + result = await self._on_off_cluster_handler.turn_off() if not result: return self.async_write_ha_state() @callback def async_set_state(self, attr_id: int, attr_name: str, value: Any): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._on_off_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) async def async_update(self) -> None: """Attempt to retrieve on off state from the switch.""" await super().async_update() - if self._on_off_channel: - await self._on_off_channel.get_attribute_value("on_off", from_cache=False) + if self._on_off_cluster_handler: + await self._on_off_cluster_handler.get_attribute_value( + "on_off", from_cache=False + ) @GROUP_MATCH() @@ -132,7 +136,7 @@ class SwitchGroup(ZhaGroupEntity, SwitchEntity): self._available: bool self._state: bool group = self.zha_device.gateway.get_group(self._group_id) - self._on_off_channel = group.endpoint[OnOff.cluster_id] + self._on_off_cluster_handler = group.endpoint[OnOff.cluster_id] @property def is_on(self) -> bool: @@ -141,7 +145,7 @@ class SwitchGroup(ZhaGroupEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - result = await self._on_off_channel.on() + result = await self._on_off_cluster_handler.on() if isinstance(result, Exception) or result[1] is not Status.SUCCESS: return self._state = True @@ -149,7 +153,7 @@ class SwitchGroup(ZhaGroupEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - result = await self._on_off_channel.off() + result = await self._on_off_cluster_handler.off() if isinstance(result, Exception) or result[1] is not Status.SUCCESS: return self._state = False @@ -178,17 +182,17 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. Return entity if it is a supported configuration, otherwise return None """ - channel = channels[0] + cluster_handler = cluster_handlers[0] if ( - cls._zcl_attribute in channel.cluster.unsupported_attributes - or channel.cluster.get(cls._zcl_attribute) is None + cls._zcl_attribute in cluster_handler.cluster.unsupported_attributes + or cluster_handler.cluster.get(cls._zcl_attribute) is None ): _LOGGER.debug( "%s is not supported - skipping %s entity creation", @@ -197,48 +201,48 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): ) return None - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) def __init__( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this number configuration entity.""" - self._channel: ZigbeeChannel = channels[0] - super().__init__(unique_id, zha_device, channels, **kwargs) + self._cluster_handler: ClusterHandler = cluster_handlers[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) @callback def async_set_state(self, attr_id: int, attr_name: str, value: Any): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() @property def inverted(self) -> bool: """Return True if the switch is inverted.""" if self._zcl_inverter_attribute: - return bool(self._channel.cluster.get(self._zcl_inverter_attribute)) + return bool(self._cluster_handler.cluster.get(self._zcl_inverter_attribute)) return self._force_inverted @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" - val = bool(self._channel.cluster.get(self._zcl_attribute)) + val = bool(self._cluster_handler.cluster.get(self._zcl_attribute)) return (not val) if self.inverted else val async def async_turn_on_off(self, state: bool) -> None: """Turn the entity on or off.""" try: - result = await self._channel.cluster.write_attributes( + result = await self._cluster_handler.cluster.write_attributes( {self._zcl_attribute: not state if self.inverted else state} ) except zigpy.exceptions.ZigbeeException as ex: @@ -261,18 +265,18 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): """Attempt to retrieve the state of the entity.""" await super().async_update() self.error("Polling current state") - if self._channel: - value = await self._channel.get_attribute_value( + if self._cluster_handler: + value = await self._cluster_handler.get_attribute_value( self._zcl_attribute, from_cache=False ) - await self._channel.get_attribute_value( + await self._cluster_handler.get_attribute_value( self._zcl_inverter_attribute, from_cache=False ) self.debug("read value=%s, inverted=%s", value, self.inverted) @CONFIG_DIAGNOSTIC_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_b6wax7g0", }, @@ -284,9 +288,12 @@ class OnOffWindowDetectionFunctionConfigurationEntity( _zcl_attribute: str = "window_detection_function" _zcl_inverter_attribute: str = "window_detection_function_inverter" + _attr_name: str = "Invert window detection" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac02"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.motion.ac02"} +) class P1MotionTriggerIndicatorSwitch( ZHASwitchConfigurationEntity, id_suffix="trigger_indicator" ): @@ -297,7 +304,8 @@ class P1MotionTriggerIndicatorSwitch( @CONFIG_DIAGNOSTIC_MATCH( - channel_names="opple_cluster", models={"lumi.plug.mmeu01", "lumi.plug.maeu01"} + cluster_handler_names="opple_cluster", + models={"lumi.plug.mmeu01", "lumi.plug.maeu01"}, ) class XiaomiPlugPowerOutageMemorySwitch( ZHASwitchConfigurationEntity, id_suffix="power_outage_memory" @@ -309,7 +317,7 @@ class XiaomiPlugPowerOutageMemorySwitch( @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_BASIC, + cluster_handler_names=CLUSTER_HANDLER_BASIC, manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML001", "SML002", "SML003", "SML004"}, ) @@ -323,7 +331,7 @@ class HueMotionTriggerIndicatorSwitch( @CONFIG_DIAGNOSTIC_MATCH( - channel_names="ikea_airpurifier", + cluster_handler_names="ikea_airpurifier", models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, ) class ChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): @@ -334,7 +342,7 @@ class ChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): @CONFIG_DIAGNOSTIC_MATCH( - channel_names="ikea_airpurifier", + cluster_handler_names="ikea_airpurifier", models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, ) class DisableLed(ZHASwitchConfigurationEntity, id_suffix="disable_led"): @@ -345,7 +353,7 @@ class DisableLed(ZHASwitchConfigurationEntity, id_suffix="disable_led"): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliInvertSwitch(ZHASwitchConfigurationEntity, id_suffix="invert_switch"): """Inovelli invert switch control.""" @@ -355,7 +363,7 @@ class InovelliInvertSwitch(ZHASwitchConfigurationEntity, id_suffix="invert_switc @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliSmartBulbMode(ZHASwitchConfigurationEntity, id_suffix="smart_bulb_mode"): """Inovelli smart bulb mode control.""" @@ -365,31 +373,55 @@ class InovelliSmartBulbMode(ZHASwitchConfigurationEntity, id_suffix="smart_bulb_ @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliDoubleTapForFullBrightness( - ZHASwitchConfigurationEntity, id_suffix="double_tap_up_for_max_brightness" +class InovelliDoubleTapUpEnabled( + ZHASwitchConfigurationEntity, id_suffix="double_tap_up_enabled" ): - """Inovelli double tap for full brightness control.""" + """Inovelli double tap up enabled.""" - _zcl_attribute: str = "double_tap_up_for_max_brightness" - _attr_name: str = "Double tap full brightness" + _zcl_attribute: str = "double_tap_up_enabled" + _attr_name: str = "Double tap up enabled" @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliDoubleTapForMinBrightness( - ZHASwitchConfigurationEntity, id_suffix="double_tap_down_for_min_brightness" +class InovelliDoubleTapDownEnabled( + ZHASwitchConfigurationEntity, id_suffix="double_tap_down_enabled" ): - """Inovelli double tap down for minimum brightness control.""" + """Inovelli double tap down enabled.""" - _zcl_attribute: str = "double_tap_down_for_min_brightness" - _attr_name: str = "Double tap minimum brightness" + _zcl_attribute: str = "double_tap_down_enabled" + _attr_name: str = "Double tap down enabled" @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliAuxSwitchScenes( + ZHASwitchConfigurationEntity, id_suffix="aux_switch_scenes" +): + """Inovelli unique aux switch scenes.""" + + _zcl_attribute: str = "aux_switch_scenes" + _attr_name: str = "Aux switch scenes" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliBindingOffToOnSyncLevel( + ZHASwitchConfigurationEntity, id_suffix="binding_off_to_on_sync_level" +): + """Inovelli send move to level with on/off to bound devices.""" + + _zcl_attribute: str = "binding_off_to_on_sync_level" + _attr_name: str = "Binding off to on sync level" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliLocalProtection( ZHASwitchConfigurationEntity, id_suffix="local_protection" @@ -401,7 +433,7 @@ class InovelliLocalProtection( @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliOnOffLEDMode(ZHASwitchConfigurationEntity, id_suffix="on_off_led_mode"): """Inovelli only 1 LED mode control.""" @@ -411,7 +443,7 @@ class InovelliOnOffLEDMode(ZHASwitchConfigurationEntity, id_suffix="on_off_led_m @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliFirmwareProgressLED( ZHASwitchConfigurationEntity, id_suffix="firmware_progress_led" @@ -423,7 +455,7 @@ class InovelliFirmwareProgressLED( @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliRelayClickInOnOffMode( ZHASwitchConfigurationEntity, id_suffix="relay_click_in_on_off_mode" @@ -435,7 +467,7 @@ class InovelliRelayClickInOnOffMode( @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliDisableDoubleTapClearNotificationsMode( ZHASwitchConfigurationEntity, id_suffix="disable_clear_notifications_double_tap" @@ -446,7 +478,9 @@ class InovelliDisableDoubleTapClearNotificationsMode( _attr_name: str = "Disable config 2x tap to clear notifications" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} +) class AqaraPetFeederLEDIndicator( ZHASwitchConfigurationEntity, id_suffix="disable_led_indicator" ): @@ -458,7 +492,9 @@ class AqaraPetFeederLEDIndicator( _attr_icon: str = "mdi:led-on" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} +) class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): """Representation of a child lock configuration entity.""" @@ -468,7 +504,7 @@ class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity, id_suffix="child_loc @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_ON_OFF, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, models={"TS011F"}, ) class TuyaChildLockSwitch(ZHASwitchConfigurationEntity, id_suffix="child_lock"): @@ -477,3 +513,88 @@ class TuyaChildLockSwitch(ZHASwitchConfigurationEntity, id_suffix="child_lock"): _zcl_attribute: str = "child_lock" _attr_name = "Child lock" _attr_icon: str = "mdi:account-lock" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) +class AqaraThermostatWindowDetection( + ZHASwitchConfigurationEntity, id_suffix="window_detection" +): + """Representation of an Aqara thermostat window detection configuration entity.""" + + _zcl_attribute: str = "window_detection" + _attr_name = "Window detection" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) +class AqaraThermostatValveDetection( + ZHASwitchConfigurationEntity, id_suffix="valve_detection" +): + """Representation of an Aqara thermostat valve detection configuration entity.""" + + _zcl_attribute: str = "valve_detection" + _attr_name = "Valve detection" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) +class AqaraThermostatChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): + """Representation of an Aqara thermostat child lock configuration entity.""" + + _zcl_attribute: str = "child_lock" + _attr_name = "Child lock" + _attr_icon: str = "mdi:account-lock" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraHeartbeatIndicator( + ZHASwitchConfigurationEntity, id_suffix="heartbeat_indicator" +): + """Representation of a heartbeat indicator configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "heartbeat_indicator" + _attr_name = "Heartbeat indicator" + _attr_icon: str = "mdi:heart-flash" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraLinkageAlarm(ZHASwitchConfigurationEntity, id_suffix="linkage_alarm"): + """Representation of a linkage alarm configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "linkage_alarm" + _attr_name = "Linkage alarm" + _attr_icon: str = "mdi:shield-link-variant" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraBuzzerManualMute( + ZHASwitchConfigurationEntity, id_suffix="buzzer_manual_mute" +): + """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "buzzer_manual_mute" + _attr_name = "Buzzer manual mute" + _attr_icon: str = "mdi:volume-off" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraBuzzerManualAlarm( + ZHASwitchConfigurationEntity, id_suffix="buzzer_manual_alarm" +): + """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "buzzer_manual_alarm" + _attr_name = "Buzzer manual alarm" + _attr_icon: str = "mdi:bullhorn" diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py new file mode 100644 index 0000000000..322107a074 --- /dev/null +++ b/homeassistant/components/zha/websocket_api.py @@ -0,0 +1,1541 @@ +"""Web socket API for Zigbee Home Automation devices.""" +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast + +import voluptuous as vol +import zigpy.backups +from zigpy.config.validators import cv_boolean +from zigpy.types.named import EUI64 +from zigpy.zcl.clusters.security import IasAce +import zigpy.zdo.types as zdo_types + +from homeassistant.components import websocket_api +from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME +from homeassistant.core import HomeAssistant, ServiceCall, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service import async_register_admin_service + +from .api import async_get_active_network_settings, async_get_radio_type +from .core.const import ( + ATTR_ARGS, + ATTR_ATTRIBUTE, + ATTR_CLUSTER_ID, + ATTR_CLUSTER_TYPE, + ATTR_COMMAND_TYPE, + ATTR_ENDPOINT_ID, + ATTR_IEEE, + ATTR_LEVEL, + ATTR_MANUFACTURER, + ATTR_MEMBERS, + ATTR_PARAMS, + ATTR_TYPE, + ATTR_VALUE, + ATTR_WARNING_DEVICE_DURATION, + ATTR_WARNING_DEVICE_MODE, + ATTR_WARNING_DEVICE_STROBE, + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, + ATTR_WARNING_DEVICE_STROBE_INTENSITY, + BINDINGS, + CLUSTER_COMMAND_SERVER, + CLUSTER_COMMANDS_CLIENT, + CLUSTER_COMMANDS_SERVER, + CLUSTER_HANDLER_IAS_WD, + CLUSTER_TYPE_IN, + CLUSTER_TYPE_OUT, + CUSTOM_CONFIGURATION, + DATA_ZHA, + DATA_ZHA_GATEWAY, + DOMAIN, + EZSP_OVERWRITE_EUI64, + GROUP_ID, + GROUP_IDS, + GROUP_NAME, + MFG_CLUSTER_ID_START, + WARNING_DEVICE_MODE_EMERGENCY, + WARNING_DEVICE_SOUND_HIGH, + WARNING_DEVICE_SQUAWK_MODE_ARMED, + WARNING_DEVICE_STROBE_HIGH, + WARNING_DEVICE_STROBE_YES, + ZHA_ALARM_OPTIONS, + ZHA_CLUSTER_HANDLER_MSG, + ZHA_CONFIG_SCHEMAS, +) +from .core.gateway import EntityReference +from .core.group import GroupMember +from .core.helpers import ( + async_cluster_exists, + async_is_bindable_target, + cluster_command_schema_to_vol_schema, + convert_install_code, + get_matched_clusters, + qr_to_install_code, +) + +if TYPE_CHECKING: + from homeassistant.components.websocket_api.connection import ActiveConnection + + from .core.device import ZHADevice + from .core.gateway import ZHAGateway + +_LOGGER = logging.getLogger(__name__) + +TYPE = "type" +CLIENT = "client" +ID = "id" +RESPONSE = "response" +DEVICE_INFO = "device_info" + +ATTR_DURATION = "duration" +ATTR_GROUP = "group" +ATTR_IEEE_ADDRESS = "ieee_address" +ATTR_INSTALL_CODE = "install_code" +ATTR_SOURCE_IEEE = "source_ieee" +ATTR_TARGET_IEEE = "target_ieee" +ATTR_QR_CODE = "qr_code" + +SERVICE_PERMIT = "permit" +SERVICE_REMOVE = "remove" +SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = "set_zigbee_cluster_attribute" +SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = "issue_zigbee_cluster_command" +SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND = "issue_zigbee_group_command" +SERVICE_DIRECT_ZIGBEE_BIND = "issue_direct_zigbee_bind" +SERVICE_DIRECT_ZIGBEE_UNBIND = "issue_direct_zigbee_unbind" +SERVICE_WARNING_DEVICE_SQUAWK = "warning_device_squawk" +SERVICE_WARNING_DEVICE_WARN = "warning_device_warn" +SERVICE_ZIGBEE_BIND = "service_zigbee_bind" +IEEE_SERVICE = "ieee_based_service" + +IEEE_SCHEMA = vol.All(cv.string, EUI64.convert) + +# typing typevar +_T = TypeVar("_T") + + +def _ensure_list_if_present(value: _T | None) -> list[_T] | list[Any] | None: + """Wrap value in list if it is provided and not one.""" + if value is None: + return None + return cast("list[_T]", value) if isinstance(value, list) else [value] + + +SERVICE_PERMIT_PARAMS = { + vol.Optional(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional(ATTR_DURATION, default=60): vol.All( + vol.Coerce(int), vol.Range(0, 254) + ), + vol.Inclusive(ATTR_SOURCE_IEEE, "install_code"): IEEE_SCHEMA, + vol.Inclusive(ATTR_INSTALL_CODE, "install_code"): vol.All( + cv.string, convert_install_code + ), + vol.Exclusive(ATTR_QR_CODE, "install_code"): vol.All(cv.string, qr_to_install_code), +} + +SERVICE_SCHEMAS = { + SERVICE_PERMIT: vol.Schema( + vol.All( + cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), + SERVICE_PERMIT_PARAMS, + ) + ), + IEEE_SERVICE: vol.Schema( + vol.All( + cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), + {vol.Required(ATTR_IEEE): IEEE_SCHEMA}, + ) + ), + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_ATTRIBUTE): vol.Any(cv.positive_int, str), + vol.Required(ATTR_VALUE): vol.Any(int, cv.boolean, cv.string), + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } + ), + SERVICE_WARNING_DEVICE_SQUAWK: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional( + ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_SQUAWK_MODE_ARMED + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ): cv.positive_int, + vol.Optional( + ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ): cv.positive_int, + } + ), + SERVICE_WARNING_DEVICE_WARN: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional( + ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_MODE_EMERGENCY + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ): cv.positive_int, + vol.Optional( + ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ): cv.positive_int, + vol.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE_INTENSITY, default=WARNING_DEVICE_STROBE_HIGH + ): cv.positive_int, + } + ), + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.All( + vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_COMMAND): cv.positive_int, + vol.Required(ATTR_COMMAND_TYPE): cv.string, + vol.Exclusive(ATTR_ARGS, "attrs_params"): _ensure_list_if_present, + vol.Exclusive(ATTR_PARAMS, "attrs_params"): dict, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } + ), + cv.deprecated(ATTR_ARGS), + cv.has_at_least_one_key(ATTR_ARGS, ATTR_PARAMS), + ), + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND: vol.Schema( + { + vol.Required(ATTR_GROUP): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_COMMAND): cv.positive_int, + vol.Optional(ATTR_ARGS, default=[]): cv.ensure_list, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } + ), +} + + +class ClusterBinding(NamedTuple): + """Describes a cluster binding.""" + + name: str + type: str + id: int + endpoint_id: int + + +def _cv_group_member(value: dict[str, Any]) -> GroupMember: + """Transform a group member.""" + return GroupMember( + ieee=value[ATTR_IEEE], + endpoint_id=value[ATTR_ENDPOINT_ID], + ) + + +def _cv_cluster_binding(value: dict[str, Any]) -> ClusterBinding: + """Transform a cluster binding.""" + return ClusterBinding( + name=value[ATTR_NAME], + type=value[ATTR_TYPE], + id=value[ATTR_ID], + endpoint_id=value[ATTR_ENDPOINT_ID], + ) + + +def _cv_zigpy_network_backup(value: dict[str, Any]) -> zigpy.backups.NetworkBackup: + """Transform a zigpy network backup.""" + + try: + return zigpy.backups.NetworkBackup.from_dict(value) + except ValueError as err: + raise vol.Invalid(str(err)) from err + + +GROUP_MEMBER_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), + } + ), + _cv_group_member, +) + + +CLUSTER_BINDING_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_TYPE): cv.string, + vol.Required(ATTR_ID): vol.Coerce(int), + vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), + } + ), + _cv_cluster_binding, +) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "zha/devices/permit", + **SERVICE_PERMIT_PARAMS, + } +) +@websocket_api.async_response +async def websocket_permit_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Permit ZHA zigbee devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + duration: int = msg[ATTR_DURATION] + ieee: EUI64 | None = msg.get(ATTR_IEEE) + + async def forward_messages(data): + """Forward events to websocket.""" + connection.send_message(websocket_api.event_message(msg["id"], data)) + + remove_dispatcher_function = async_dispatcher_connect( + hass, "zha_gateway_message", forward_messages + ) + + @callback + def async_cleanup() -> None: + """Remove signal listener and turn off debug mode.""" + zha_gateway.async_disable_debug_mode() + remove_dispatcher_function() + + connection.subscriptions[msg["id"]] = async_cleanup + zha_gateway.async_enable_debug_mode() + src_ieee: EUI64 + code: bytes + if ATTR_SOURCE_IEEE in msg: + src_ieee = msg[ATTR_SOURCE_IEEE] + code = msg[ATTR_INSTALL_CODE] + _LOGGER.debug("Allowing join for %s device with install code", src_ieee) + await zha_gateway.application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + elif ATTR_QR_CODE in msg: + src_ieee, code = msg[ATTR_QR_CODE] + _LOGGER.debug("Allowing join for %s device with install code", src_ieee) + await zha_gateway.application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + else: + await zha_gateway.application_controller.permit(time_s=duration, node=ieee) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices"}) +@websocket_api.async_response +async def websocket_get_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + devices = [device.zha_device_info for device in zha_gateway.devices.values()] + connection.send_result(msg[ID], devices) + + +@callback +def _get_entity_name( + zha_gateway: ZHAGateway, entity_ref: EntityReference +) -> str | None: + entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + return entry.name if entry else None + + +@callback +def _get_entity_original_name( + zha_gateway: ZHAGateway, entity_ref: EntityReference +) -> str | None: + entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + return entry.original_name if entry else None + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices/groupable"}) +@websocket_api.async_response +async def websocket_get_groupable_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices that can be grouped.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + devices = [device for device in zha_gateway.devices.values() if device.is_groupable] + groupable_devices = [] + + for device in devices: + entity_refs = zha_gateway.device_registry[device.ieee] + for ep_id in device.async_get_groupable_endpoints(): + groupable_devices.append( + { + "endpoint_id": ep_id, + "entities": [ + { + "name": _get_entity_name(zha_gateway, entity_ref), + "original_name": _get_entity_original_name( + zha_gateway, entity_ref + ), + } + for entity_ref in entity_refs + if list(entity_ref.cluster_handlers.values())[ + 0 + ].cluster.endpoint.endpoint_id + == ep_id + ], + "device": device.zha_device_info, + } + ) + + connection.send_result(msg[ID], groupable_devices) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/groups"}) +@websocket_api.async_response +async def websocket_get_groups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA groups.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + groups = [group.group_info for group in zha_gateway.groups.values()] + connection.send_result(msg[ID], groups) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/device", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_get_device( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + + if not (zha_device := zha_gateway.devices.get(ieee)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Device not found" + ) + ) + return + + device_info = zha_device.zha_device_info + connection.send_result(msg[ID], device_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group", + vol.Required(GROUP_ID): cv.positive_int, + } +) +@websocket_api.async_response +async def websocket_get_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_id: int = msg[GROUP_ID] + + if not (zha_group := zha_gateway.groups.get(group_id)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + ) + ) + return + + group_info = zha_group.group_info + connection.send_result(msg[ID], group_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/add", + vol.Required(GROUP_NAME): cv.string, + vol.Optional(GROUP_ID): cv.positive_int, + vol.Optional(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_add_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Add a new ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_name: str = msg[GROUP_NAME] + group_id: int | None = msg.get(GROUP_ID) + members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) + group = await zha_gateway.async_create_zigpy_group(group_name, members, group_id) + assert group + connection.send_result(msg[ID], group.group_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/remove", + vol.Required(GROUP_IDS): vol.All(cv.ensure_list, [cv.positive_int]), + } +) +@websocket_api.async_response +async def websocket_remove_groups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Remove the specified ZHA groups.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_ids: list[int] = msg[GROUP_IDS] + + if len(group_ids) > 1: + tasks = [] + for group_id in group_ids: + tasks.append(zha_gateway.async_remove_zigpy_group(group_id)) + await asyncio.gather(*tasks) + else: + await zha_gateway.async_remove_zigpy_group(group_ids[0]) + ret_groups = [group.group_info for group in zha_gateway.groups.values()] + connection.send_result(msg[ID], ret_groups) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/members/add", + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_add_group_members( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Add members to a ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_id: int = msg[GROUP_ID] + members: list[GroupMember] = msg[ATTR_MEMBERS] + + if not (zha_group := zha_gateway.groups.get(group_id)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + ) + ) + return + + await zha_group.async_add_members(members) + ret_group = zha_group.group_info + connection.send_result(msg[ID], ret_group) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/members/remove", + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_remove_group_members( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Remove members from a ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_id: int = msg[GROUP_ID] + members: list[GroupMember] = msg[ATTR_MEMBERS] + + if not (zha_group := zha_gateway.groups.get(group_id)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + ) + ) + return + + await zha_group.async_remove_members(members) + ret_group = zha_group.group_info + connection.send_result(msg[ID], ret_group) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/reconfigure", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_reconfigure_node( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Reconfigure a ZHA nodes entities by its ieee address.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + device: ZHADevice | None = zha_gateway.get_device(ieee) + + async def forward_messages(data): + """Forward events to websocket.""" + connection.send_message(websocket_api.event_message(msg["id"], data)) + + remove_dispatcher_function = async_dispatcher_connect( + hass, ZHA_CLUSTER_HANDLER_MSG, forward_messages + ) + + @callback + def async_cleanup() -> None: + """Remove signal listener.""" + remove_dispatcher_function() + + connection.subscriptions[msg["id"]] = async_cleanup + + _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) + assert device + hass.async_create_task(device.async_configure()) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/topology/update", + } +) +@websocket_api.async_response +async def websocket_update_topology( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Update the ZHA network topology.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + hass.async_create_task(zha_gateway.application_controller.topology.scan()) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_device_clusters( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a list of device clusters.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + zha_device = zha_gateway.get_device(ieee) + response_clusters = [] + if zha_device is not None: + clusters_by_endpoint = zha_device.async_get_clusters() + for ep_id, clusters in clusters_by_endpoint.items(): + for c_id, cluster in clusters[CLUSTER_TYPE_IN].items(): + response_clusters.append( + { + TYPE: CLUSTER_TYPE_IN, + ID: c_id, + ATTR_NAME: cluster.__class__.__name__, + "endpoint_id": ep_id, + } + ) + for c_id, cluster in clusters[CLUSTER_TYPE_OUT].items(): + response_clusters.append( + { + TYPE: CLUSTER_TYPE_OUT, + ID: c_id, + ATTR_NAME: cluster.__class__.__name__, + "endpoint_id": ep_id, + } + ) + + connection.send_result(msg[ID], response_clusters) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters/attributes", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + } +) +@websocket_api.async_response +async def websocket_device_cluster_attributes( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a list of cluster attributes.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + cluster_attributes: list[dict[str, Any]] = [] + zha_device = zha_gateway.get_device(ieee) + attributes = None + if zha_device is not None: + attributes = zha_device.async_get_cluster_attributes( + endpoint_id, cluster_id, cluster_type + ) + if attributes is not None: + for attr_id, attr in attributes.items(): + cluster_attributes.append({ID: attr_id, ATTR_NAME: attr.name}) + _LOGGER.debug( + "Requested attributes for: %s: %s, %s: '%s', %s: %s, %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + RESPONSE, + cluster_attributes, + ) + + connection.send_result(msg[ID], cluster_attributes) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters/commands", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + } +) +@websocket_api.async_response +async def websocket_device_cluster_commands( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a list of cluster commands.""" + import voluptuous_serialize # pylint: disable=import-outside-toplevel + + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + zha_device = zha_gateway.get_device(ieee) + cluster_commands: list[dict[str, Any]] = [] + commands = None + if zha_device is not None: + commands = zha_device.async_get_cluster_commands( + endpoint_id, cluster_id, cluster_type + ) + + if commands is not None: + for cmd_id, cmd in commands[CLUSTER_COMMANDS_CLIENT].items(): + cluster_commands.append( + { + TYPE: CLIENT, + ID: cmd_id, + ATTR_NAME: cmd.name, + "schema": voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(cmd.schema), + custom_serializer=cv.custom_serializer, + ), + } + ) + for cmd_id, cmd in commands[CLUSTER_COMMANDS_SERVER].items(): + cluster_commands.append( + { + TYPE: CLUSTER_COMMAND_SERVER, + ID: cmd_id, + ATTR_NAME: cmd.name, + "schema": voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(cmd.schema), + custom_serializer=cv.custom_serializer, + ), + } + ) + _LOGGER.debug( + "Requested commands for: %s: %s, %s: '%s', %s: %s, %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + RESPONSE, + cluster_commands, + ) + + connection.send_result(msg[ID], cluster_commands) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters/attributes/value", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + vol.Required(ATTR_ATTRIBUTE): int, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } +) +@websocket_api.async_response +async def websocket_read_zigbee_cluster_attributes( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Read zigbee attribute for cluster on ZHA entity.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + attribute: int = msg[ATTR_ATTRIBUTE] + manufacturer: int | None = msg.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + success = {} + failure = {} + if zha_device is not None: + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code + cluster = zha_device.async_get_cluster( + endpoint_id, cluster_id, cluster_type=cluster_type + ) + success, failure = await cluster.read_attributes( + [attribute], allow_cache=False, only_cache=False, manufacturer=manufacturer + ) + _LOGGER.debug( + ( + "Read attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" + " %s: [%s]," + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_ATTRIBUTE, + attribute, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + str(success.get(attribute)), + "failure", + failure, + ) + connection.send_result(msg[ID], str(success.get(attribute))) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/bindable", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_get_bindable_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Directly bind devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_IEEE] + source_device = zha_gateway.get_device(source_ieee) + + devices = [ + device.zha_device_info + for device in zha_gateway.devices.values() + if async_is_bindable_target(source_device, device) + ] + + _LOGGER.debug( + "Get bindable devices: %s: [%s], %s: [%s]", + ATTR_SOURCE_IEEE, + source_ieee, + "bindable devices", + devices, + ) + + connection.send_message(websocket_api.result_message(msg[ID], devices)) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/bind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_bind_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Directly bind devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Bind_req + ) + _LOGGER.info( + "Devices bound: %s: [%s] %s: [%s]", + ATTR_SOURCE_IEEE, + source_ieee, + ATTR_TARGET_IEEE, + target_ieee, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/unbind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_unbind_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Remove a direct binding between devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Unbind_req + ) + _LOGGER.info( + "Devices un-bound: %s: [%s] %s: [%s]", + ATTR_SOURCE_IEEE, + source_ieee, + ATTR_TARGET_IEEE, + target_ieee, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/groups/bind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_bind_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Directly bind a device to a group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + group_id: int = msg[GROUP_ID] + bindings: list[ClusterBinding] = msg[BINDINGS] + source_device = zha_gateway.get_device(source_ieee) + assert source_device + await source_device.async_bind_to_group(group_id, bindings) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/groups/unbind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_unbind_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Unbind a device from a group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + group_id: int = msg[GROUP_ID] + bindings: list[ClusterBinding] = msg[BINDINGS] + source_device = zha_gateway.get_device(source_ieee) + assert source_device + await source_device.async_unbind_from_group(group_id, bindings) + + +async def async_binding_operation( + zha_gateway: ZHAGateway, + source_ieee: EUI64, + target_ieee: EUI64, + operation: zdo_types.ZDOCmd, +) -> None: + """Create or remove a direct zigbee binding between 2 devices.""" + + source_device = zha_gateway.get_device(source_ieee) + target_device = zha_gateway.get_device(target_ieee) + + assert source_device + assert target_device + clusters_to_bind = await get_matched_clusters(source_device, target_device) + + zdo = source_device.device.zdo + bind_tasks = [] + for binding_pair in clusters_to_bind: + op_msg = "cluster: %s %s --> [%s]" + op_params = ( + binding_pair.source_cluster.cluster_id, + operation.name, + target_ieee, + ) + zdo.debug(f"processing {op_msg}", *op_params) + + bind_tasks.append( + ( + zdo.request( + operation, + source_device.ieee, + binding_pair.source_cluster.endpoint.endpoint_id, + binding_pair.source_cluster.cluster_id, + binding_pair.destination_address, + ), + op_msg, + op_params, + ) + ) + res = await asyncio.gather(*(t[0] for t in bind_tasks), return_exceptions=True) + for outcome, log_msg in zip(res, bind_tasks): + if isinstance(outcome, Exception): + fmt = f"{log_msg[1]} failed: %s" + else: + fmt = f"{log_msg[1]} completed: %s" + zdo.debug(fmt, *(log_msg[2] + (outcome,))) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/configuration"}) +@websocket_api.async_response +async def websocket_get_configuration( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA configuration.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + import voluptuous_serialize # pylint: disable=import-outside-toplevel + + def custom_serializer(schema: Any) -> Any: + """Serialize additional types for voluptuous_serialize.""" + if schema is cv_boolean: + return {"type": "bool"} + if schema is vol.Schema: + return voluptuous_serialize.convert( + schema, custom_serializer=custom_serializer + ) + + return cv.custom_serializer(schema) + + data: dict[str, dict[str, Any]] = {"schemas": {}, "data": {}} + for section, schema in ZHA_CONFIG_SCHEMAS.items(): + if section == ZHA_ALARM_OPTIONS and not async_cluster_exists( + hass, IasAce.cluster_id + ): + continue + data["schemas"][section] = voluptuous_serialize.convert( + schema, custom_serializer=custom_serializer + ) + data["data"][section] = zha_gateway.config_entry.options.get( + CUSTOM_CONFIGURATION, {} + ).get(section, {}) + + # send default values for unconfigured options + for entry in data["schemas"][section]: + if data["data"][section].get(entry["name"]) is None: + data["data"][section][entry["name"]] = entry["default"] + + connection.send_result(msg[ID], data) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/configuration/update", + vol.Required("data"): ZHA_CONFIG_SCHEMAS, + } +) +@websocket_api.async_response +async def websocket_update_zha_configuration( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Update the ZHA configuration.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + options = zha_gateway.config_entry.options + data_to_save = {**options, **{CUSTOM_CONFIGURATION: msg["data"]}} + + for section, schema in ZHA_CONFIG_SCHEMAS.items(): + for entry in schema.schema: + # remove options that match defaults + if ( + data_to_save[CUSTOM_CONFIGURATION].get(section, {}).get(entry) + == entry.default() + ): + data_to_save[CUSTOM_CONFIGURATION][section].pop(entry) + # remove entire section block if empty + if ( + not data_to_save[CUSTOM_CONFIGURATION].get(section) + and section in data_to_save[CUSTOM_CONFIGURATION] + ): + data_to_save[CUSTOM_CONFIGURATION].pop(section) + + # remove entire custom_configuration block if empty + if ( + not data_to_save.get(CUSTOM_CONFIGURATION) + and CUSTOM_CONFIGURATION in data_to_save + ): + data_to_save.pop(CUSTOM_CONFIGURATION) + + _LOGGER.info( + "Updating ZHA custom configuration options from %s to %s", + options, + data_to_save, + ) + + hass.config_entries.async_update_entry( + zha_gateway.config_entry, options=data_to_save + ) + status = await hass.config_entries.async_reload(zha_gateway.config_entry.entry_id) + connection.send_result(msg[ID], status) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/settings"}) +@websocket_api.async_response +async def websocket_get_network_settings( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA network settings.""" + backup = async_get_active_network_settings(hass) + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + connection.send_result( + msg[ID], + { + "radio_type": async_get_radio_type(hass, zha_gateway.config_entry).name, + "settings": backup.as_dict(), + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/list"}) +@websocket_api.async_response +async def websocket_list_network_backups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA network settings.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + + # Serialize known backups + connection.send_result( + msg[ID], [backup.as_dict() for backup in application_controller.backups] + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/create"}) +@websocket_api.async_response +async def websocket_create_network_backup( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Create a ZHA network backup.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + + # This can take 5-30s + backup = await application_controller.backups.create_backup(load_devices=True) + connection.send_result( + msg[ID], + { + "backup": backup.as_dict(), + "is_complete": backup.is_complete(), + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/network/backups/restore", + vol.Required("backup"): _cv_zigpy_network_backup, + vol.Optional("ezsp_force_write_eui64", default=False): cv.boolean, + } +) +@websocket_api.async_response +async def websocket_restore_network_backup( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Restore a ZHA network backup.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + backup = msg["backup"] + + if msg["ezsp_force_write_eui64"]: + backup.network_info.stack_specific.setdefault("ezsp", {})[ + EZSP_OVERWRITE_EUI64 + ] = True + + # This can take 30-40s + try: + await application_controller.backups.restore_backup(backup) + except ValueError as err: + connection.send_error(msg[ID], websocket_api.const.ERR_INVALID_FORMAT, str(err)) + else: + connection.send_result(msg[ID]) + + +@callback +def async_load_api(hass: HomeAssistant) -> None: + """Set up the web socket API.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + + async def permit(service: ServiceCall) -> None: + """Allow devices to join this network.""" + duration: int = service.data[ATTR_DURATION] + ieee: EUI64 | None = service.data.get(ATTR_IEEE) + src_ieee: EUI64 + code: bytes + if ATTR_SOURCE_IEEE in service.data: + src_ieee = service.data[ATTR_SOURCE_IEEE] + code = service.data[ATTR_INSTALL_CODE] + _LOGGER.info("Allowing join for %s device with install code", src_ieee) + await application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + return + + if ATTR_QR_CODE in service.data: + src_ieee, code = service.data[ATTR_QR_CODE] + _LOGGER.info("Allowing join for %s device with install code", src_ieee) + await application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + return + + if ieee: + _LOGGER.info("Permitting joins for %ss on %s device", duration, ieee) + else: + _LOGGER.info("Permitting joins for %ss", duration) + await application_controller.permit(time_s=duration, node=ieee) + + async_register_admin_service( + hass, DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT] + ) + + async def remove(service: ServiceCall) -> None: + """Remove a node from the network.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = service.data[ATTR_IEEE] + zha_device: ZHADevice | None = zha_gateway.get_device(ieee) + if zha_device is not None and zha_device.is_active_coordinator: + _LOGGER.info("Removing the coordinator (%s) is not allowed", ieee) + return + _LOGGER.info("Removing node %s", ieee) + await application_controller.remove(ieee) + + async_register_admin_service( + hass, DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[IEEE_SERVICE] + ) + + async def set_zigbee_cluster_attributes(service: ServiceCall) -> None: + """Set zigbee attribute for cluster on zha entity.""" + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + attribute: int | str = service.data[ATTR_ATTRIBUTE] + value: int | bool | str = service.data[ATTR_VALUE] + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + response = None + if zha_device is not None: + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code + response = await zha_device.write_zigbee_attribute( + endpoint_id, + cluster_id, + attribute, + value, + cluster_type=cluster_type, + manufacturer=manufacturer, + ) + _LOGGER.debug( + ( + "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" + " [%s] %s: [%s]" + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_ATTRIBUTE, + attribute, + ATTR_VALUE, + value, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + response, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, + set_zigbee_cluster_attributes, + schema=SERVICE_SCHEMAS[SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE], + ) + + async def issue_zigbee_cluster_command(service: ServiceCall) -> None: + """Issue command on zigbee cluster on ZHA entity.""" + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + command: int = service.data[ATTR_COMMAND] + command_type: str = service.data[ATTR_COMMAND_TYPE] + args: list | None = service.data.get(ATTR_ARGS) + params: dict | None = service.data.get(ATTR_PARAMS) + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + if zha_device is not None: + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code + + await zha_device.issue_cluster_command( + endpoint_id, + cluster_id, + command, + command_type, + args, + params, + cluster_type=cluster_type, + manufacturer=manufacturer, + ) + _LOGGER.debug( + ( + "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" + " %s: [%s] %s: [%s] %s: [%s]" + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_COMMAND, + command, + ATTR_COMMAND_TYPE, + command_type, + ATTR_ARGS, + args, + ATTR_PARAMS, + params, + ATTR_MANUFACTURER, + manufacturer, + ) + else: + raise ValueError(f"Device with IEEE {str(ieee)} not found") + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, + issue_zigbee_cluster_command, + schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND], + ) + + async def issue_zigbee_group_command(service: ServiceCall) -> None: + """Issue command on zigbee cluster on a zigbee group.""" + group_id: int = service.data[ATTR_GROUP] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + command: int = service.data[ATTR_COMMAND] + args: list = service.data[ATTR_ARGS] + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + group = zha_gateway.get_group(group_id) + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + _LOGGER.error("Missing manufacturer attribute for cluster: %d", cluster_id) + response = None + if group is not None: + cluster = group.endpoint[cluster_id] + response = await cluster.command( + command, *args, manufacturer=manufacturer, expect_reply=True + ) + _LOGGER.debug( + "Issued group command for: %s: [%s] %s: [%s] %s: %s %s: [%s] %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_COMMAND, + command, + ATTR_ARGS, + args, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + response, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, + issue_zigbee_group_command, + schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], + ) + + def _get_ias_wd_cluster_handler(zha_device): + """Get the IASWD cluster handler for a device.""" + cluster_handlers = { + ch.name: ch + for endpoint in zha_device.endpoints.values() + for ch in endpoint.claimed_cluster_handlers.values() + } + return cluster_handlers.get(CLUSTER_HANDLER_IAS_WD) + + async def warning_device_squawk(service: ServiceCall) -> None: + """Issue the squawk command for an IAS warning device.""" + ieee: EUI64 = service.data[ATTR_IEEE] + mode: int = service.data[ATTR_WARNING_DEVICE_MODE] + strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] + level: int = service.data[ATTR_LEVEL] + + if (zha_device := zha_gateway.get_device(ieee)) is not None: + if cluster_handler := _get_ias_wd_cluster_handler(zha_device): + await cluster_handler.issue_squawk(mode, strobe, level) + else: + _LOGGER.error( + "Squawking IASWD: %s: [%s] is missing the required IASWD cluster handler!", + ATTR_IEEE, + str(ieee), + ) + else: + _LOGGER.error( + "Squawking IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) + ) + _LOGGER.debug( + "Squawking IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", + ATTR_IEEE, + str(ieee), + ATTR_WARNING_DEVICE_MODE, + mode, + ATTR_WARNING_DEVICE_STROBE, + strobe, + ATTR_LEVEL, + level, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_WARNING_DEVICE_SQUAWK, + warning_device_squawk, + schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_SQUAWK], + ) + + async def warning_device_warn(service: ServiceCall) -> None: + """Issue the warning command for an IAS warning device.""" + ieee: EUI64 = service.data[ATTR_IEEE] + mode: int = service.data[ATTR_WARNING_DEVICE_MODE] + strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] + level: int = service.data[ATTR_LEVEL] + duration: int = service.data[ATTR_WARNING_DEVICE_DURATION] + duty_mode: int = service.data[ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE] + intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY] + + if (zha_device := zha_gateway.get_device(ieee)) is not None: + if cluster_handler := _get_ias_wd_cluster_handler(zha_device): + await cluster_handler.issue_start_warning( + mode, strobe, level, duration, duty_mode, intensity + ) + else: + _LOGGER.error( + "Warning IASWD: %s: [%s] is missing the required IASWD cluster handler!", + ATTR_IEEE, + str(ieee), + ) + else: + _LOGGER.error( + "Warning IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) + ) + _LOGGER.debug( + "Warning IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", + ATTR_IEEE, + str(ieee), + ATTR_WARNING_DEVICE_MODE, + mode, + ATTR_WARNING_DEVICE_STROBE, + strobe, + ATTR_LEVEL, + level, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_WARNING_DEVICE_WARN, + warning_device_warn, + schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_WARN], + ) + + websocket_api.async_register_command(hass, websocket_permit_devices) + websocket_api.async_register_command(hass, websocket_get_devices) + websocket_api.async_register_command(hass, websocket_get_groupable_devices) + websocket_api.async_register_command(hass, websocket_get_groups) + websocket_api.async_register_command(hass, websocket_get_device) + websocket_api.async_register_command(hass, websocket_get_group) + websocket_api.async_register_command(hass, websocket_add_group) + websocket_api.async_register_command(hass, websocket_remove_groups) + websocket_api.async_register_command(hass, websocket_add_group_members) + websocket_api.async_register_command(hass, websocket_remove_group_members) + websocket_api.async_register_command(hass, websocket_bind_group) + websocket_api.async_register_command(hass, websocket_unbind_group) + websocket_api.async_register_command(hass, websocket_reconfigure_node) + websocket_api.async_register_command(hass, websocket_device_clusters) + websocket_api.async_register_command(hass, websocket_device_cluster_attributes) + websocket_api.async_register_command(hass, websocket_device_cluster_commands) + websocket_api.async_register_command(hass, websocket_read_zigbee_cluster_attributes) + websocket_api.async_register_command(hass, websocket_get_bindable_devices) + websocket_api.async_register_command(hass, websocket_bind_devices) + websocket_api.async_register_command(hass, websocket_unbind_devices) + websocket_api.async_register_command(hass, websocket_update_topology) + websocket_api.async_register_command(hass, websocket_get_configuration) + websocket_api.async_register_command(hass, websocket_update_zha_configuration) + websocket_api.async_register_command(hass, websocket_get_network_settings) + websocket_api.async_register_command(hass, websocket_list_network_backups) + websocket_api.async_register_command(hass, websocket_create_network_backup) + websocket_api.async_register_command(hass, websocket_restore_network_backup) + + +@callback +def async_unload_api(hass: HomeAssistant) -> None: + """Unload the ZHA API.""" + hass.services.async_remove(DOMAIN, SERVICE_PERMIT) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE) + hass.services.async_remove(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE) + hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND) + hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND) + hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_SQUAWK) + hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_WARN) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 3ab2a35bf1..2133c8550d 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -163,7 +163,7 @@ def in_zone(zone: State, latitude: float, longitude: float, radius: float = 0) - return zone_dist - radius < cast(float, zone.attributes[ATTR_RADIUS]) -class ZoneStorageCollection(collection.StorageCollection): +class ZoneStorageCollection(collection.DictStorageCollection): """Zone collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -178,10 +178,10 @@ class ZoneStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return cast(str, info[CONF_NAME]) - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.UPDATE_SCHEMA(update_data) - return {**data, **update_data} + return {**item, **update_data} async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -198,7 +198,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = ZoneStorageCollection( storage.Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -210,7 +209,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index a2d729e22d..66839026dd 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections import defaultdict from collections.abc import Coroutine +from contextlib import suppress from typing import Any from async_timeout import timeout @@ -792,7 +793,11 @@ async def disconnect_client(hass: HomeAssistant, entry: ConfigEntry) -> None: for task in platform_setup_tasks: task.cancel() - await asyncio.gather(listen_task, start_client_task, *platform_setup_tasks) + tasks = (listen_task, start_client_task, *platform_setup_tasks) + await asyncio.gather(*tasks, return_exceptions=True) + for task in tasks: + with suppress(asyncio.CancelledError): + await task if client.connected: await client.disconnect() diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 91b1e2a715..29e0dcf9e0 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -82,8 +82,8 @@ from .const import ( from .helpers import ( async_enable_statistics, async_get_node_from_device_id, + async_update_data_collection_preference, get_device_id, - update_data_collection_preference, ) DATA_UNSUBSCRIBE = "unsubs" @@ -1860,7 +1860,7 @@ async def websocket_update_data_collection_preference( ) -> None: """Update preference for data collection and enable/disable collection.""" opted_in = msg[OPTED_IN] - update_data_collection_preference(hass, entry, opted_in) + async_update_data_collection_preference(hass, entry, opted_in) if opted_in: await async_enable_statistics(driver) diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index 2db82d38d6..e743284abd 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -5,7 +5,7 @@ from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode -from homeassistant.components.button import ButtonEntity +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback @@ -13,6 +13,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_CLIENT, DOMAIN, LOGGER +from .discovery import ZwaveDiscoveryInfo +from .entity import ZWaveBaseEntity from .helpers import get_device_info, get_valueless_base_unique_id PARALLEL_UPDATES = 0 @@ -26,6 +28,17 @@ async def async_setup_entry( """Set up Z-Wave button from config entry.""" client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + @callback + def async_add_button(info: ZwaveDiscoveryInfo) -> None: + """Add Z-Wave Button.""" + driver = client.driver + assert driver is not None # Driver is ready before platforms are loaded. + entities: list[ZWaveBaseEntity] = [] + if info.platform_hint == "notification idle": + entities.append(ZWaveNotificationIdleButton(config_entry, driver, info)) + + async_add_entities(entities) + @callback def async_add_ping_button_entity(node: ZwaveNode) -> None: """Add ping button entity.""" @@ -41,6 +54,14 @@ async def async_setup_entry( ) ) + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_{BUTTON_DOMAIN}", + async_add_button, + ) + ) + class ZWaveNodePingButton(ButtonEntity): """Representation of a ping button entity.""" @@ -88,3 +109,25 @@ class ZWaveNodePingButton(ButtonEntity): async def async_press(self) -> None: """Press the button.""" self.hass.async_create_task(self.node.async_ping()) + + +class ZWaveNotificationIdleButton(ZWaveBaseEntity, ButtonEntity): + """Button to idle Notification CC values.""" + + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZWaveNotificationIdleButton entity.""" + super().__init__(config_entry, driver, info) + self._attr_name = self.generate_name( + include_value_name=True, name_prefix="Idle" + ) + self._attr_unique_id = f"{self._attr_unique_id}.notification_idle" + + async def async_press(self) -> None: + """Press the button.""" + await self.info.node.async_manually_idle_notification_value( + self.info.primary_value + ) diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 50130fc263..4f52c41a08 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -34,16 +34,23 @@ VALUES_TO_REDACT = ( ) -def redact_value_of_zwave_value(zwave_value: ValueDataType) -> ValueDataType: - """Redact value of a Z-Wave value.""" +def _redacted_value(zwave_value: ValueDataType) -> ValueDataType: + """Return redacted value of a Z-Wave value.""" + redacted_value: ValueDataType = deepcopy(zwave_value) + redacted_value["value"] = REDACTED + return redacted_value + + +def optionally_redact_value_of_zwave_value(zwave_value: ValueDataType) -> ValueDataType: + """Redact value of a Z-Wave value if it matches criteria to redact.""" # If the value has no value, there is nothing to redact if zwave_value.get("value") in (None, ""): return zwave_value + if zwave_value.get("metadata", {}).get("secret"): + return _redacted_value(zwave_value) for value_to_redact in VALUES_TO_REDACT: if value_matches_matcher(value_to_redact, zwave_value): - redacted_value: ValueDataType = deepcopy(zwave_value) - redacted_value["value"] = REDACTED - return redacted_value + return _redacted_value(zwave_value) return zwave_value @@ -51,7 +58,8 @@ def redact_node_state(node_state: NodeDataType) -> NodeDataType: """Redact node state.""" redacted_state: NodeDataType = deepcopy(node_state) redacted_state["values"] = [ - redact_value_of_zwave_value(zwave_value) for zwave_value in node_state["values"] + optionally_redact_value_of_zwave_value(zwave_value) + for zwave_value in node_state["values"] ] return redacted_state @@ -117,7 +125,8 @@ async def async_get_config_entry_diagnostics( handshake_msgs = msgs[:-1] network_state = msgs[-1] network_state["result"]["state"]["nodes"] = [ - redact_node_state(node) for node in network_state["result"]["state"]["nodes"] + redact_node_state(async_redact_data(node, KEYS_TO_REDACT)) + for node in network_state["result"]["state"]["nodes"] ] return {"messages": [*handshake_msgs, network_state]} @@ -136,7 +145,6 @@ async def async_get_device_diagnostics( entities = get_device_entities(hass, node, device) assert client.version node_state = redact_node_state(async_redact_data(node.data, KEYS_TO_REDACT)) - node_state["statistics"] = node.statistics.data return { "versionInfo": { "driverVersion": client.version.driver_version, diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 36295a6455..b3255a76f7 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -147,6 +147,8 @@ class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): property_key_name: set[str | None] | None = None # [optional] the value's metadata_type must match ANY of these values type: set[str] | None = None + # [optional] the value's states map must include ANY of these key/value pairs + any_available_states: set[tuple[int, str]] | None = None @dataclass @@ -897,6 +899,17 @@ DISCOVERY_SCHEMAS = [ type={ValueType.NUMBER}, ), ), + # button + # Notification CC idle + ZWaveDiscoverySchema( + platform=Platform.BUTTON, + hint="notification idle", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + type={ValueType.NUMBER}, + any_available_states={(0, "idle")}, + ), + ), ] @@ -1072,6 +1085,16 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: # check metadata_type if schema.type is not None and value.metadata.type not in schema.type: return False + # check available states + if ( + schema.any_available_states is not None + and value.metadata.states is not None + and not any( + str(key) in value.metadata.states and value.metadata.states[str(key)] == val + for key, val in schema.any_available_states + ) + ): + return False return True diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index d856e987af..6c54a46483 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -92,7 +92,6 @@ def value_matches_matcher( ) -@callback def get_value_id_from_unique_id(unique_id: str) -> str | None: """Get the value ID and optional state key from a unique ID. @@ -106,7 +105,6 @@ def get_value_id_from_unique_id(unique_id: str) -> str | None: return None -@callback def get_state_key_from_unique_id(unique_id: str) -> int | None: """Get the state key from a unique ID.""" # If the unique ID has more than two parts, it's a special unique ID. If the last @@ -119,7 +117,6 @@ def get_state_key_from_unique_id(unique_id: str) -> int | None: return None -@callback def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None: """Return the value of a ZwaveValue.""" return value.value if value else None @@ -132,7 +129,7 @@ async def async_enable_statistics(driver: Driver) -> None: @callback -def update_data_collection_preference( +def async_update_data_collection_preference( hass: HomeAssistant, entry: ConfigEntry, preference: bool ) -> None: """Update data collection preference on config entry.""" @@ -141,7 +138,6 @@ def update_data_collection_preference( hass.config_entries.async_update_entry(entry, data=new_data) -@callback def get_valueless_base_unique_id(driver: Driver, node: ZwaveNode) -> str: """Return the base unique ID for an entity that is not based on a value.""" return f"{driver.controller.home_id}.{node.node_id}" @@ -152,13 +148,11 @@ def get_unique_id(driver: Driver, value_id: str) -> str: return f"{driver.controller.home_id}.{value_id}" -@callback def get_device_id(driver: Driver, node: ZwaveNode) -> tuple[str, str]: """Get device registry identifier for Z-Wave node.""" return (DOMAIN, f"{driver.controller.home_id}-{node.node_id}") -@callback def get_device_id_ext(driver: Driver, node: ZwaveNode) -> tuple[str, str] | None: """Get extended device registry identifier for Z-Wave node.""" if None in (node.manufacturer_id, node.product_type, node.product_id): @@ -171,7 +165,6 @@ def get_device_id_ext(driver: Driver, node: ZwaveNode) -> tuple[str, str] | None ) -@callback def get_home_and_node_id_from_device_entry( device_entry: dr.DeviceEntry, ) -> tuple[str, int] | None: diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index a21f7a6f30..8452ba2ed3 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.46.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.48.0"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 1c6824920c..47a16ee127 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -9,7 +9,7 @@ from typing import Any import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass, CommandStatus -from zwave_js_server.exceptions import SetValueFailed +from zwave_js_server.exceptions import FailedZWaveCommand, SetValueFailed from zwave_js_server.model.endpoint import Endpoint from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ValueDataType, get_value_id_str @@ -604,13 +604,16 @@ class ZWaveServices: ): new_value = str(new_value) - success = await async_multicast_set_value( - client=client, - new_value=new_value, - value_data=value, - nodes=None if broadcast else list(nodes), - options=options, - ) + try: + success = await async_multicast_set_value( + client=client, + new_value=new_value, + value_data=value, + nodes=None if broadcast else list(nodes), + options=options, + ) + except FailedZWaveCommand as err: + raise HomeAssistantError("Unable to set value via multicast") from err if success is False: raise HomeAssistantError( diff --git a/homeassistant/components/zwave_me/__init__.py b/homeassistant/components/zwave_me/__init__.py index 346831b34d..1740820d0b 100644 --- a/homeassistant/components/zwave_me/__init__.py +++ b/homeassistant/components/zwave_me/__init__.py @@ -50,6 +50,8 @@ class ZWaveMeController: self.zwave_api = ZWaveMe( on_device_create=self.on_device_create, on_device_update=self.on_device_update, + on_device_remove=self.on_device_unavailable, + on_device_destroy=self.on_device_destroy, on_new_device=self.add_device, token=self.config.data[CONF_TOKEN], url=self.config.data[CONF_URL], @@ -82,6 +84,14 @@ class ZWaveMeController: """Send signal to update device.""" dispatcher_send(self._hass, f"ZWAVE_ME_INFO_{new_info.id}", new_info) + def on_device_unavailable(self, device_id: str) -> None: + """Send signal to set device unavailable.""" + dispatcher_send(self._hass, f"ZWAVE_ME_UNAVAILABLE_{device_id}") + + def on_device_destroy(self, device_id: str) -> None: + """Send signal to destroy device.""" + dispatcher_send(self._hass, f"ZWAVE_ME_DESTROY_{device_id}") + def remove_stale_devices(self, registry: dr.DeviceRegistry): """Remove old-format devices in the registry.""" for device_id in self.device_ids: @@ -133,10 +143,33 @@ class ZWaveMeEntity(Entity): self.hass, f"ZWAVE_ME_INFO_{self.device.id}", self.get_new_data ) ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"ZWAVE_ME_UNAVAILABLE_{self.device.id}", + self.set_unavailable_status, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, f"ZWAVE_ME_DESTROY_{self.device.id}", self.delete_entity + ) + ) @callback - def get_new_data(self, new_data): + def get_new_data(self, new_data: ZWaveMeData) -> None: """Update info in the HAss.""" self.device = new_data self._attr_available = not new_data.isFailed self.async_write_ha_state() + + @callback + def set_unavailable_status(self): + """Update status in the HAss.""" + self._attr_available = False + self.async_write_ha_state() + + @callback + def delete_entity(self) -> None: + """Remove this entity.""" + self.hass.async_create_task(self.async_remove(force_remove=True)) diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index 6339015968..1a4d9cccbe 100644 --- a/homeassistant/components/zwave_me/manifest.json +++ b/homeassistant/components/zwave_me/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_me", "iot_class": "local_push", - "requirements": ["zwave_me_ws==0.3.1", "url-normalize==1.4.3"], + "requirements": ["zwave_me_ws==0.4.2", "url-normalize==1.4.3"], "zeroconf": [ { "type": "_hap._tcp.local.", diff --git a/homeassistant/config.py b/homeassistant/config.py index 283f8726e2..0a5da91d94 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -61,7 +61,7 @@ from .helpers import ( ) from .helpers.entity_values import EntityValues from .helpers.typing import ConfigType -from .loader import Integration, IntegrationNotFound +from .loader import ComponentProtocol, Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements from .util.package import is_docker_env from .util.unit_system import get_unit_system, validate_unit_system @@ -681,7 +681,7 @@ def _log_pkg_error(package: str, component: str, config: dict, message: str) -> _LOGGER.error(message) -def _identify_config_schema(module: ModuleType) -> str | None: +def _identify_config_schema(module: ComponentProtocol) -> str | None: """Extract the schema and identify list or dict based.""" if not isinstance(module.CONFIG_SCHEMA, vol.Schema): return None diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 3ab16f6967..adbb2f80f6 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -20,7 +20,7 @@ from . import data_entry_flow, loader from .backports.enum import StrEnum from .components import persistent_notification from .const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, Platform -from .core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback +from .core import CALLBACK_TYPE, CoreState, Event, HassJob, HomeAssistant, callback from .data_entry_flow import FlowResult from .exceptions import ( ConfigEntryAuthFailed, @@ -294,10 +294,10 @@ class ConfigEntry: self.disabled_by = disabled_by # Supports unload - self.supports_unload = False + self.supports_unload: bool | None = None # Supports remove device - self.supports_remove_device = False + self.supports_remove_device: bool | None = None # Listeners to call on update self.update_listeners: list[ @@ -310,8 +310,10 @@ class ConfigEntry: # Function to cancel a scheduled retry self._async_cancel_retry_setup: Callable[[], Any] | None = None - # Hold list for functions to call on unload. - self._on_unload: list[CALLBACK_TYPE] | None = None + # Hold list for actions to call on unload. + self._on_unload: list[ + Callable[[], Coroutine[Any, Any, None] | None] + ] | None = None # Reload lock to prevent conflicting reloads self.reload_lock = asyncio.Lock() @@ -338,10 +340,12 @@ class ConfigEntry: if self.domain == integration.domain: self.async_set_state(hass, ConfigEntryState.SETUP_IN_PROGRESS, None) - self.supports_unload = await support_entry_unload(hass, self.domain) - self.supports_remove_device = await support_remove_from_device( - hass, self.domain - ) + if self.supports_unload is None: + self.supports_unload = await support_entry_unload(hass, self.domain) + if self.supports_remove_device is None: + self.supports_remove_device = await support_remove_from_device( + hass, self.domain + ) try: component = integration.get_component() @@ -383,7 +387,7 @@ class ConfigEntry: result = await component.async_setup_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( + _LOGGER.error( # type: ignore[unreachable] "%s.async_setup_entry did not return boolean", integration.domain ) result = False @@ -395,7 +399,7 @@ class ConfigEntry: self.domain, error_reason, ) - await self._async_process_on_unload() + await self._async_process_on_unload(hass) result = False except ConfigEntryAuthFailed as ex: message = str(ex) @@ -410,7 +414,7 @@ class ConfigEntry: self.domain, auth_message, ) - await self._async_process_on_unload() + await self._async_process_on_unload(hass) self.async_start_reauth(hass) result = False except ConfigEntryNotReady as ex: @@ -461,7 +465,7 @@ class ConfigEntry: EVENT_HOMEASSISTANT_STARTED, setup_again ) - await self._async_process_on_unload() + await self._async_process_on_unload(hass) return # pylint: disable-next=broad-except except (asyncio.CancelledError, SystemExit, Exception): @@ -544,10 +548,9 @@ class ConfigEntry: if result and integration.domain == self.domain: self.async_set_state(hass, ConfigEntryState.NOT_LOADED, None) - await self._async_process_on_unload() + await self._async_process_on_unload(hass) - # https://github.com/python/mypy/issues/11839 - return result # type: ignore[no-any-return] + return result except Exception as ex: # pylint: disable=broad-except _LOGGER.exception( "Error unloading entry %s for %s", self.title, integration.domain @@ -628,15 +631,14 @@ class ConfigEntry: try: result = await component.async_migrate_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( + _LOGGER.error( # type: ignore[unreachable] "%s.async_migrate_entry did not return boolean", self.domain ) return False if result: # pylint: disable-next=protected-access hass.config_entries._async_schedule_save() - # https://github.com/python/mypy/issues/11839 - return result # type: ignore[no-any-return] + return result except Exception: # pylint: disable=broad-except _LOGGER.exception( "Error migrating entry %s for %s", self.title, self.domain @@ -676,17 +678,20 @@ class ConfigEntry: } @callback - def async_on_unload(self, func: CALLBACK_TYPE) -> None: + def async_on_unload( + self, func: Callable[[], Coroutine[Any, Any, None] | None] + ) -> None: """Add a function to call when config entry is unloaded.""" if self._on_unload is None: self._on_unload = [] self._on_unload.append(func) - async def _async_process_on_unload(self) -> None: + async def _async_process_on_unload(self, hass: HomeAssistant) -> None: """Process the on_unload callbacks and wait for pending tasks.""" if self._on_unload is not None: while self._on_unload: - self._on_unload.pop()() + if job := self._on_unload.pop()(): + self._tasks.add(hass.async_create_task(job)) if not self._tasks and not self._background_tasks: return @@ -954,25 +959,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): Handler key is the domain of the component that we want to set up. """ - try: - integration = await loader.async_get_integration(self.hass, handler_key) - except loader.IntegrationNotFound as err: - _LOGGER.error("Cannot find integration %s", handler_key) - raise data_entry_flow.UnknownHandler from err - - # Make sure requirements and dependencies of component are resolved - await async_process_deps_reqs(self.hass, self._hass_config, integration) - - try: - integration.get_platform("config_flow") - except ImportError as err: - _LOGGER.error( - "Error occurred loading configuration flow for integration %s: %s", - handler_key, - err, - ) - raise data_entry_flow.UnknownHandler - + await _load_integration(self.hass, handler_key, self._hass_config) if (handler := HANDLERS.get(handler_key)) is None: raise data_entry_flow.UnknownHandler @@ -1371,26 +1358,6 @@ class ConfigEntries: self.hass, SIGNAL_CONFIG_ENTRY_CHANGED, change_type, entry ) - @callback - def async_setup_platforms( - self, entry: ConfigEntry, platforms: Iterable[Platform | str] - ) -> None: - """Forward the setup of an entry to platforms.""" - report( - ( - "called async_setup_platforms instead of awaiting" - " async_forward_entry_setups; this will fail in version 2023.3" - ), - # Raise this to warning once all core integrations have been migrated - level=logging.WARNING, - error_if_core=False, - ) - for platform in platforms: - self.hass.async_create_task( - self.async_forward_entry_setup(entry, platform), - f"config entry forward setup {entry.title} {entry.domain} {entry.entry_id} {platform}", - ) - async def async_forward_entry_setups( self, entry: ConfigEntry, platforms: Iterable[Platform | str] ) -> None: @@ -1486,6 +1453,28 @@ async def _old_conf_migrator(old_config: dict[str, Any]) -> dict[str, Any]: return {"entries": old_config} +@callback +def _async_abort_entries_match( + other_entries: list[ConfigEntry], match_dict: dict[str, Any] | None = None +) -> None: + """Abort if current entries match all data. + + Requires `already_configured` in strings.json in user visible flows. + """ + if match_dict is None: + match_dict = {} # Match any entry + for entry in other_entries: + if all( + item + in ChainMap( + entry.options, # type: ignore[arg-type] + entry.data, # type: ignore[arg-type] + ).items() + for item in match_dict.items() + ): + raise data_entry_flow.AbortFlow("already_configured") + + class ConfigFlow(data_entry_flow.FlowHandler): """Base class for config flows with some helpers.""" @@ -1523,18 +1512,9 @@ class ConfigFlow(data_entry_flow.FlowHandler): Requires `already_configured` in strings.json in user visible flows. """ - if match_dict is None: - match_dict = {} # Match any entry - for entry in self._async_current_entries(include_ignore=False): - if all( - item - in ChainMap( - entry.options, # type: ignore[arg-type] - entry.data, # type: ignore[arg-type] - ).items() - for item in match_dict.items() - ): - raise data_entry_flow.AbortFlow("already_configured") + _async_abort_entries_match( + self._async_current_entries(include_ignore=False), match_dict + ) @callback def _abort_if_unique_id_configured( @@ -1842,6 +1822,8 @@ class OptionsFlowManager(data_entry_flow.FlowManager): if entry is None: raise UnknownEntry(handler_key) + await _load_integration(self.hass, entry.domain, {}) + if entry.domain not in HANDLERS: raise data_entry_flow.UnknownHandler @@ -1874,6 +1856,27 @@ class OptionsFlow(data_entry_flow.FlowHandler): handler: str + @callback + def _async_abort_entries_match( + self, match_dict: dict[str, Any] | None = None + ) -> None: + """Abort if another current entry matches all data. + + Requires `already_configured` in strings.json in user visible flows. + """ + + config_entry = cast( + ConfigEntry, self.hass.config_entries.async_get_entry(self.handler) + ) + _async_abort_entries_match( + [ + entry + for entry in self.hass.config_entries.async_entries(config_entry.domain) + if entry is not config_entry and entry.source != SOURCE_IGNORE + ], + match_dict, + ) + class OptionsFlowWithConfigEntry(OptionsFlow): """Base class for options flows with config entry and options.""" @@ -1949,7 +1952,9 @@ class EntityRegistryDisabledHandler: self._remove_call_later() self._remove_call_later = async_call_later( - self.hass, RELOAD_AFTER_UPDATE_DELAY, self._handle_reload + self.hass, + RELOAD_AFTER_UPDATE_DELAY, + HassJob(self._handle_reload, cancel_on_shutdown=True), ) async def _handle_reload(self, _now: Any) -> None: @@ -2006,3 +2011,26 @@ async def support_remove_from_device(hass: HomeAssistant, domain: str) -> bool: integration = await loader.async_get_integration(hass, domain) component = integration.get_component() return hasattr(component, "async_remove_config_entry_device") + + +async def _load_integration( + hass: HomeAssistant, domain: str, hass_config: ConfigType +) -> None: + try: + integration = await loader.async_get_integration(hass, domain) + except loader.IntegrationNotFound as err: + _LOGGER.error("Cannot find integration %s", domain) + raise data_entry_flow.UnknownHandler from err + + # Make sure requirements and dependencies of component are resolved + await async_process_deps_reqs(hass, hass_config, integration) + + try: + integration.get_platform("config_flow") + except ImportError as err: + _LOGGER.error( + "Error occurred loading flow for integration %s: %s", + domain, + err, + ) + raise data_entry_flow.UnknownHandler diff --git a/homeassistant/const.py b/homeassistant/const.py index 1559560f11..23b4a9a132 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 4 +MINOR_VERSION: Final = 5 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/homeassistant/core.py b/homeassistant/core.py index 900355d4a5..f7cfcf4420 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -217,13 +217,25 @@ class HassJob(Generic[_P, _R_co]): we run the job. """ - __slots__ = ("job_type", "target", "name") + __slots__ = ("job_type", "target", "name", "_cancel_on_shutdown") - def __init__(self, target: Callable[_P, _R_co], name: str | None = None) -> None: + def __init__( + self, + target: Callable[_P, _R_co], + name: str | None = None, + *, + cancel_on_shutdown: bool | None = None, + ) -> None: """Create a job object.""" self.target = target self.name = name self.job_type = _get_hassjob_callable_job_type(target) + self._cancel_on_shutdown = cancel_on_shutdown + + @property + def cancel_on_shutdown(self) -> bool | None: + """Return if the job should be cancelled on shutdown.""" + return self._cancel_on_shutdown def __repr__(self) -> str: """Return the job.""" @@ -505,12 +517,14 @@ class HomeAssistant: return task - def create_task(self, target: Coroutine[Any, Any, Any]) -> None: + def create_task( + self, target: Coroutine[Any, Any, Any], name: str | None = None + ) -> None: """Add task to the executor pool. target: target to call. """ - self.loop.call_soon_threadsafe(self.async_create_task, target) + self.loop.call_soon_threadsafe(self.async_create_task, target, name) @callback def async_create_task( @@ -728,6 +742,7 @@ class HomeAssistant: self._tasks.add(task) task.add_done_callback(self._tasks.remove) task.cancel() + self._cancel_cancellable_timers() self.exit_code = exit_code @@ -812,6 +827,20 @@ class HomeAssistant: if self._stopped is not None: self._stopped.set() + def _cancel_cancellable_timers(self) -> None: + """Cancel timer handles marked as cancellable.""" + # pylint: disable-next=protected-access + handles: Iterable[asyncio.TimerHandle] = self.loop._scheduled # type: ignore[attr-defined] + for handle in handles: + if ( + not handle.cancelled() + and (args := handle._args) # pylint: disable=protected-access + # pylint: disable-next=unidiomatic-typecheck + and type(job := args[0]) is HassJob + and job.cancel_on_shutdown + ): + handle.cancel() + def _async_log_running_tasks(self, stage: int) -> None: """Log all running tasks.""" for task in self._tasks: @@ -1847,7 +1876,10 @@ class ServiceRegistry: except Exception: # pylint: disable=broad-except _LOGGER.exception("Error executing service: %s", service_call) - self._hass.async_create_task(catch_exceptions()) + self._hass.async_create_task( + catch_exceptions(), + f"service call background {service_call.domain}.{service_call.service}", + ) async def _execute_service( self, handler: Service, service_call: ServiceCall @@ -1950,7 +1982,11 @@ class Config: ) def is_allowed_path(self, path: str) -> bool: - """Check if the path is valid for access from outside.""" + """Check if the path is valid for access from outside. + + This function does blocking I/O and should not be called from the event loop. + Use hass.async_add_executor_job to schedule it on the executor. + """ assert path is not None thepath = pathlib.Path(path) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 347ab89e45..e213814f52 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -48,7 +48,7 @@ RESULT_TYPE_MENU = "menu" EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed" -@dataclass +@dataclass(slots=True) class BaseServiceInfo: """Base class for discovery ServiceInfo.""" diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 6cc93ef4f6..bfc96eabfd 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -32,7 +32,7 @@ class TemplateError(HomeAssistantError): super().__init__(f"{exception.__class__.__name__}: {exception}") -@dataclass +@dataclass(slots=True) class ConditionError(HomeAssistantError): """Error during condition evaluation.""" @@ -52,7 +52,7 @@ class ConditionError(HomeAssistantError): return "\n".join(list(self.output(indent=0))) -@dataclass +@dataclass(slots=True) class ConditionErrorMessage(ConditionError): """Condition error message.""" @@ -64,7 +64,7 @@ class ConditionErrorMessage(ConditionError): yield self._indent(indent, f"In '{self.type}' condition: {self.message}") -@dataclass +@dataclass(slots=True) class ConditionErrorIndex(ConditionError): """Condition error with index.""" @@ -87,7 +87,7 @@ class ConditionErrorIndex(ConditionError): yield from self.error.output(indent + 1) -@dataclass +@dataclass(slots=True) class ConditionErrorContainer(ConditionError): """Condition error with subconditions.""" diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index fc29508431..24215a8a0c 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -327,6 +327,21 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "qingping", "service_data_uuid": "0000fdcd-0000-1000-8000-00805f9b34fb", }, + { + "domain": "rapt_ble", + "manufacturer_data_start": [ + 80, + 84, + ], + "manufacturer_id": 16722, + }, + { + "domain": "rapt_ble", + "manufacturer_data_start": [ + 71, + ], + "manufacturer_id": 17739, + }, { "connectable": False, "domain": "ruuvitag_ble", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6656972f8b..066fb6fb8b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -39,6 +39,8 @@ FLOWS = { "ambient_station", "android_ip_webcam", "androidtv", + "androidtv_remote", + "anova", "anthemav", "apcupsd", "apple_tv", @@ -67,6 +69,7 @@ FLOWS = { "braviatv", "broadlink", "brother", + "brottsplatskartan", "brunt", "bsblan", "bthome", @@ -79,7 +82,6 @@ FLOWS = { "coinbase", "control4", "coolmaster", - "coronavirus", "cpuspeed", "crownstone", "daikin", @@ -282,6 +284,7 @@ FLOWS = { "netatmo", "netgear", "nexia", + "nextcloud", "nextdns", "nfandroidtv", "nibe_heatpump", @@ -338,6 +341,7 @@ FLOWS = { "pushover", "pvoutput", "pvpc_hourly_pricing", + "qbittorrent", "qingping", "qnap_qsw", "rachio", @@ -347,6 +351,7 @@ FLOWS = { "rainbird", "rainforest_eagle", "rainmachine", + "rapt_ble", "rdw", "recollect_waste", "renault", @@ -357,6 +362,7 @@ FLOWS = { "ring", "risco", "rituals_perfume_genie", + "roborock", "roku", "roomba", "roon", @@ -397,6 +403,7 @@ FLOWS = { "smarttub", "smhi", "sms", + "snapcast", "snooz", "solaredge", "solarlog", @@ -478,6 +485,7 @@ FLOWS = { "vilfo", "vizio", "vlc_telnet", + "voip", "volumio", "volvooncall", "vulcan", @@ -494,7 +502,9 @@ FLOWS = { "wiz", "wled", "wolflink", + "workday", "ws66i", + "wyoming", "xbox", "xiaomi_aqara", "xiaomi_ble", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 333db76d4f..adcc32fe8d 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -330,11 +330,19 @@ DHCP: list[dict[str, str | bool]] = [ "domain": "nuki", "hostname": "nuki_bridge_*", }, + { + "domain": "obihai", + "macaddress": "9CADEF*", + }, { "domain": "oncue", "hostname": "kohlergen*", "macaddress": "00146F*", }, + { + "domain": "onvif", + "registered_devices": True, + }, { "domain": "overkiz", "hostname": "gateway*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index efd1899c5b..b0c164da1e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -241,17 +241,29 @@ "iot_class": "local_polling" }, "androidtv": { - "name": "Android TV", + "name": "Android Debug Bridge", "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, + "androidtv_remote": { + "name": "Android TV Remote", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "anel_pwrctrl": { "name": "Anel NET-PwrCtrl", "integration_type": "hub", "config_flow": false, "iot_class": "local_polling" }, + "anova": { + "name": "Anova", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "anthemav": { "name": "Anthem A/V Receivers", "integration_type": "hub", @@ -293,7 +305,7 @@ "integration_type": "hub", "config_flow": true, "iot_class": "local_push", - "name": "HomeKit" + "name": "HomeKit Bridge" }, "ibeacon": { "integration_type": "hub", @@ -392,6 +404,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "assist_pipeline": { + "name": "Assist pipeline", + "integration_type": "hub", + "config_flow": false, + "iot_class": "local_push" + }, "asterisk": { "name": "Asterisk", "integrations": { @@ -657,7 +675,7 @@ "brottsplatskartan": { "name": "Brottsplatskartan", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "browser": { @@ -875,12 +893,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "coronavirus": { - "name": "Coronavirus (COVID-19)", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "cozytouch": { "name": "Atlantic Cozytouch", "integration_type": "virtual", @@ -926,12 +938,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "darksky": { - "name": "Dark Sky", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" - }, "datadog": { "name": "Datadog", "integration_type": "hub", @@ -2305,6 +2311,12 @@ } } }, + "homeseer": { + "name": "HomeSeer", + "iot_standards": [ + "zwave" + ] + }, "homewizard": { "name": "HomeWizard Energy", "integration_type": "hub", @@ -3078,12 +3090,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "magicseaweed": { - "name": "Magicseaweed", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" - }, "mailgun": { "name": "Mailgun", "integration_type": "hub", @@ -3293,12 +3299,6 @@ "config_flow": true, "iot_class": "cloud_polling", "name": "Xbox" - }, - "xbox_live": { - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling", - "name": "Xbox Live" } } }, @@ -3395,6 +3395,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "monessen": { + "name": "Monessen", + "integration_type": "virtual", + "supported_by": "intellifire" + }, "monoprice": { "name": "Monoprice 6-Zone Amplifier", "integration_type": "hub", @@ -3622,7 +3627,7 @@ "nextcloud": { "name": "Nextcloud", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "nextdns": { @@ -4315,8 +4320,8 @@ }, "qbittorrent": { "name": "qBittorrent", - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "local_polling" }, "qingping": { @@ -4425,6 +4430,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "rapt_ble": { + "name": "RAPT Bluetooth", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "raspberry_pi": { "name": "Raspberry Pi", "integrations": { @@ -4585,8 +4596,9 @@ }, "roborock": { "name": "Roborock", - "integration_type": "virtual", - "supported_by": "xiaomi_miio" + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" }, "rocketchat": { "name": "Rocket.Chat", @@ -5061,7 +5073,7 @@ "snapcast": { "name": "Snapcast", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "snips": { @@ -6068,18 +6080,18 @@ } } }, - "voice_assistant": { - "name": "Voice Assistant", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_push" - }, "voicerss": { "name": "VoiceRSS", "integration_type": "hub", "config_flow": false, "iot_class": "cloud_push" }, + "voip": { + "name": "Voice over IP", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "volkszaehler": { "name": "Volkszaehler", "integration_type": "hub", @@ -6219,7 +6231,7 @@ "workday": { "name": "Workday", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "worldclock": { @@ -6252,6 +6264,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "wyoming": { + "name": "Wyoming Protocol", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "x10": { "name": "Heyu X10", "integration_type": "hub", @@ -6508,7 +6526,6 @@ "config_flow": false }, "input_button": { - "name": "Input Button", "integration_type": "helper", "config_flow": false }, @@ -6584,6 +6601,7 @@ "growatt_server", "homekit_controller", "input_boolean", + "input_button", "input_datetime", "input_number", "input_select", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index e5e83d5eae..3a2097a1d3 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -130,6 +130,11 @@ SSDP = { "st": "urn:schemas-upnp-org:device:fritzbox:1", }, ], + "frontier_silicon": [ + { + "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1", + }, + ], "harmony": [ { "deviceType": "urn:myharmony-com:device:harmony:1", @@ -300,6 +305,12 @@ SSDP = { { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:2", }, + { + "nt": "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + }, + { + "nt": "urn:schemas-upnp-org:device:InternetGatewayDevice:2", + }, ], "webostv": [ { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 2f3dbaefb1..1771d9d63b 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -279,6 +279,11 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_androidtvremote2._tcp.local.": [ + { + "domain": "androidtv_remote", + }, + ], "_api._tcp.local.": [ { "domain": "baf", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 53c3cc1cf2..78a8051df1 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -273,7 +273,7 @@ def _async_get_connector( if verify_ssl: ssl_context: bool | SSLContext = ssl_util.get_default_context() else: - ssl_context = False + ssl_context = ssl_util.get_default_no_verify_context() connector = aiohttp.TCPConnector( enable_cleanup_closed=True, diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 10ed9fdd65..499e548ce9 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -8,11 +8,9 @@ from typing import Any, cast import attr from homeassistant.core import HomeAssistant, callback -from homeassistant.loader import bind_hass from homeassistant.util import slugify from . import device_registry as dr, entity_registry as er -from .frame import report from .storage import Store from .typing import UNDEFINED, UndefinedType @@ -216,7 +214,7 @@ class AreaRegistry: if not new_values: return old - new = self.areas[area_id] = attr.evolve(old, **new_values) + new = self.areas[area_id] = attr.evolve(old, **new_values) # type: ignore[arg-type] if normalized_name is not None: self._normalized_name_area_idx[ normalized_name @@ -282,19 +280,6 @@ async def async_load(hass: HomeAssistant) -> None: await hass.data[DATA_REGISTRY].async_load() -@bind_hass -async def async_get_registry(hass: HomeAssistant) -> AreaRegistry: - """Get area registry. - - This is deprecated and will be removed in the future. Use async_get instead. - """ - report( - "uses deprecated `async_get_registry` to access area registry, use async_get" - " instead" - ) - return async_get(hass) - - def normalize_area_name(area_name: str) -> str: """Normalize an area name by removing whitespace and case folding.""" return area_name.casefold().replace(" ", "") diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 437cd41871..a0c47a0490 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -7,7 +7,8 @@ from collections.abc import Awaitable, Callable, Coroutine, Iterable from dataclasses import dataclass from itertools import groupby import logging -from typing import Any, cast +from operator import attrgetter +from typing import Any, Generic, TypedDict, TypeVar import voluptuous as vol from voluptuous.humanize import humanize_error @@ -31,8 +32,12 @@ CHANGE_ADDED = "added" CHANGE_UPDATED = "updated" CHANGE_REMOVED = "removed" +_ItemT = TypeVar("_ItemT") +_StoreT = TypeVar("_StoreT", bound="SerializedStorageCollection") +_StorageCollectionT = TypeVar("_StorageCollectionT", bound="StorageCollection") -@dataclass + +@dataclass(slots=True) class CollectionChangeSet: """Class to represent a change set. @@ -120,41 +125,42 @@ class CollectionEntity(Entity): """Handle updated configuration.""" -class ObservableCollection(ABC): +class ObservableCollection(ABC, Generic[_ItemT]): """Base collection type that can be observed.""" - def __init__( - self, logger: logging.Logger, id_manager: IDManager | None = None - ) -> None: + def __init__(self, id_manager: IDManager | None) -> None: """Initialize the base collection.""" - self.logger = logger self.id_manager = id_manager or IDManager() - self.data: dict[str, dict] = {} + self.data: dict[str, _ItemT] = {} self.listeners: list[ChangeListener] = [] self.change_set_listeners: list[ChangeSetListener] = [] self.id_manager.add_collection(self.data) @callback - def async_items(self) -> list[dict]: + def async_items(self) -> list[_ItemT]: """Return list of items in collection.""" return list(self.data.values()) @callback - def async_add_listener(self, listener: ChangeListener) -> None: + def async_add_listener(self, listener: ChangeListener) -> Callable[[], None]: """Add a listener. Will be called with (change_type, item_id, updated_config). """ self.listeners.append(listener) + return lambda: self.listeners.remove(listener) @callback - def async_add_change_set_listener(self, listener: ChangeSetListener) -> None: + def async_add_change_set_listener( + self, listener: ChangeSetListener + ) -> Callable[[], None]: """Add a listener for a full change set. Will be called with [(change_type, item_id, updated_config), ...] """ self.change_set_listeners.append(listener) + return lambda: self.change_set_listeners.remove(listener) async def notify_changes(self, change_sets: Iterable[CollectionChangeSet]) -> None: """Notify listeners of a change.""" @@ -171,9 +177,18 @@ class ObservableCollection(ABC): ) -class YamlCollection(ObservableCollection): +class YamlCollection(ObservableCollection[dict]): """Offer a collection based on static data.""" + def __init__( + self, + logger: logging.Logger, + id_manager: IDManager | None = None, + ) -> None: + """Initialize the storage collection.""" + super().__init__(id_manager) + self.logger = logger + @staticmethod def create_entity( entity_class: type[CollectionEntity], config: ConfigType @@ -211,17 +226,22 @@ class YamlCollection(ObservableCollection): await self.notify_changes(change_sets) -class StorageCollection(ObservableCollection, ABC): +class SerializedStorageCollection(TypedDict): + """Serialized storage collection.""" + + items: list[dict[str, Any]] + + +class StorageCollection(ObservableCollection[_ItemT], Generic[_ItemT, _StoreT]): """Offer a CRUD interface on top of JSON storage.""" def __init__( self, - store: Store, - logger: logging.Logger, + store: Store[_StoreT], id_manager: IDManager | None = None, ) -> None: """Initialize the storage collection.""" - super().__init__(logger, id_manager) + super().__init__(id_manager) self.store = store @staticmethod @@ -236,19 +256,17 @@ class StorageCollection(ObservableCollection, ABC): """Home Assistant object.""" return self.store.hass - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> _StoreT | None: """Load the data.""" - return cast(dict | None, await self.store.async_load()) + return await self.store.async_load() async def async_load(self) -> None: """Load the storage Manager.""" - raw_storage = await self._async_load_data() - - if raw_storage is None: - raw_storage = {"items": []} + if not (raw_storage := await self._async_load_data()): + return for item in raw_storage["items"]: - self.data[item[CONF_ID]] = item + self.data[item[CONF_ID]] = self._deserialize_item(item) await self.notify_changes( [ @@ -267,21 +285,35 @@ class StorageCollection(ObservableCollection, ABC): """Suggest an ID based on the config.""" @abstractmethod - async def _update_data(self, data: dict, update_data: dict) -> dict: - """Return a new updated data object.""" + async def _update_data(self, item: _ItemT, update_data: dict) -> _ItemT: + """Return a new updated item.""" - async def async_create_item(self, data: dict) -> dict: + @abstractmethod + def _create_item(self, item_id: str, data: dict) -> _ItemT: + """Create an item from validated config.""" + + @abstractmethod + def _deserialize_item(self, data: dict) -> _ItemT: + """Create an item from its serialized representation.""" + + @abstractmethod + def _serialize_item(self, item_id: str, item: _ItemT) -> dict: + """Return the serialized representation of an item for storing. + + The serialized representation must include the item_id in the "id" key. + """ + + async def async_create_item(self, data: dict) -> _ItemT: """Create a new item.""" - item = await self._process_create_data(data) - item[CONF_ID] = self.id_manager.generate_id(self._get_suggested_id(item)) - self.data[item[CONF_ID]] = item + validated_data = await self._process_create_data(data) + item_id = self.id_manager.generate_id(self._get_suggested_id(validated_data)) + item = self._create_item(item_id, validated_data) + self.data[item_id] = item self._async_schedule_save() - await self.notify_changes( - [CollectionChangeSet(CHANGE_ADDED, item[CONF_ID], item)] - ) + await self.notify_changes([CollectionChangeSet(CHANGE_ADDED, item_id, item)]) return item - async def async_update_item(self, item_id: str, updates: dict) -> dict: + async def async_update_item(self, item_id: str, updates: dict) -> _ItemT: """Update item.""" if item_id not in self.data: raise ItemNotFound(item_id) @@ -314,13 +346,44 @@ class StorageCollection(ObservableCollection, ABC): @callback def _async_schedule_save(self) -> None: - """Schedule saving the area registry.""" + """Schedule saving the collection.""" self.store.async_delay_save(self._data_to_save, SAVE_DELAY) @callback - def _data_to_save(self) -> dict: - """Return data of area registry to store in a file.""" - return {"items": list(self.data.values())} + def _base_data_to_save(self) -> SerializedStorageCollection: + """Return JSON-compatible data for storing to file.""" + return { + "items": [ + self._serialize_item(item_id, item) + for item_id, item in self.data.items() + ] + } + + @abstractmethod + @callback + def _data_to_save(self) -> _StoreT: + """Return JSON-compatible date for storing to file.""" + + +class DictStorageCollection(StorageCollection[dict, SerializedStorageCollection]): + """A specialized StorageCollection where the items are untyped dicts.""" + + def _create_item(self, item_id: str, data: dict) -> dict: + """Create an item from its validated, serialized representation.""" + return {CONF_ID: item_id} | data + + def _deserialize_item(self, data: dict) -> dict: + """Create an item from its validated, serialized representation.""" + return data + + def _serialize_item(self, item_id: str, item: dict) -> dict: + """Return the serialized representation of an item for storing.""" + return item + + @callback + def _data_to_save(self) -> SerializedStorageCollection: + """Return JSON-compatible date for storing to file.""" + return self._base_data_to_save() class IDLessCollection(YamlCollection): @@ -410,9 +473,8 @@ def sync_entity_lifecycle( # Create a new bucket every time we have a different change type # to ensure operations happen in order. We only group # the same change type. - for _, grouped in groupby( - change_sets, lambda change_set: change_set.change_type - ): + groupby_key = attrgetter("change_type") + for _, grouped in groupby(change_sets, groupby_key): new_entities = [ entity for entity in await asyncio.gather( @@ -429,12 +491,12 @@ def sync_entity_lifecycle( collection.async_add_change_set_listener(_collection_changed) -class StorageCollectionWebsocket: +class StorageCollectionWebsocket(Generic[_StorageCollectionT]): """Class to expose storage collection management over websocket.""" def __init__( self, - storage_collection: StorageCollection, + storage_collection: _StorageCollectionT, api_prefix: str, model_name: str, create_schema: dict, @@ -517,6 +579,7 @@ class StorageCollectionWebsocket: ), ) + @callback def ws_list_item( self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: @@ -587,3 +650,7 @@ class StorageCollectionWebsocket: ) connection.send_result(msg["id"]) + + +class DictStorageCollectionWebsocket(StorageCollectionWebsocket[DictStorageCollection]): + """Class to expose storage collection management over websocket.""" diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index dd536956a8..2df8965de3 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -44,6 +44,7 @@ class Debouncer(Generic[_R_co]): function, f"debouncer cooldown={cooldown}, immediate={immediate}" ) ) + self._shutdown_requested = False @property def function(self) -> Callable[[], _R_co] | None: @@ -62,6 +63,11 @@ class Debouncer(Generic[_R_co]): async def async_call(self) -> None: """Call the function.""" + if self._shutdown_requested: + self.logger.warning( + "Debouncer call ignored as shutdown has been requested." + ) + return assert self._job is not None if self._timer_task: @@ -115,6 +121,11 @@ class Debouncer(Generic[_R_co]): # Schedule a new timer to prevent new runs during cooldown self._schedule_timer() + async def async_shutdown(self) -> None: + """Cancel any scheduled call, and prevent new runs.""" + self._shutdown_requested = True + self.async_cancel() + @callback def async_cancel(self) -> None: """Cancel any scheduled call.""" @@ -137,4 +148,7 @@ class Debouncer(Generic[_R_co]): @callback def _schedule_timer(self) -> None: """Schedule a timer.""" - self._timer_task = self.hass.loop.call_later(self.cooldown, self._on_debounce) + if not self._shutdown_requested: + self._timer_task = self.hass.loop.call_later( + self.cooldown, self._on_debounce + ) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index b72a187865..29e6463972 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -13,7 +13,6 @@ from homeassistant.backports.enum import StrEnum from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, RequiredParameterMissing -from homeassistant.loader import bind_hass from homeassistant.util.json import format_unserializable_data import homeassistant.util.uuid as uuid_util @@ -749,19 +748,6 @@ async def async_load(hass: HomeAssistant) -> None: await hass.data[DATA_REGISTRY].async_load() -@bind_hass -async def async_get_registry(hass: HomeAssistant) -> DeviceRegistry: - """Get device registry. - - This is deprecated and will be removed in the future. Use async_get instead. - """ - report( - "uses deprecated `async_get_registry` to access device registry, use async_get" - " instead" - ) - return async_get(hass) - - @callback def async_entries_for_area(registry: DeviceRegistry, area_id: str) -> list[DeviceEntry]: """Return entries that match an area.""" diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 824b1de701..7045966c52 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -46,16 +46,15 @@ def async_listen( """ job = core.HassJob(callback, f"discovery listener {service}") - async def discovery_event_listener(discovered: DiscoveryDict) -> None: + @core.callback + def _async_discovery_event_listener(discovered: DiscoveryDict) -> None: """Listen for discovery events.""" - task = hass.async_run_hass_job( - job, discovered["service"], discovered["discovered"] - ) - if task: - await task + hass.async_run_hass_job(job, discovered["service"], discovered["discovered"]) async_dispatcher_connect( - hass, SIGNAL_PLATFORM_DISCOVERED.format(service), discovery_event_listener + hass, + SIGNAL_PLATFORM_DISCOVERED.format(service), + _async_discovery_event_listener, ) @@ -68,7 +67,10 @@ def discover( hass_config: ConfigType, ) -> None: """Fire discovery event. Can ensure a component is loaded.""" - hass.add_job(async_discover(hass, service, discovered, component, hass_config)) + hass.create_task( + async_discover(hass, service, discovered, component, hass_config), + f"discover {service} {component} {discovered}", + ) @bind_hass @@ -105,17 +107,17 @@ def async_listen_platform( service = EVENT_LOAD_PLATFORM.format(component) job = core.HassJob(callback, f"platform loaded {component}") - async def discovery_platform_listener(discovered: DiscoveryDict) -> None: + @core.callback + def _async_discovery_platform_listener(discovered: DiscoveryDict) -> None: """Listen for platform discovery events.""" if not (platform := discovered["platform"]): return - - task = hass.async_run_hass_job(job, platform, discovered.get("discovered")) - if task: - await task + hass.async_run_hass_job(job, platform, discovered.get("discovered")) return async_dispatcher_connect( - hass, SIGNAL_PLATFORM_DISCOVERED.format(service), discovery_platform_listener + hass, + SIGNAL_PLATFORM_DISCOVERED.format(service), + _async_discovery_platform_listener, ) @@ -128,8 +130,9 @@ def load_platform( hass_config: ConfigType, ) -> None: """Load a component and platform dynamically.""" - hass.add_job( - async_load_platform(hass, component, platform, discovered, hass_config) + hass.create_task( + async_load_platform(hass, component, platform, discovered, hass_config), + f"discovery load_platform {component} {platform}", ) diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index bd5ee4942d..586824b449 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -11,7 +11,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.async_ import gather_with_concurrency FLOW_INIT_LIMIT = 2 -DISCOVERY_FLOW_DISPATCHER = "discovery_flow_disptacher" +DISCOVERY_FLOW_DISPATCHER = "discovery_flow_dispatcher" @bind_hass diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9d9e685d6a..9dbd5d4ad6 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -205,7 +205,7 @@ class EntityPlatformState(Enum): REMOVED = auto() -@dataclass +@dataclass(slots=True) class EntityDescription: """A class that describes Home Assistant entities.""" @@ -249,6 +249,10 @@ class Entity(ABC): # If we reported this entity is updated while disabled _disabled_reported = False + # If we reported this entity is using async_update_ha_state, while + # it should be using async_write_ha_state. + _async_update_ha_state_reported = False + # Protect for multiple updates _update_staged = False @@ -551,6 +555,19 @@ class Entity(ABC): except Exception: # pylint: disable=broad-except _LOGGER.exception("Update for %s fails", self.entity_id) return + elif not self._async_update_ha_state_reported: + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "Entity %s (%s) is using self.async_update_ha_state(), without" + " enabling force_update. Instead it should use" + " self.async_write_ha_state(), please %s" + ), + self.entity_id, + type(self), + report_issue, + ) + self._async_update_ha_state_reported = True self._async_write_ha_state() @@ -698,7 +715,13 @@ class Entity(ABC): If state is changed more than once before the ha state change task has been executed, the intermediate state transitions will be missed. """ - self.hass.add_job(self.async_update_ha_state(force_refresh)) + if force_refresh: + self.hass.create_task( + self.async_update_ha_state(force_refresh), + f"Entity {self.entity_id} schedule update ha state", + ) + else: + self.hass.loop.call_soon_threadsafe(self.async_write_ha_state) @callback def async_schedule_update_ha_state(self, force_refresh: bool = False) -> None: @@ -720,6 +743,15 @@ class Entity(ABC): else: self.async_write_ha_state() + @callback + def _async_slow_update_warning(self) -> None: + """Log a warning if update is taking too long.""" + _LOGGER.warning( + "Update of %s is taking over %s seconds", + self.entity_id, + SLOW_UPDATE_WARNING, + ) + async def async_device_update(self, warning: bool = True) -> None: """Process 'update' or 'async_update' from entity. @@ -727,42 +759,33 @@ class Entity(ABC): """ if self._update_staged: return + + hass = self.hass + assert hass is not None + + if hasattr(self, "async_update"): + coro: asyncio.Future[None] = self.async_update() + elif hasattr(self, "update"): + coro = hass.async_add_executor_job(self.update) + else: + return + self._update_staged = True # Process update sequential if self.parallel_updates: await self.parallel_updates.acquire() - try: - task: asyncio.Future[None] - if hasattr(self, "async_update"): - task = self.hass.async_create_task( - self.async_update(), f"Entity async update {self.entity_id}" - ) - elif hasattr(self, "update"): - task = self.hass.async_add_executor_job(self.update) - else: - return - - if not warning: - await task - return - - finished, _ = await asyncio.wait([task], timeout=SLOW_UPDATE_WARNING) - - for done in finished: - if exc := done.exception(): - raise exc - return - - _LOGGER.warning( - "Update of %s is taking over %s seconds", - self.entity_id, - SLOW_UPDATE_WARNING, + if warning: + update_warn = hass.loop.call_later( + SLOW_UPDATE_WARNING, self._async_slow_update_warning ) - await task + try: + await coro finally: self._update_staged = False + if warning: + update_warn.cancel() if self.parallel_updates: self.parallel_updates.release() @@ -945,25 +968,6 @@ class Entity(ABC): self.entity_id = self.registry_entry.entity_id await self.platform.async_add_entities([self]) - def __eq__(self, other: Any) -> bool: - """Return the comparison.""" - if not isinstance(other, self.__class__): - return False - - # Can only decide equality if both have a unique id - if self.unique_id is None or other.unique_id is None: - return False - - # Ensure they belong to the same platform - if self.platform is not None or other.platform is not None: - if self.platform is None or other.platform is None: - return False - - if self.platform.platform != other.platform.platform: - return False - - return self.unique_id == other.unique_id - def __repr__(self) -> str: """Return the representation.""" return f"" @@ -997,7 +1001,7 @@ class Entity(ABC): return report_issue -@dataclass +@dataclass(slots=True) class ToggleEntityDescription(EntityDescription): """A class that describes toggle entities.""" diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 0c43dddec6..dc101a10b0 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -109,12 +109,22 @@ class EntityComponent(Generic[_EntityT]): return entity_obj # type: ignore[return-value] return None + def register_shutdown(self) -> None: + """Register shutdown on Home Assistant STOP event. + + Note: this is only required if the integration never calls + `setup` or `async_setup`. + """ + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown) + def setup(self, config: ConfigType) -> None: """Set up a full entity component. This doesn't block the executor to protect from deadlocks. """ - self.hass.add_job(self.async_setup(config)) + self.hass.create_task( + self.async_setup(config), f"EntityComponent setup {self.domain}" + ) async def async_setup(self, config: ConfigType) -> None: """Set up a full entity component. @@ -124,7 +134,7 @@ class EntityComponent(Generic[_EntityT]): This method must be run in the event loop. """ - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown) + self.register_shutdown() self.config = config diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 6687af6a27..0f93dca693 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -479,6 +479,7 @@ class EntityPlatform: self.hass, self._update_entity_states, self.scan_interval, + name=f"EntityPlatform poll {self.domain}.{self.platform_name}", ) def _entity_id_already_exists(self, entity_id: str) -> tuple[bool, bool]: diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 4c192d916c..3ed0ff8d47 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -12,6 +12,7 @@ from __future__ import annotations from collections import UserDict from collections.abc import Callable, Iterable, Mapping, ValuesView import logging +from types import MappingProxyType from typing import TYPE_CHECKING, Any, TypeVar, cast import attr @@ -41,13 +42,11 @@ from homeassistant.core import ( valid_entity_id, ) from homeassistant.exceptions import MaxLengthExceeded -from homeassistant.loader import bind_hass from homeassistant.util import slugify, uuid as uuid_util from homeassistant.util.json import format_unserializable_data from . import device_registry as dr, storage from .device_registry import EVENT_DEVICE_REGISTRY_UPDATED -from .frame import report from .json import JSON_DUMP, find_paths_unserializable_data from .typing import UNDEFINED, UndefinedType @@ -111,6 +110,29 @@ DISLAY_DICT_OPTIONAL = ( ) +class _EntityOptions(UserDict[str, MappingProxyType]): + """Container for entity options.""" + + def __init__(self, data: Mapping[str, Mapping] | None) -> None: + """Initialize.""" + super().__init__() + if data is None: + return + self.data = {key: MappingProxyType(val) for key, val in data.items()} + + def __setitem__(self, key: str, entry: Mapping) -> None: + """Add an item.""" + raise NotImplementedError + + def __delitem__(self, key: str) -> None: + """Remove an item.""" + raise NotImplementedError + + def as_dict(self) -> dict[str, dict]: + """Return dictionary version.""" + return {key: dict(val) for key, val in self.data.items()} + + @attr.s(slots=True, frozen=True) class RegistryEntry: """Entity Registry Entry.""" @@ -132,10 +154,7 @@ class RegistryEntry: id: str = attr.ib(factory=uuid_util.random_uuid_hex) has_entity_name: bool = attr.ib(default=False) name: str | None = attr.ib(default=None) - options: EntityOptionsType = attr.ib( - default=None, - converter=attr.converters.default_if_none(factory=dict), # type: ignore[misc] - ) + options: _EntityOptions = attr.ib(default=None, converter=_EntityOptions) # As set by integration original_device_class: str | None = attr.ib(default=None) original_icon: str | None = attr.ib(default=None) @@ -288,6 +307,26 @@ class RegistryEntry: hass.states.async_set(self.entity_id, STATE_UNAVAILABLE, attrs) + def async_friendly_name(self, hass: HomeAssistant) -> str | None: + """Return the friendly name. + + If self.name is not None, this returns self.name + If has_entity_name is False, self.original_name + If has_entity_name is True, this returns device.name + self.original_name + """ + if not self.has_entity_name or self.name is not None: + return self.name or self.original_name + + device_registry = dr.async_get(hass) + if not (device_id := self.device_id) or not ( + device_entry := device_registry.async_get(device_id) + ): + return self.original_name + + if not (original_name := self.original_name): + return device_entry.name_by_user or device_entry.name + return f"{device_entry.name_by_user or device_entry.name} {original_name}" + class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): """Store entity registry data.""" @@ -930,7 +969,7 @@ class EntityRegistry: If the domain options are set to None, they will be removed. """ old = self.entities[entity_id] - new_options = { + new_options: dict[str, Mapping] = { key: value for key, value in old.options.items() if key != domain } if options is not None: @@ -1010,7 +1049,7 @@ class EntityRegistry: "id": entry.id, "has_entity_name": entry.has_entity_name, "name": entry.name, - "options": entry.options, + "options": entry.options.as_dict(), "original_device_class": entry.original_device_class, "original_icon": entry.original_icon, "original_name": entry.original_name, @@ -1056,19 +1095,6 @@ async def async_load(hass: HomeAssistant) -> None: await hass.data[DATA_REGISTRY].async_load() -@bind_hass -async def async_get_registry(hass: HomeAssistant) -> EntityRegistry: - """Get entity registry. - - This is deprecated and will be removed in the future. Use async_get instead. - """ - report( - "uses deprecated `async_get_registry` to access entity registry, use async_get" - " instead" - ) - return async_get(hass) - - @callback def async_entries_for_device( registry: EntityRegistry, device_id: str, include_disabled_entities: bool = False diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index d8b827bd24..057e8f0955 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -33,26 +33,20 @@ class EntityFilter: self._exclude_e = set(config[CONF_EXCLUDE_ENTITIES]) self._include_d = set(config[CONF_INCLUDE_DOMAINS]) self._exclude_d = set(config[CONF_EXCLUDE_DOMAINS]) - self._include_eg = _convert_globs_to_pattern_list( - config[CONF_INCLUDE_ENTITY_GLOBS] - ) - self._exclude_eg = _convert_globs_to_pattern_list( - config[CONF_EXCLUDE_ENTITY_GLOBS] - ) + self._include_eg = _convert_globs_to_pattern(config[CONF_INCLUDE_ENTITY_GLOBS]) + self._exclude_eg = _convert_globs_to_pattern(config[CONF_EXCLUDE_ENTITY_GLOBS]) self._filter: Callable[[str], bool] | None = None def explicitly_included(self, entity_id: str) -> bool: """Check if an entity is explicitly included.""" return entity_id in self._include_e or ( - bool(self._include_eg) - and _test_against_patterns(self._include_eg, entity_id) + bool(self._include_eg and self._include_eg.match(entity_id)) ) def explicitly_excluded(self, entity_id: str) -> bool: """Check if an entity is explicitly excluded.""" return entity_id in self._exclude_e or ( - bool(self._exclude_eg) - and _test_against_patterns(self._exclude_eg, entity_id) + bool(self._exclude_eg and self._exclude_eg.match(entity_id)) ) def __call__(self, entity_id: str) -> bool: @@ -140,19 +134,22 @@ INCLUDE_EXCLUDE_FILTER_SCHEMA = vol.All( ) -def _glob_to_re(glob: str) -> re.Pattern[str]: - """Translate and compile glob string into pattern.""" - return re.compile(fnmatch.translate(glob)) - - -def _test_against_patterns(patterns: list[re.Pattern[str]], entity_id: str) -> bool: - """Test entity against list of patterns, true if any match.""" - return any(pattern.match(entity_id) for pattern in patterns) - - -def _convert_globs_to_pattern_list(globs: list[str] | None) -> list[re.Pattern[str]]: +def _convert_globs_to_pattern(globs: list[str] | None) -> re.Pattern[str] | None: """Convert a list of globs to a re pattern list.""" - return list(map(_glob_to_re, set(globs or []))) + if globs is None: + return None + + translated_patterns: list[str] = [] + for glob in set(globs): + if pattern := fnmatch.translate(glob): + translated_patterns.append(pattern) + + if not translated_patterns: + return None + + inner = "|".join(translated_patterns) + combined = f"(?:{inner})" + return re.compile(combined) def generate_filter( @@ -169,8 +166,8 @@ def generate_filter( set(include_entities), set(exclude_domains), set(exclude_entities), - _convert_globs_to_pattern_list(include_entity_globs), - _convert_globs_to_pattern_list(exclude_entity_globs), + _convert_globs_to_pattern(include_entity_globs), + _convert_globs_to_pattern(exclude_entity_globs), ) @@ -179,8 +176,8 @@ def _generate_filter_from_sets_and_pattern_lists( include_e: set[str], exclude_d: set[str], exclude_e: set[str], - include_eg: list[re.Pattern[str]], - exclude_eg: list[re.Pattern[str]], + include_eg: re.Pattern[str] | None, + exclude_eg: re.Pattern[str] | None, ) -> Callable[[str], bool]: """Generate a filter from pre-comuted sets and pattern lists.""" have_exclude = bool(exclude_e or exclude_d or exclude_eg) @@ -191,7 +188,7 @@ def _generate_filter_from_sets_and_pattern_lists( return ( entity_id in include_e or domain in include_d - or (bool(include_eg) and _test_against_patterns(include_eg, entity_id)) + or (bool(include_eg and include_eg.match(entity_id))) ) def entity_excluded(domain: str, entity_id: str) -> bool: @@ -199,7 +196,7 @@ def _generate_filter_from_sets_and_pattern_lists( return ( entity_id in exclude_e or domain in exclude_d - or (bool(exclude_eg) and _test_against_patterns(exclude_eg, entity_id)) + or (bool(exclude_eg and exclude_eg.match(entity_id))) ) # Case 1 - No filter @@ -249,12 +246,10 @@ def _generate_filter_from_sets_and_pattern_lists( return entity_id in include_e or ( entity_id not in exclude_e and ( - (include_eg and _test_against_patterns(include_eg, entity_id)) + bool(include_eg and include_eg.match(entity_id)) or ( split_entity_id(entity_id)[0] in include_d - and not ( - exclude_eg and _test_against_patterns(exclude_eg, entity_id) - ) + and not (exclude_eg and exclude_eg.match(entity_id)) ) ) ) @@ -272,9 +267,7 @@ def _generate_filter_from_sets_and_pattern_lists( def entity_filter_4b(entity_id: str) -> bool: """Return filter function for case 4b.""" domain = split_entity_id(entity_id)[0] - if domain in exclude_d or ( - exclude_eg and _test_against_patterns(exclude_eg, entity_id) - ): + if domain in exclude_d or bool(exclude_eg and exclude_eg.match(entity_id)): return entity_id in include_e return entity_id not in exclude_e diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 3ac715426e..0a51d6660a 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -66,7 +66,7 @@ RANDOM_MICROSECOND_MAX = 500000 _P = ParamSpec("_P") -@dataclass +@dataclass(slots=True) class TrackStates: """Class for keeping track of states being tracked. @@ -80,7 +80,7 @@ class TrackStates: domains: set[str] -@dataclass +@dataclass(slots=True) class TrackTemplate: """Class for keeping track of a template with variables. @@ -94,7 +94,7 @@ class TrackTemplate: rate_limit: timedelta | None = None -@dataclass +@dataclass(slots=True) class TrackTemplateResult: """Class for result of template tracking. @@ -1295,7 +1295,12 @@ def async_track_point_in_time( """Convert passed in UTC now to local now.""" hass.async_run_hass_job(job, dt_util.as_local(utc_now)) - return async_track_point_in_utc_time(hass, utc_converter, point_in_time) + track_job = HassJob( + utc_converter, + name=f"{job.name} UTC converter", + cancel_on_shutdown=job.cancel_on_shutdown, + ) + return async_track_point_in_utc_time(hass, track_job, point_in_time) track_point_in_time = threaded_listener_factory(async_track_point_in_time) @@ -1397,12 +1402,17 @@ def async_track_time_interval( hass: HomeAssistant, action: Callable[[datetime], Coroutine[Any, Any, None] | None], interval: timedelta, + *, + name: str | None = None, + cancel_on_shutdown: bool | None = None, ) -> CALLBACK_TYPE: """Add a listener that fires repetitively at every timedelta interval.""" remove: CALLBACK_TYPE interval_listener_job: HassJob[[datetime], None] - job = HassJob(action, f"track time interval {interval}") + job = HassJob( + action, f"track time interval {interval}", cancel_on_shutdown=cancel_on_shutdown + ) def next_interval() -> datetime: """Return the next interval.""" @@ -1419,8 +1429,13 @@ def async_track_time_interval( ) hass.async_run_hass_job(job, now) + if name: + job_name = f"{name}: track time interval {interval} {action}" + else: + job_name = f"track time interval {interval} {action}" + interval_listener_job = HassJob( - interval_listener, f"track time interval listener {interval}" + interval_listener, job_name, cancel_on_shutdown=cancel_on_shutdown ) remove = async_track_point_in_utc_time(hass, interval_listener_job, next_interval()) @@ -1543,7 +1558,7 @@ def async_track_utc_time_change( """Add a listener that will fire if time matches a pattern.""" # We do not have to wrap the function with time pattern matching logic # if no pattern given - if all(val is None for val in (hour, minute, second)): + if all(val is None or val == "*" for val in (hour, minute, second)): # Previously this relied on EVENT_TIME_FIRED # which meant it would not fire right away because # the caller would always be misaligned with the call @@ -1568,23 +1583,30 @@ def async_track_utc_time_change( ).replace(microsecond=microsecond) time_listener: CALLBACK_TYPE | None = None + pattern_time_change_listener_job: HassJob[[datetime], Any] | None = None @callback def pattern_time_change_listener(_: datetime) -> None: """Listen for matching time_changed events.""" nonlocal time_listener + nonlocal pattern_time_change_listener_job now = time_tracker_utcnow() hass.async_run_hass_job(job, dt_util.as_local(now) if local else now) + assert pattern_time_change_listener_job is not None time_listener = async_track_point_in_utc_time( hass, - pattern_time_change_listener, + pattern_time_change_listener_job, calculate_next(now + timedelta(seconds=1)), ) + pattern_time_change_listener_job = HassJob( + pattern_time_change_listener, + "time change listener {hour}:{minute}:{second} {action}", + ) time_listener = async_track_point_in_utc_time( - hass, pattern_time_change_listener, calculate_next(dt_util.utcnow()) + hass, pattern_time_change_listener_job, calculate_next(dt_util.utcnow()) ) @callback diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index 1e9d2e776c..beb084d8c1 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -11,7 +11,11 @@ from typing_extensions import Self from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.loader import bind_hass -from homeassistant.util import ssl as ssl_util +from homeassistant.util.ssl import ( + SSLCipherList, + client_context, + create_no_verify_ssl_context, +) from .frame import warn_use @@ -56,6 +60,7 @@ def create_async_httpx_client( hass: HomeAssistant, verify_ssl: bool = True, auto_cleanup: bool = True, + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, **kwargs: Any, ) -> httpx.AsyncClient: """Create a new httpx.AsyncClient with kwargs, i.e. for cookies. @@ -65,8 +70,13 @@ def create_async_httpx_client( This method must be run in the event loop. """ + ssl_context = ( + client_context(ssl_cipher_list) + if verify_ssl + else create_no_verify_ssl_context(ssl_cipher_list) + ) client = HassHttpXAsyncClient( - verify=ssl_util.get_default_context() if verify_ssl else False, + verify=ssl_context, headers={USER_AGENT: SERVER_SOFTWARE}, **kwargs, ) diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index ef05dae518..ddaede4496 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) DATA_INTEGRATION_PLATFORMS = "integration_platforms" -@dataclass(frozen=True) +@dataclass(slots=True, frozen=True) class IntegrationPlatform: """An integration platform.""" diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 4e7dcc5a5a..7a4ca862ee 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -568,7 +568,7 @@ class IntentResponseTargetType(str, Enum): CUSTOM = "custom" -@dataclass +@dataclass(slots=True) class IntentResponseTarget: """Target of the intent response.""" diff --git a/homeassistant/helpers/issue_registry.py b/homeassistant/helpers/issue_registry.py index 345ec099d3..afe2d98ed0 100644 --- a/homeassistant/helpers/issue_registry.py +++ b/homeassistant/helpers/issue_registry.py @@ -32,7 +32,7 @@ class IssueSeverity(StrEnum): WARNING = "warning" -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(slots=True, frozen=True) class IssueEntry: """Issue Registry Entry.""" diff --git a/homeassistant/helpers/recorder.py b/homeassistant/helpers/recorder.py index 5545aa09f0..74ebbe5c67 100644 --- a/homeassistant/helpers/recorder.py +++ b/homeassistant/helpers/recorder.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant, callback DOMAIN = "recorder" -@dataclass +@dataclass(slots=True) class RecorderData: """Recorder data stored in hass.data.""" diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 0263bd2868..d31c12d0fd 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -216,7 +216,10 @@ class RestoreStateData: # Dump states periodically cancel_interval = async_track_time_interval( - self.hass, _async_dump_states, STATE_DUMP_INTERVAL + self.hass, + _async_dump_states, + STATE_DUMP_INTERVAL, + name="RestoreStateData dump states", ) async def _async_dump_states_at_stop(*_: Any) -> None: diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 5101e5c69a..653594f280 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -27,7 +27,7 @@ class SchemaFlowStep: """Define a config or options flow step.""" -@dataclass +@dataclass(slots=True) class SchemaFlowFormStep(SchemaFlowStep): """Define a config or options flow form step.""" @@ -79,7 +79,7 @@ class SchemaFlowFormStep(SchemaFlowStep): """ -@dataclass +@dataclass(slots=True) class SchemaFlowMenuStep(SchemaFlowStep): """Define a config or options flow menu step.""" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 33c677454b..14cf6a85a2 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -199,7 +199,7 @@ class ServiceTargetSelector: return bool(self.entity_ids or self.device_ids or self.area_ids) -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class SelectedEntities: """Class to hold the selected entities.""" diff --git a/homeassistant/helpers/service_info/mqtt.py b/homeassistant/helpers/service_info/mqtt.py index 3626f9b575..906072a2d4 100644 --- a/homeassistant/helpers/service_info/mqtt.py +++ b/homeassistant/helpers/service_info/mqtt.py @@ -7,7 +7,7 @@ from homeassistant.data_entry_flow import BaseServiceInfo ReceivePayloadType = str | bytes -@dataclass +@dataclass(slots=True) class MqttServiceInfo(BaseServiceInfo): """Prepared info from mqtt entries.""" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 8f68c7af37..5cc2c6aa80 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -5,7 +5,7 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Callable, Collection, Generator, Iterable +from collections.abc import Callable, Collection, Generator, Iterable, MutableMapping from contextlib import contextmanager, suppress from contextvars import ContextVar from datetime import datetime, timedelta @@ -13,7 +13,7 @@ from functools import cache, lru_cache, partial, wraps import json import logging import math -from operator import attrgetter, contains +from operator import contains import pathlib import random import re @@ -41,6 +41,8 @@ from jinja2 import pass_context, pass_environment, pass_eval_context from jinja2.runtime import AsyncLoopContext, LoopContext from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace +from lru import LRU # pylint: disable=no-name-in-module +import orjson import voluptuous as vol from homeassistant.const import ( @@ -49,6 +51,9 @@ from homeassistant.const import ( ATTR_LONGITUDE, ATTR_PERSONS, ATTR_UNIT_OF_MEASUREMENT, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, + STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfLength, ) @@ -120,10 +125,80 @@ template_cv: ContextVar[tuple[str, str] | None] = ContextVar( "template_cv", default=None ) +# +# CACHED_TEMPLATE_STATES is a rough estimate of the number of entities +# on a typical system. It is used as the initial size of the LRU cache +# for TemplateState objects. +# +# If the cache is too small we will end up creating and destroying +# TemplateState objects too often which will cause a lot of GC activity +# and slow down the system. For systems with a lot of entities and +# templates, this can reach 100000s of object creations and destructions +# per minute. +# +# Since entity counts may grow over time, we will increase +# the size if the number of entities grows via _async_adjust_lru_sizes +# at the start of the system and every 10 minutes if needed. +# CACHED_TEMPLATE_STATES = 512 EVAL_CACHE_SIZE = 512 -MAX_CUSTOM_JINJA_SIZE = 5 * 1024 * 1024 +MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 + +CACHED_TEMPLATE_LRU: MutableMapping[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) +CACHED_TEMPLATE_NO_COLLECT_LRU: MutableMapping[State, TemplateState] = LRU( + CACHED_TEMPLATE_STATES +) +ENTITY_COUNT_GROWTH_FACTOR = 1.2 + +ORJSON_PASSTHROUGH_OPTIONS = ( + orjson.OPT_PASSTHROUGH_DATACLASS | orjson.OPT_PASSTHROUGH_DATETIME +) + + +def _template_state_no_collect(hass: HomeAssistant, state: State) -> TemplateState: + """Return a TemplateState for a state without collecting.""" + if template_state := CACHED_TEMPLATE_NO_COLLECT_LRU.get(state): + return template_state + template_state = _create_template_state_no_collect(hass, state) + CACHED_TEMPLATE_NO_COLLECT_LRU[state] = template_state + return template_state + + +def _template_state(hass: HomeAssistant, state: State) -> TemplateState: + """Return a TemplateState for a state that collects.""" + if template_state := CACHED_TEMPLATE_LRU.get(state): + return template_state + template_state = TemplateState(hass, state) + CACHED_TEMPLATE_LRU[state] = template_state + return template_state + + +def async_setup(hass: HomeAssistant) -> bool: + """Set up tracking the template LRUs.""" + + @callback + def _async_adjust_lru_sizes(_: Any) -> None: + """Adjust the lru cache sizes.""" + new_size = int( + round(hass.states.async_entity_ids_count() * ENTITY_COUNT_GROWTH_FACTOR) + ) + for lru in (CACHED_TEMPLATE_LRU, CACHED_TEMPLATE_NO_COLLECT_LRU): + # There is no typing for LRU + current_size = lru.get_size() # type: ignore[attr-defined] + if new_size > current_size: + lru.set_size(new_size) # type: ignore[attr-defined] + + from .event import ( # pylint: disable=import-outside-toplevel + async_track_time_interval, + ) + + cancel = async_track_time_interval( + hass, _async_adjust_lru_sizes, timedelta(minutes=10) + ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_adjust_lru_sizes) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, callback(lambda _: cancel())) + return True @bind_hass @@ -366,6 +441,7 @@ class Template: "_limited", "_strict", "_hash_cache", + "_renders", ) def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: @@ -382,6 +458,7 @@ class Template: self._limited: bool | None = None self._strict: bool | None = None self._hash_cache: int = hash(self.template) + self._renders: int = 0 @property def _env(self) -> TemplateEnvironment: @@ -451,6 +528,8 @@ class Template: If limited is True, the template is not allowed to access any function or filter depending on hass or the state machine. """ + self._renders += 1 + if self.is_static: if not parse_result or self.hass and self.hass.config.legacy_templates: return self.template @@ -526,6 +605,8 @@ class Template: This method must be run in the event loop. """ + self._renders += 1 + if self.is_static: return False @@ -568,6 +649,7 @@ class Template: self, variables: TemplateVarsType = None, strict: bool = False, **kwargs: Any ) -> RenderInfo: """Render the template and collect an entity filter.""" + self._renders += 1 assert self.hass and _RENDER_INFO not in self.hass.data render_info = RenderInfo(self) @@ -617,6 +699,8 @@ class Template: This method must be run in the event loop. """ + self._renders += 1 + if self.is_static: return self.template @@ -680,7 +764,7 @@ class Template: def __repr__(self) -> str: """Representation of Template.""" - return 'Template("' + self.template + '")' + return f"Template" @cache @@ -968,21 +1052,33 @@ class TemplateStateFromEntityId(TemplateStateBase): return f"