diff --git a/.core_files.yaml b/.core_files.yaml index 7bf7a09b36..118346408f 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -20,6 +20,7 @@ base_platforms: &base_platforms - homeassistant/components/camera/** - homeassistant/components/climate/** - homeassistant/components/cover/** + - homeassistant/components/date/** - homeassistant/components/device_tracker/** - homeassistant/components/diagnostics/** - homeassistant/components/fan/** @@ -39,6 +40,7 @@ base_platforms: &base_platforms - homeassistant/components/stt/** - homeassistant/components/switch/** - homeassistant/components/text/** + - homeassistant/components/time/** - homeassistant/components/tts/** - homeassistant/components/update/** - homeassistant/components/vacuum/** @@ -125,6 +127,7 @@ tests: &tests - tests/mock/** - tests/pylint/** - tests/scripts/** + - tests/syrupy.py - tests/test_util/** - tests/testing_config/** - tests/util/** @@ -136,7 +139,6 @@ other: &other requirements: &requirements - .github/workflows/** - homeassistant/package_constraints.txt - - script/pip_check - requirements*.txt - pyproject.toml diff --git a/.coveragerc b/.coveragerc index 957ec8620c..834232c902 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 @@ -249,7 +250,8 @@ omit = homeassistant/components/ecowitt/sensor.py homeassistant/components/eddystone_temperature/sensor.py homeassistant/components/edimax/switch.py - homeassistant/components/edl21/* + homeassistant/components/edl21/__init__.py + homeassistant/components/edl21/sensor.py homeassistant/components/egardia/* homeassistant/components/eight_sleep/__init__.py homeassistant/components/eight_sleep/binary_sensor.py @@ -384,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 @@ -394,7 +399,8 @@ omit = homeassistant/components/fritzbox_callmonitor/__init__.py homeassistant/components/fritzbox_callmonitor/base.py homeassistant/components/fritzbox_callmonitor/sensor.py - homeassistant/components/frontier_silicon/const.py + homeassistant/components/frontier_silicon/__init__.py + homeassistant/components/frontier_silicon/browse_media.py homeassistant/components/frontier_silicon/media_player.py homeassistant/components/futurenow/light.py homeassistant/components/garadget/cover.py @@ -414,6 +420,7 @@ omit = homeassistant/components/goalfeed/* homeassistant/components/goodwe/__init__.py homeassistant/components/goodwe/button.py + homeassistant/components/goodwe/coordinator.py homeassistant/components/goodwe/number.py homeassistant/components/goodwe/select.py homeassistant/components/goodwe/sensor.py @@ -476,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 @@ -515,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 @@ -626,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 @@ -638,6 +637,12 @@ omit = homeassistant/components/linode/* 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 + homeassistant/components/livisi/switch.py homeassistant/components/llamalab_automate/notify.py homeassistant/components/logi_circle/__init__.py homeassistant/components/logi_circle/camera.py @@ -669,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 @@ -770,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 @@ -780,6 +788,7 @@ omit = homeassistant/components/nibe_heatpump/select.py homeassistant/components/nibe_heatpump/sensor.py homeassistant/components/nibe_heatpump/switch.py + homeassistant/components/nibe_heatpump/water_heater.py homeassistant/components/niko_home_control/light.py homeassistant/components/nilu/air_quality.py homeassistant/components/nissan_leaf/* @@ -802,7 +811,10 @@ omit = homeassistant/components/nuki/sensor.py homeassistant/components/nx584/alarm_control_panel.py homeassistant/components/oasa_telematics/sensor.py - homeassistant/components/obihai/* + homeassistant/components/obihai/__init__.py + homeassistant/components/obihai/button.py + homeassistant/components/obihai/connectivity.py + homeassistant/components/obihai/sensor.py homeassistant/components/octoprint/__init__.py homeassistant/components/oem/climate.py homeassistant/components/ohmconnect/sensor.py @@ -822,6 +834,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 @@ -897,7 +910,6 @@ omit = homeassistant/components/ping/binary_sensor.py homeassistant/components/ping/device_tracker.py homeassistant/components/pioneer/media_player.py - homeassistant/components/pjlink/media_player.py homeassistant/components/plaato/__init__.py homeassistant/components/plaato/binary_sensor.py homeassistant/components/plaato/entity.py @@ -930,6 +942,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 @@ -966,21 +979,28 @@ omit = homeassistant/components/rejseplanen/sensor.py homeassistant/components/remember_the_milk/__init__.py homeassistant/components/remote_rpi_gpio/* - homeassistant/components/reolink/__init__.py homeassistant/components/reolink/binary_sensor.py + homeassistant/components/reolink/button.py homeassistant/components/reolink/camera.py homeassistant/components/reolink/entity.py homeassistant/components/reolink/host.py + homeassistant/components/reolink/light.py homeassistant/components/reolink/number.py + homeassistant/components/reolink/select.py + homeassistant/components/reolink/siren.py + homeassistant/components/reolink/switch.py + homeassistant/components/reolink/update.py homeassistant/components/repetier/__init__.py homeassistant/components/repetier/sensor.py 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 @@ -1086,7 +1106,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 @@ -1271,13 +1293,14 @@ omit = homeassistant/components/toon/switch.py homeassistant/components/torque/sensor.py homeassistant/components/totalconnect/__init__.py - homeassistant/components/totalconnect/binary_sensor.py homeassistant/components/touchline/climate.py homeassistant/components/tplink_lte/* homeassistant/components/tplink_omada/__init__.py + homeassistant/components/tplink_omada/controller.py homeassistant/components/tplink_omada/coordinator.py homeassistant/components/tplink_omada/entity.py homeassistant/components/tplink_omada/switch.py + homeassistant/components/tplink_omada/update.py homeassistant/components/traccar/device_tracker.py homeassistant/components/tractive/__init__.py homeassistant/components/tractive/binary_sensor.py @@ -1345,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 @@ -1362,8 +1386,6 @@ omit = homeassistant/components/verisure/sensor.py homeassistant/components/verisure/switch.py homeassistant/components/versasense/* - homeassistant/components/vesync/__init__.py - homeassistant/components/vesync/common.py homeassistant/components/vesync/fan.py homeassistant/components/vesync/light.py homeassistant/components/vesync/sensor.py @@ -1419,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 @@ -1492,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/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1711ab68fd..042eb94b19 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,7 +20,6 @@ "python.linting.enabled": true, "python.linting.pylintEnabled": true, "python.formatting.blackPath": "/usr/local/bin/black", - "python.linting.flake8Path": "/usr/local/bin/flake8", "python.linting.pycodestylePath": "/usr/local/bin/pycodestyle", "python.linting.pydocstylePath": "/usr/local/bin/pydocstyle", "python.linting.mypyPath": "/usr/local/bin/mypy", 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/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 2440cb7ff2..8a4c7d4670 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,6 +1,6 @@ blank_issues_enabled: false contact_links: - - name: Report a bug with the UI, Frontend or Lovelace + - name: Report a bug with the UI, Frontend or Dashboards url: https://github.com/home-assistant/frontend/issues about: This is the issue tracker for our backend. Please report issues with the UI in the frontend repository. - name: Report incorrect or missing information on our website diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 23b355a223..4bc1442d9e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -59,6 +59,7 @@ - [ ] Local tests pass. **Your PR cannot be merged unless tests pass** - [ ] There is no commented out code in this PR. - [ ] I have followed the [development checklist][dev-checklist] +- [ ] I have followed the [perfect PR recommendations][perfect-pr] - [ ] The code has been formatted using Black (`black --fast homeassistant tests`) - [ ] Tests have been added to verify that the new code works. @@ -103,7 +104,8 @@ To help with the load of incoming pull requests: Below, some useful links you could explore: --> -[dev-checklist]: https://developers.home-assistant.io/docs/en/development_checklist.html -[manifest-docs]: https://developers.home-assistant.io/docs/en/creating_integration_manifest.html -[quality-scale]: https://developers.home-assistant.io/docs/en/next/integration_quality_scale_index.html +[dev-checklist]: https://developers.home-assistant.io/docs/development_checklist/ +[manifest-docs]: https://developers.home-assistant.io/docs/creating_integration_manifest/ +[quality-scale]: https://developers.home-assistant.io/docs/integration_quality_scale_index/ [docs-repository]: https://github.com/home-assistant/home-assistant.io +[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 531efe5674..94d4128bc6 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.3.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.3.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.3.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 }} @@ -140,7 +140,7 @@ jobs: shell: bash run: | python3 -m pip install packaging tomli - python3 -m pip install --use-deprecated=legacy-resolver . + python3 -m pip install . version="$(python3 script/version_bump.py nightly)" if [[ "$(ls home_assistant_frontend*.whl)" =~ ^home_assistant_frontend-(.*)-py3-none-any.whl$ ]]; then @@ -174,6 +174,18 @@ jobs: python -m script.gen_requirements_all fi + - name: Adjustments for armhf + if: matrix.arch == 'armhf' + run: | + # 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" requirements_all.txt + sed -i "s|noaa-coops|# noaa-coops|g" requirements_all.txt + sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt + sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt + - name: Download Translations run: python3 -m script.translations download env: @@ -198,7 +210,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2022.11.0 + uses: home-assistant/builder@2023.03.0 with: args: | $BUILD_ARGS \ @@ -232,6 +244,7 @@ jobs: - khadas-vim3 - odroid-c2 - odroid-c4 + - odroid-m1 - odroid-n2 - odroid-xu - qemuarm @@ -248,7 +261,7 @@ jobs: - yellow steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.2 - name: Set build additional args run: | @@ -275,7 +288,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2022.11.0 + uses: home-assistant/builder@2023.03.0 with: args: | $BUILD_ARGS \ @@ -291,7 +304,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.2 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -330,7 +343,7 @@ jobs: - "homeassistant" steps: - name: Checkout the repository - uses: actions/checkout@v3.3.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 d4def28106..583be8f458 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.3 + HA_SHORT_VERSION: 2023.6 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 @@ -76,10 +79,10 @@ jobs: test_groups: ${{ steps.info.outputs.test_groups }} tests_glob: ${{ steps.info.outputs.tests_glob }} tests: ${{ steps.info.outputs.tests }} - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.2 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -195,7 +198,7 @@ jobs: pre-commit: name: Prepare pre-commit base - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 if: | github.event.inputs.pylint-only != 'true' && github.event.inputs.mypy-only != 'true' @@ -203,16 +206,16 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.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 - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.2.5 + uses: actions/cache@v3.3.1 with: path: venv key: >- @@ -227,9 +230,10 @@ jobs: pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.2.5 + uses: actions/cache@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} + lookup-only: true key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} @@ -241,22 +245,22 @@ jobs: lint-black: name: Check black - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - info - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.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 }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -265,7 +269,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -285,73 +289,24 @@ jobs: shopt -s globstar pre-commit run --hook-stage manual black --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure - lint-flake8: - name: Check flake8 - runs-on: ubuntu-20.04 - needs: - - info - - pre-commit - steps: - - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - check-latest: true - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache/restore@v3.2.5 - with: - path: venv - fail-on-cache-miss: true - key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ - needs.info.outputs.pre-commit_cache_key }} - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache/restore@v3.2.5 - with: - path: ${{ env.PRE_COMMIT_CACHE }} - fail-on-cache-miss: true - key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.info.outputs.pre-commit_cache_key }} - - name: Register flake8 problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/flake8.json" - - name: Run flake8 (fully) - if: needs.info.outputs.test_full_suite == 'true' - run: | - . venv/bin/activate - pre-commit run --hook-stage manual flake8 --all-files - - name: Run flake8 (partially) - if: needs.info.outputs.test_full_suite == 'false' - shell: bash - run: | - . venv/bin/activate - shopt -s globstar - pre-commit run --hook-stage manual flake8 --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} - lint-ruff: name: Check ruff - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - info - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.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 }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -360,7 +315,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -385,22 +340,22 @@ jobs: lint-isort: name: Check isort - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - info - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.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 }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -409,7 +364,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -423,22 +378,22 @@ jobs: lint-other: name: Check other linters - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - info - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.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 }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -447,7 +402,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -455,19 +410,6 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - - name: Run pyupgrade (fully) - if: needs.info.outputs.test_full_suite == 'true' - run: | - . venv/bin/activate - pre-commit run --hook-stage manual pyupgrade --all-files --show-diff-on-failure - - name: Run pyupgrade (partially) - if: needs.info.outputs.test_full_suite == 'false' - shell: bash - run: | - . venv/bin/activate - shopt -s globstar - pre-commit run --hook-stage manual pyupgrade --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure - - name: Register yamllint problem matcher run: | echo "::add-matcher::.github/workflows/matchers/yamllint.json" @@ -495,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 @@ -540,7 +483,7 @@ jobs: base: name: Prepare dependencies - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: info timeout-minutes: 60 strategy: @@ -548,10 +491,10 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.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 @@ -562,15 +505,16 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.2.5 + uses: actions/cache@v3.3.1 with: path: venv + lookup-only: true key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.2.5 + uses: actions/cache@v3.3.1 with: path: ${{ env.PIP_CACHE }} key: >- @@ -599,14 +543,14 @@ 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 -r requirements_all.txt --use-deprecated=legacy-resolver - pip install --cache-dir=$PIP_CACHE -r requirements_test.txt --use-deprecated=legacy-resolver + pip install --cache-dir=$PIP_CACHE -U "pip>=21.0,<23.2" setuptools wheel + pip install --cache-dir=$PIP_CACHE -r requirements_all.txt + pip install --cache-dir=$PIP_CACHE -r requirements_test.txt pip install -e . hassfest: name: Check hassfest - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 if: | github.event.inputs.pylint-only != 'true' && github.event.inputs.mypy-only != 'true' @@ -615,16 +559,16 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.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 - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -638,7 +582,7 @@ jobs: gen-requirements-all: name: Check all requirements - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 if: | github.event.inputs.pylint-only != 'true' && github.event.inputs.mypy-only != 'true' @@ -647,16 +591,16 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.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 - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -670,7 +614,7 @@ jobs: pylint: name: Check pylint - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 timeout-minutes: 20 if: | github.event.inputs.mypy-only != 'true' @@ -680,16 +624,16 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.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 - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -715,7 +659,7 @@ jobs: mypy: name: Check mypy - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 if: | github.event.inputs.pylint-only != 'true' || github.event.inputs.mypy-only == 'true' @@ -724,10 +668,10 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.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 @@ -740,7 +684,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -748,7 +692,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v3.2.5 + uses: actions/cache@v3.3.1 with: path: .mypy_cache key: >- @@ -775,44 +719,8 @@ jobs: python --version mypy homeassistant/components/${{ needs.info.outputs.integrations_glob }} - pip-check: - runs-on: ubuntu-20.04 - if: | - github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - needs: - - info - - base - strategy: - fail-fast: false - matrix: - python-version: ${{ fromJson(needs.info.outputs.python_versions) }} - name: Run pip check ${{ matrix.python-version }} - steps: - - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 - - name: Set up Python ${{ matrix.python-version }} - id: python - uses: actions/setup-python@v4.5.0 - with: - python-version: ${{ matrix.python-version }} - check-latest: true - - name: Restore full Python ${{ matrix.python-version }} virtual environment - id: cache-venv - uses: actions/cache/restore@v3.2.5 - with: - path: venv - fail-on-cache-miss: true - key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.info.outputs.python_cache_key }} - - name: Run pip check - run: | - . venv/bin/activate - ./script/pip_check $PIP_CACHE - pytest: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 if: | (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') && github.event.inputs.lint-only != 'true' @@ -843,16 +751,16 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.3.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 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -929,7 +837,7 @@ jobs: ./script/check_dirty pytest-mariadb: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 services: mariadb: image: ${{ matrix.mariadb-group }} @@ -969,16 +877,16 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.3.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 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -1001,6 +909,10 @@ jobs: run: | . venv/bin/activate pip install mysqlclient sqlalchemy_utils + - name: Compile English translations + run: | + . venv/bin/activate + python3 -m script.translations develop --all - name: Run pytest (partially) timeout-minutes: 20 shell: bash @@ -1033,7 +945,7 @@ jobs: ./script/check_dirty pytest-postgres: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 services: postgres: image: ${{ matrix.postgresql-group }} @@ -1071,27 +983,23 @@ jobs: sudo apt-get -y install \ bluez \ ffmpeg \ - postgresql-server-dev-12 + postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.1.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.3.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.2.5 + uses: actions/cache/restore@v3.3.1 with: path: venv + fail-on-cache-miss: true key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - name: Register Python problem matcher run: | echo "::add-matcher::.github/workflows/matchers/python.json" @@ -1109,6 +1017,10 @@ jobs: run: | . venv/bin/activate pip install psycopg2 sqlalchemy_utils + - name: Compile English translations + run: | + . venv/bin/activate + python3 -m script.translations develop --all - name: Run pytest (partially) timeout-minutes: 20 shell: bash @@ -1143,20 +1055,32 @@ jobs: coverage: name: Upload test coverage to Codecov - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - info - pytest + timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.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/matchers/flake8.json b/.github/workflows/matchers/flake8.json deleted file mode 100644 index e059a1cf5f..0000000000 --- a/.github/workflows/matchers/flake8.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "problemMatcher": [ - { - "owner": "flake8-error", - "severity": "error", - "pattern": [ - { - "regexp": "^(.*):(\\d+):(\\d+):\\s([EF]\\d{3}\\s.*)$", - "file": 1, - "line": 2, - "column": 3, - "message": 4 - } - ] - }, - { - "owner": "flake8-warning", - "severity": "warning", - "pattern": [ - { - "regexp": "^(.*):(\\d+):(\\d+):\\s([CDNW]\\d{3}\\s.*)$", - "file": 1, - "line": 2, - "column": 3, - "message": 4 - } - ] - } - ] -} 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 8f8244c490..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.3.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 59604ae0e4..c735a44693 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.3.0 + uses: actions/checkout@v3.5.2 - name: Get information id: info @@ -54,6 +58,9 @@ jobs: # OpenCV headless installation echo "CI_BUILD=1" echo "ENABLE_HEADLESS=1" + + # Use C-Extension for sqlalchemy + echo "REQUIRE_SQLALCHEMY_CEXT=1" ) > .env_file - name: Upload env_file @@ -69,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.3.0 + uses: actions/checkout@v3.5.2 - name: Download env_file uses: actions/download-artifact@v3 @@ -92,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 }} @@ -105,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.3.0 + uses: actions/checkout@v3.5.2 - name: Download env_file uses: actions/download-artifact@v3 @@ -168,31 +177,168 @@ 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" - skip-binary: aiohttp;grpcio - legacy: true + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + skip-binary: aiohttp;grpcio;sqlalchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" - name: Build wheels (part 2) - uses: home-assistant/wheels@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" - skip-binary: aiohttp;grpcio - legacy: true + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + skip-binary: aiohttp;grpcio;sqlalchemy;protobuf + constraints: "homeassistant/package_constraints.txt" + requirements-diff: "requirements_diff.txt" + requirements: "requirements_all.txtab" + + # Wheels building for the cp311 ABI is currently split + # This is mainly until we have figured out to get all wheels built. + # Without harming our current workflow. + integrations_cp311: + name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }} + if: github.repository_owner == 'home-assistant' + needs: init + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + abi: ["cp311"] + arch: ${{ fromJson(needs.init.outputs.architectures) }} + steps: + - name: Checkout the repository + uses: actions/checkout@v3.5.2 + + - name: Write alternative env-file for cp311 + run: | + ( + echo "GRPC_BUILD_WITH_BORING_SSL_ASM=false" + echo "GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=true" + echo "GRPC_PYTHON_BUILD_WITH_CYTHON=true" + echo "GRPC_PYTHON_DISABLE_LIBC_COMPATIBILITY=true" + + # GRPC on armv7 needed -lexecinfo (issue #56669) since home assistant installed + # execinfo-dev when building wheels. However, this package is no longer available + # Alpine 3.17, which we use for the cp311 ABI, so the flag should no longer be needed. + echo "GRPC_PYTHON_LDFLAGS=-lpthread -Wl,-wrap,memcpy -static-libgcc" # -lexecinfo + + # Fix out of memory issues with rust + echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" + + # OpenCV headless installation + echo "CI_BUILD=1" + echo "ENABLE_HEADLESS=1" + + # Use C-Extension for sqlalchemy + echo "REQUIRE_SQLALCHEMY_CEXT=1" + ) > .env_file + + - name: Download requirements_diff + uses: actions/download-artifact@v3 + with: + name: requirements_diff + + - name: (Un)comment packages + run: | + requirement_files="requirements_all.txt requirements_diff.txt" + for requirement_file in ${requirement_files}; do + + # PyBluez no longer compiles. Commented it out for now. + # It need further cleanup down the line, as all machine images + # try to install it. + # sed -i "s|# pybluez|pybluez|g" ${requirement_file} + + # beacontools requires PyBluez. + # sed -i "s|# beacontools|beacontools|g" ${requirement_file} + + # It doesn't build for some reason, so we skip it for now. + # Bumping to the latest version (4.7.0.72) supporting Python 3.11 + # doesn't help. Reverted bump in #91871. There are 8 registered + # instances using this integration according to analytics. + # sed -i "s|# opencv-python-headless|opencv-python-headless|g" ${requirement_file} + + sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} + sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} + sed -i "s|# evdev|evdev|g" ${requirement_file} + sed -i "s|# pycups|pycups|g" ${requirement_file} + sed -i "s|# homekit|homekit|g" ${requirement_file} + sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} + sed -i "s|# 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 + constraints: "homeassistant/package_constraints.txt" + requirements-diff: "requirements_diff.txt" + requirements: "requirements_all.txtaa" + + - name: Build wheels (part 2) + uses: home-assistant/wheels@2023.04.0 + with: + abi: ${{ matrix.abi }} + tag: musllinux_1_2 + arch: ${{ matrix.arch }} + wheels-key: ${{ secrets.WHEELS_KEY }} + env-file: true + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + skip-binary: aiohttp;grpcio;sqlalchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab481ac4ea..8e8fef9769 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,26 +1,12 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.247 + rev: v0.0.262 hooks: - id: ruff args: - --fix - - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 - hooks: - - id: pyupgrade - args: [--py310-plus] - stages: [manual] - - repo: https://github.com/PyCQA/autoflake - rev: v2.0.0 - hooks: - - id: autoflake - args: - - --in-place - - --remove-all-unused-imports - stages: [manual] - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.3.0 hooks: - id: black args: @@ -36,20 +22,6 @@ repos: - --quiet-level=2 exclude_types: [csv, json] exclude: ^tests/fixtures/|homeassistant/generated/ - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - additional_dependencies: - - pycodestyle==2.10.0 - - pyflakes==3.0.1 - - flake8-docstrings==1.6.0 - - pydocstyle==6.2.3 - - flake8-comprehensions==3.10.1 - - flake8-noqa==1.3.0 - - mccabe==0.7.0 - exclude: docs/source/conf.py - stages: [manual] - repo: https://github.com/PyCQA/bandit rev: 1.7.4 hooks: 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 13fd49391e..f7297d8e68 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.* @@ -167,6 +170,7 @@ homeassistant.components.hyperion.* homeassistant.components.ibeacon.* homeassistant.components.image_processing.* homeassistant.components.image_upload.* +homeassistant.components.imap.* homeassistant.components.input_button.* homeassistant.components.input_select.* homeassistant.components.integration.* @@ -174,6 +178,7 @@ homeassistant.components.iqvia.* homeassistant.components.isy994.* homeassistant.components.jellyfin.* homeassistant.components.jewish_calendar.* +homeassistant.components.jvc_projector.* homeassistant.components.kaleidescape.* homeassistant.components.knx.* homeassistant.components.kraken.* @@ -186,6 +191,7 @@ homeassistant.components.ld2410_ble.* homeassistant.components.lidarr.* homeassistant.components.lifx.* homeassistant.components.light.* +homeassistant.components.litejet.* homeassistant.components.litterrobot.* homeassistant.components.local_ip.* homeassistant.components.lock.* @@ -280,6 +286,7 @@ homeassistant.components.smhi.* homeassistant.components.snooz.* homeassistant.components.sonarr.* homeassistant.components.speedtestdotnet.* +homeassistant.components.sql.* homeassistant.components.ssdp.* homeassistant.components.statistics.* homeassistant.components.steamist.* @@ -296,6 +303,7 @@ homeassistant.components.tag.* homeassistant.components.tailscale.* homeassistant.components.tautulli.* homeassistant.components.tcp.* +homeassistant.components.threshold.* homeassistant.components.tibber.* homeassistant.components.tile.* homeassistant.components.tilt_ble.* @@ -309,7 +317,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/.vscode/tasks.json b/.vscode/tasks.json index 849716d7ba..c484045530 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -42,20 +42,6 @@ }, "problemMatcher": [] }, - { - "label": "Flake8", - "type": "shell", - "command": "pre-commit run flake8 --all-files", - "group": { - "kind": "test", - "isDefault": true - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, - "problemMatcher": [] - }, { "label": "Ruff", "type": "shell", @@ -117,7 +103,7 @@ { "label": "Install all Requirements", "type": "shell", - "command": "pip3 install --use-deprecated=legacy-resolver -r requirements_all.txt", + "command": "pip3 install -r requirements_all.txt", "group": { "kind": "build", "isDefault": true @@ -131,7 +117,7 @@ { "label": "Install all Test Requirements", "type": "shell", - "command": "pip3 install --use-deprecated=legacy-resolver -r requirements_test_all.txt", + "command": "pip3 install -r requirements_test_all.txt", "group": { "kind": "build", "isDefault": true diff --git a/.yamllint b/.yamllint index c2f877a2b7..e587d75d79 100644 --- a/.yamllint +++ b/.yamllint @@ -25,7 +25,7 @@ rules: comments: level: error require-starting-space: true - min-spaces-from-content: 2 + min-spaces-from-content: 1 comments-indentation: level: error document-end: diff --git a/CODEOWNERS b/CODEOWNERS index cb559a7d7b..c2eba38642 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 @Drafteed +/tests/components/androidtv_remote/ @tronikos @Drafteed +/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,8 @@ 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/date/ @home-assistant/core +/tests/components/date/ @home-assistant/core /homeassistant/components/debugpy/ @frenck /tests/components/debugpy/ @frenck /homeassistant/components/deconz/ @Kane610 @@ -283,7 +289,8 @@ 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 +/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo /homeassistant/components/dynalite/ @ziv1234 /tests/components/dynalite/ @ziv1234 /homeassistant/components/eafm/ @Jc2k @@ -401,6 +408,7 @@ build.json @home-assistant/supervisor /homeassistant/components/frontend/ @home-assistant/frontend /tests/components/frontend/ @home-assistant/frontend /homeassistant/components/frontier_silicon/ @wlcrs +/tests/components/frontier_silicon/ @wlcrs /homeassistant/components/fully_kiosk/ @cgarwood /tests/components/fully_kiosk/ @cgarwood /homeassistant/components/garages_amsterdam/ @klaasnicolaas @@ -443,6 +451,8 @@ build.json @home-assistant/supervisor /homeassistant/components/google_assistant_sdk/ @tronikos /tests/components/google_assistant_sdk/ @tronikos /homeassistant/components/google_cloud/ @lufton +/homeassistant/components/google_generative_ai_conversation/ @tronikos +/tests/components/google_generative_ai_conversation/ @tronikos /homeassistant/components/google_mail/ @tkdrob /tests/components/google_mail/ @tkdrob /homeassistant/components/google_sheets/ @tkdrob @@ -545,8 +555,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 @@ -601,6 +611,8 @@ build.json @home-assistant/supervisor /tests/components/juicenet/ @jesserockz /homeassistant/components/justnimbus/ @kvanzuijlen /tests/components/justnimbus/ @kvanzuijlen +/homeassistant/components/jvc_projector/ @SteveEasley +/tests/components/jvc_projector/ @SteveEasley /homeassistant/components/kaiterra/ @Michsior14 /homeassistant/components/kaleidescape/ @SteveEasley /tests/components/kaleidescape/ @SteveEasley @@ -650,8 +662,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 @@ -659,8 +671,8 @@ build.json @home-assistant/supervisor /tests/components/litejet/ @joncar /homeassistant/components/litterrobot/ @natekspencer @tkdrob /tests/components/litterrobot/ @natekspencer @tkdrob -/homeassistant/components/livisi/ @StefanIacobLivisi -/tests/components/livisi/ @StefanIacobLivisi +/homeassistant/components/livisi/ @StefanIacobLivisi @planbnet +/tests/components/livisi/ @StefanIacobLivisi @planbnet /homeassistant/components/local_calendar/ @allenporter /tests/components/local_calendar/ @allenporter /homeassistant/components/local_ip/ @issacg @@ -778,13 +790,15 @@ build.json @home-assistant/supervisor /homeassistant/components/netdata/ @fabaff /homeassistant/components/netgear/ @hacf-fr @Quentame @starkillerOG /tests/components/netgear/ @hacf-fr @Quentame @starkillerOG +/homeassistant/components/netgear_lte/ @tkdrob /homeassistant/components/network/ @home-assistant/core /tests/components/network/ @home-assistant/core /homeassistant/components/nexia/ @bdraco /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 @@ -819,13 +833,14 @@ 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 /tests/components/nzbget/ @chriscla -/homeassistant/components/obihai/ @dshokouhi +/homeassistant/components/obihai/ @dshokouhi @ejpenney +/tests/components/obihai/ @dshokouhi @ejpenney /homeassistant/components/octoprint/ @rfleming71 /tests/components/octoprint/ @rfleming71 /homeassistant/components/ohmconnect/ @robbiet480 @@ -892,8 +907,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 @@ -930,6 +945,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 @@ -957,6 +973,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 @@ -975,6 +993,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 @@ -985,10 +1005,12 @@ build.json @home-assistant/supervisor /tests/components/ridwell/ @bachya /homeassistant/components/risco/ @OnFreund /tests/components/risco/ @OnFreund -/homeassistant/components/rituals_perfume_genie/ @milanmeu -/tests/components/rituals_perfume_genie/ @milanmeu +/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck +/tests/components/rituals_perfume_genie/ @milanmeu @frenck /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 @@ -1055,8 +1077,8 @@ build.json @home-assistant/supervisor /homeassistant/components/seven_segments/ @fabaff /homeassistant/components/sfr_box/ @epenet /tests/components/sfr_box/ @epenet -/homeassistant/components/sharkiq/ @JeffResc @funkybunch @AritroSaha10 -/tests/components/sharkiq/ @JeffResc @funkybunch @AritroSaha10 +/homeassistant/components/sharkiq/ @JeffResc @funkybunch +/tests/components/sharkiq/ @JeffResc @funkybunch /homeassistant/components/shell_command/ @home-assistant/core /tests/components/shell_command/ @home-assistant/core /homeassistant/components/shelly/ @balloob @bieniu @thecode @chemelli74 @bdraco @@ -1100,6 +1122,8 @@ build.json @home-assistant/supervisor /homeassistant/components/smhi/ @gjohansson-ST /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 @@ -1128,8 +1152,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/ @gjohansson-ST @dougiteixeira +/tests/components/sql/ @gjohansson-ST @dougiteixeira /homeassistant/components/squeezebox/ @rajlaud /tests/components/squeezebox/ @rajlaud /homeassistant/components/srp_energy/ @briglx @@ -1138,8 +1162,8 @@ build.json @home-assistant/supervisor /tests/components/starline/ @anonym-tsk /homeassistant/components/starlink/ @boswelja /tests/components/starlink/ @boswelja -/homeassistant/components/statistics/ @fabaff @ThomDietrich -/tests/components/statistics/ @fabaff @ThomDietrich +/homeassistant/components/statistics/ @ThomDietrich +/tests/components/statistics/ @ThomDietrich /homeassistant/components/steam_online/ @tkdrob /tests/components/steam_online/ @tkdrob /homeassistant/components/steamist/ @bdraco @@ -1151,8 +1175,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 @@ -1171,8 +1195,8 @@ build.json @home-assistant/supervisor /tests/components/switchbee/ @jafar-atili /homeassistant/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski /tests/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski -/homeassistant/components/switcher_kis/ @tomerfi @thecode -/tests/components/switcher_kis/ @tomerfi @thecode +/homeassistant/components/switcher_kis/ @thecode +/tests/components/switcher_kis/ @thecode /homeassistant/components/switchmate/ @danielhiversen @qiz-li /homeassistant/components/syncthing/ @zhulik /tests/components/syncthing/ @zhulik @@ -1212,14 +1236,14 @@ build.json @home-assistant/supervisor /homeassistant/components/thethingsnetwork/ @fabaff /homeassistant/components/thread/ @home-assistant/core /tests/components/thread/ @home-assistant/core -/homeassistant/components/threshold/ @fabaff -/tests/components/threshold/ @fabaff /homeassistant/components/tibber/ @danielhiversen /tests/components/tibber/ @danielhiversen /homeassistant/components/tile/ @bachya /tests/components/tile/ @bachya /homeassistant/components/tilt_ble/ @apt-itude /tests/components/tilt_ble/ @apt-itude +/homeassistant/components/time/ @home-assistant/core +/tests/components/time/ @home-assistant/core /homeassistant/components/time_date/ @fabaff /tests/components/time_date/ @fabaff /homeassistant/components/tmb/ @alemuro @@ -1249,8 +1273,8 @@ build.json @home-assistant/supervisor /tests/components/trafikverket_weatherstation/ @endor-force @gjohansson-ST /homeassistant/components/transmission/ @engrbm87 @JPHutchins /tests/components/transmission/ @engrbm87 @JPHutchins -/homeassistant/components/tts/ @pvizeli -/tests/components/tts/ @pvizeli +/homeassistant/components/tts/ @home-assistant/core @pvizeli +/tests/components/tts/ @home-assistant/core @pvizeli /homeassistant/components/tuya/ @Tuya @zlinoliver @frenck /tests/components/tuya/ @Tuya @zlinoliver @frenck /homeassistant/components/twentemilieu/ @frenck @@ -1262,8 +1286,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 @@ -1292,15 +1316,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 @@ -1308,6 +1330,8 @@ build.json @home-assistant/supervisor /tests/components/vizio/ @raman325 /homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare /tests/components/vlc_telnet/ @rodripf @MartinHjelmare +/homeassistant/components/voip/ @balloob @synesthesiam +/tests/components/voip/ @balloob @synesthesiam /homeassistant/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund /homeassistant/components/volvooncall/ @molobrakos @@ -1353,15 +1377,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 b/Dockerfile index fa8f5520f2..e229f27cb3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,6 @@ RUN \ --no-index \ --only-binary=:all: \ --find-links "${WHEELS_LINKS}" \ - --use-deprecated=legacy-resolver \ -r homeassistant/requirements.txt COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ @@ -43,7 +42,6 @@ RUN \ --no-index \ --only-binary=:all: \ --find-links "${WHEELS_LINKS}" \ - --use-deprecated=legacy-resolver \ -r homeassistant/requirements_all.txt ## Setup Home Assistant Core @@ -54,7 +52,6 @@ RUN \ --no-index \ --only-binary=:all: \ --find-links "${WHEELS_LINKS}" \ - --use-deprecated=legacy-resolver \ -e ./homeassistant \ && python3 -m compileall \ homeassistant/homeassistant diff --git a/Dockerfile.dev b/Dockerfile.dev index 863ac5690b..de49bb77f1 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -4,12 +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 flake8 -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 - \ @@ -45,9 +45,9 @@ WORKDIR /workspaces # Install Python dependencies from requirements COPY requirements.txt ./ COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt -RUN pip3 install -r requirements.txt --use-deprecated=legacy-resolver +RUN pip3 install -r requirements.txt COPY requirements_test.txt requirements_test_pre_commit.txt ./ -RUN pip3 install -r requirements_test.txt --use-deprecated=legacy-resolver +RUN pip3 install -r requirements_test.txt RUN rm -rf requirements.txt requirements_test.txt requirements_test_pre_commit.txt homeassistant/ # Set the default shell to bash instead of sh diff --git a/README.rst b/README.rst index 6f5e0e6989..084949dc44 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,7 @@ Home Assistant |Chat Status| Open source home automation that puts local control and privacy first. Powered by a worldwide community of tinkerers and DIY enthusiasts. Perfect to run on a Raspberry Pi or a local server. Check out `home-assistant.io `__ for `a -demo `__, `installation instructions `__, +demo `__, `installation instructions `__, `tutorials `__ and `documentation `__. |screenshot-states| @@ -23,6 +23,6 @@ of a component, check the `Home Assistant help section 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..c681df6655 --- /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( + payload=payload, + options=merged_options, + issuer=issuer, + leeway=leeway, + ) + return payload + + +_jwt = _PyJWTWithVerify() +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/auth/permissions/models.py b/homeassistant/auth/permissions/models.py index aa1a777ced..9b9c384c74 100644 --- a/homeassistant/auth/permissions/models.py +++ b/homeassistant/auth/permissions/models.py @@ -6,15 +6,12 @@ from typing import TYPE_CHECKING import attr if TYPE_CHECKING: - from homeassistant.helpers import ( - device_registry as dev_reg, - entity_registry as ent_reg, - ) + from homeassistant.helpers import device_registry as dr, entity_registry as er @attr.s(slots=True) class PermissionLookup: """Class to hold data for permission lookups.""" - entity_registry: ent_reg.EntityRegistry = attr.ib() - device_registry: dev_reg.DeviceRegistry = attr.ib() + entity_registry: er.EntityRegistry = attr.ib() + device_registry: dr.DeviceRegistry = attr.ib() diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index 753fda5ae9..d7c1a7c9ee 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -8,7 +8,7 @@ from .util.async_ import protect_loop def enable() -> None: """Enable the detection of blocking calls in the event loop.""" # Prevent urllib3 and requests doing I/O in event loop - HTTPConnection.putrequest = protect_loop( # type: ignore[assignment] + HTTPConnection.putrequest = protect_loop( # type: ignore[method-assign] HTTPConnection.putrequest ) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index e87ee1ae28..46bd4b5d88 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -31,6 +31,7 @@ from .helpers import ( entity_registry, issue_registry, recorder, + template, ) from .helpers.dispatcher import async_dispatcher_send from .helpers.typing import ConfigType @@ -238,12 +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_templates(hass), ) @@ -377,6 +380,11 @@ def async_enable_logging( # formatting. If the above succeeds, this will result in a no-op. logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.INFO) + # Capture warnings.warn(...) and friends messages in logs. + # The standard destination for them is stderr, which may end up unnoticed. + # This way they're where other messages are, and can be filtered as usual. + logging.captureWarnings(True) + # Suppress overly verbose logs from libraries that aren't helpful logging.getLogger("requests").setLevel(logging.WARNING) logging.getLogger("urllib3").setLevel(logging.WARNING) @@ -508,19 +516,20 @@ async def async_setup_multi_components( ) -> None: """Set up multiple domains. Log on failure.""" futures = { - domain: hass.async_create_task(async_setup_component(hass, domain, config)) + domain: hass.async_create_task( + async_setup_component(hass, domain, config), f"setup component {domain}" + ) 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( @@ -625,6 +634,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) @@ -636,7 +648,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/google.json b/homeassistant/brands/google.json index 0d396ca05e..3eb2e9e64f 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -6,6 +6,7 @@ "google_assistant_sdk", "google_cloud", "google_domains", + "google_generative_ai_conversation", "google_mail", "google_maps", "google_pubsub", diff --git a/homeassistant/brands/heltun.json b/homeassistant/brands/heltun.json new file mode 100644 index 0000000000..d9e85a8954 --- /dev/null +++ b/homeassistant/brands/heltun.json @@ -0,0 +1,5 @@ +{ + "domain": "heltun", + "name": "HELTUN", + "iot_standards": ["zwave"] +} 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/__init__.py b/homeassistant/components/__init__.py index d0e631fb04..690b38b487 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -1,4 +1,4 @@ -"""This package contains components that can be plugged into Home Assistant. +"""Contains components that can be plugged into Home Assistant. Component design guidelines: - Each component defines a constant DOMAIN that is equal to its filename. 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..fbf31720e1 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.2"] } 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/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index 9c18e2ba90..5397fed5e1 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -40,7 +40,7 @@ def get_scanner( class ActiontecDeviceScanner(DeviceScanner): - """This class queries an actiontec router for connected devices.""" + """Class which queries an actiontec router for connected devices.""" def __init__(self, config: ConfigType) -> None: """Initialize the scanner.""" 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 362701f3b9..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,53 +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.async_update_ac({"state": ADVANTAGE_AIR_STATE_ON}) + + async def async_turn_off(self) -> None: + """Set the HVAC State to off.""" + await self.async_update_ac( + { + "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: @@ -172,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"] @@ -181,26 +233,22 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): """Return the target temperature.""" return self._zone["setTemp"] + async def async_turn_on(self) -> None: + """Set the HVAC State to on.""" + 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.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.""" if hvac_mode == HVACMode.OFF: - await self.aircon( - { - self.ac_key: { - "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}} - } - } - ) + await self.async_turn_off() else: - await self.aircon( - { - self.ac_key: { - "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_OPEN}} - } - } - ) + await self.async_turn_on() 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/airnow/manifest.json b/homeassistant/components/airnow/manifest.json index 68efb618a2..28dada485b 100644 --- a/homeassistant/components/airnow/manifest.json +++ b/homeassistant/components/airnow/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airnow", "iot_class": "cloud_polling", "loggers": ["pyairnow"], - "requirements": ["pyairnow==1.1.0"] + "requirements": ["pyairnow==1.2.1"] } 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 e46893e8d7..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,10 +93,16 @@ 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", - device_class=SensorDeviceClass.CO, native_unit_of_measurement=CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("co"), @@ -81,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", @@ -96,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", @@ -103,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", @@ -110,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", @@ -141,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", @@ -173,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", @@ -188,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", @@ -196,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", @@ -246,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", @@ -289,7 +400,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ AirQEntityDescription( key="tvoc", name="VOC", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc"), @@ -297,11 +407,18 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ AirQEntityDescription( key="tvoc_ionsc", name="VOC (Industrial)", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc_ionsc"), ), + AirQEntityDescription( + key="virus", + name="Virus Index", + 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/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index 513c589dc7..5212ff51fe 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -150,10 +150,14 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): self._attr_unique_id = f"{airthings_device.device_id}_{entity_description.key}" self._id = airthings_device.device_id self._attr_device_info = DeviceInfo( - configuration_url="https://dashboard.airthings.com/", + configuration_url=( + "https://dashboard.airthings.com/devices/" + f"{airthings_device.device_id}" + ), identifiers={(DOMAIN, airthings_device.device_id)}, name=airthings_device.name, manufacturer="Airthings", + model=airthings_device.device_type.replace("_", " ").lower().title(), ) @property diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index e18d76128f..b6c8c25491 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -65,24 +65,28 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { key="temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, name="Temperature", ), "humidity": SensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, name="Humidity", ), "pressure": SensorEntityDescription( key="pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, name="Pressure", ), "battery": SensorEntityDescription( key="battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, name="Battery", ), @@ -90,12 +94,13 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { key="co2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, name="co2", ), "voc": SensorEntityDescription( key="voc", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=SensorStateClass.MEASUREMENT, name="VOC", icon="mdi:cloud", ), @@ -103,6 +108,7 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { key="illuminance", device_class=SensorDeviceClass.ILLUMINANCE, native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, name="Illuminance", ), } @@ -150,7 +156,6 @@ class AirthingsSensor( ): """Airthings BLE sensors for the device.""" - _attr_state_class = SensorStateClass.MEASUREMENT _attr_has_entity_name = True def __init__( 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/airzone/climate.py b/homeassistant/components/airzone/climate.py index c344b1ff49..764beddd43 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -3,18 +3,21 @@ from __future__ import annotations from typing import Any, Final -from aioairzone.common import OperationMode +from aioairzone.common import OperationAction, OperationMode from aioairzone.const import ( API_MODE, API_ON, API_SET_POINT, - AZD_DEMAND, + API_SPEED, + AZD_ACTION, AZD_HUMIDITY, AZD_MASTER, AZD_MODE, AZD_MODES, AZD_NAME, AZD_ON, + AZD_SPEED, + AZD_SPEEDS, AZD_TEMP, AZD_TEMP_MAX, AZD_TEMP_MIN, @@ -24,6 +27,10 @@ from aioairzone.const import ( ) from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -39,12 +46,29 @@ from .const import API_TEMPERATURE_STEP, DOMAIN, TEMP_UNIT_LIB_TO_HASS from .coordinator import AirzoneUpdateCoordinator from .entity import AirzoneZoneEntity -HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationMode, HVACAction]] = { - OperationMode.STOP: HVACAction.OFF, - OperationMode.COOLING: HVACAction.COOLING, - OperationMode.HEATING: HVACAction.HEATING, - OperationMode.FAN: HVACAction.FAN, - OperationMode.DRY: HVACAction.DRYING, +BASE_FAN_SPEEDS: Final[dict[int, str]] = { + 0: FAN_AUTO, + 1: FAN_LOW, +} +FAN_SPEED_MAPS: Final[dict[int, dict[int, str]]] = { + 2: BASE_FAN_SPEEDS + | { + 2: FAN_HIGH, + }, + 3: BASE_FAN_SPEEDS + | { + 2: FAN_MEDIUM, + 3: FAN_HIGH, + }, +} + +HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationAction, HVACAction]] = { + OperationAction.COOLING: HVACAction.COOLING, + OperationAction.DRYING: HVACAction.DRYING, + OperationAction.FAN: HVACAction.FAN, + OperationAction.HEATING: HVACAction.HEATING, + OperationAction.IDLE: HVACAction.IDLE, + OperationAction.OFF: HVACAction.OFF, } HVAC_MODE_LIB_TO_HASS: Final[dict[OperationMode, HVACMode]] = { OperationMode.STOP: HVACMode.OFF, @@ -83,6 +107,9 @@ async def async_setup_entry( class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): """Define an Airzone sensor.""" + _speeds: dict[int, str] = {} + _speeds_reverse: dict[str, int] = {} + def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -105,8 +132,35 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): self._attr_hvac_modes = [ HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) ] + if ( + self.get_airzone_value(AZD_SPEED) is not None + and self.get_airzone_value(AZD_SPEEDS) is not None + ): + self._set_fan_speeds() + self._async_update_attrs() + def _set_fan_speeds(self) -> None: + self._attr_supported_features |= ClimateEntityFeature.FAN_MODE + + speeds = self.get_airzone_value(AZD_SPEEDS) + max_speed = max(speeds) + if _speeds := FAN_SPEED_MAPS.get(max_speed): + self._speeds = _speeds + else: + for speed in speeds: + if speed == 0: + self._speeds[speed] = FAN_AUTO + else: + self._speeds[speed] = f"{int(round((speed * 100) / max_speed, 0))}%" + + self._speeds[1] = FAN_LOW + self._speeds[int(round((max_speed + 1) / 2, 0))] = FAN_MEDIUM + self._speeds[max_speed] = FAN_HIGH + + self._speeds_reverse = {v: k for k, v in self._speeds.items()} + self._attr_fan_modes = list(self._speeds_reverse) + async def async_turn_on(self) -> None: """Turn the entity on.""" params = { @@ -121,6 +175,13 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): } await self._async_update_hvac_params(params) + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + params = { + API_SPEED: self._speeds_reverse.get(fan_mode), + } + await self._async_update_hvac_params(params) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" params = {} @@ -156,14 +217,15 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): """Update climate attributes.""" self._attr_current_temperature = self.get_airzone_value(AZD_TEMP) self._attr_current_humidity = self.get_airzone_value(AZD_HUMIDITY) + self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[ + self.get_airzone_value(AZD_ACTION) + ] if self.get_airzone_value(AZD_ON): - mode = self.get_airzone_value(AZD_MODE) - self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[mode] - if self.get_airzone_value(AZD_DEMAND): - self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[mode] - else: - self._attr_hvac_action = HVACAction.IDLE + self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[ + self.get_airzone_value(AZD_MODE) + ] else: - self._attr_hvac_action = HVACAction.OFF self._attr_hvac_mode = HVACMode.OFF self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) + if self.supported_features & ClimateEntityFeature.FAN_MODE: + self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 08d7fb1ace..d55ffd187f 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.5.2"] + "requirements": ["aioairzone==0.5.6"] } diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index 89d3b0faf1..e5170e9b0a 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Mapping -import logging from typing import Any from AIOAladdinConnect import AladdinConnectClient @@ -20,8 +19,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CLIENT_ID, DOMAIN -_LOGGER = logging.getLogger(__name__) - STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -134,12 +131,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def async_step_import( - self, import_data: dict[str, Any] | None = None - ) -> FlowResult: - """Import Aladin Connect config from configuration.yaml.""" - return await self.async_step_user(import_data) - class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 8815ccdbb9..32eb34333c 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -2,63 +2,24 @@ from __future__ import annotations from datetime import timedelta -import logging -from typing import Any, Final +from typing import Any -from AIOAladdinConnect import AladdinConnectClient -import voluptuous as vol +from AIOAladdinConnect import AladdinConnectClient, session_manager -from homeassistant.components.cover import ( - PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, - CoverDeviceClass, - CoverEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - STATE_CLOSED, - STATE_CLOSING, - STATE_OPENING, -) +from homeassistant.components.cover import CoverDeviceClass, CoverEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN, STATES_MAP, SUPPORTED_FEATURES from .model import DoorDevice -_LOGGER: Final = logging.getLogger(__name__) - -PLATFORM_SCHEMA: Final = BASE_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} -) SCAN_INTERVAL = timedelta(seconds=300) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up Aladdin Connect devices yaml depreciated.""" - _LOGGER.warning( - "Configuring Aladdin Connect through yaml is deprecated. Please remove it from" - " your configuration as it has already been imported to a config entry" - ) - await hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -85,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"] @@ -124,7 +85,12 @@ 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, session_manager.InvalidPasswordError): + self._attr_available = False @property def is_closed(self) -> bool | None: diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index dd0c3d03a4..de4f3df257 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -21,7 +21,7 @@ from homeassistant.const import ( SERVICE_ALARM_TRIGGER, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -57,11 +57,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Alarm control panel devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py index 4764d5cfcb..a097aa9853 100644 --- a/homeassistant/components/alarm_control_panel/device_condition.py +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -21,7 +21,11 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -64,11 +68,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Alarm control panel devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index 303243d66c..9106942c5e 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -23,7 +23,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -57,11 +57,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Alarm control panel devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers: list[dict[str, str]] = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 5126f49d92..6b01cab2be 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -26,19 +26,41 @@ "armed_vacation": "{entity_name} armed vacation" } }, - "state": { + "entity_component": { "_": { - "armed": "Armed", - "disarmed": "Disarmed", - "armed_home": "Armed home", - "armed_away": "Armed away", - "armed_night": "Armed night", - "armed_vacation": "Armed vacation", - "armed_custom_bypass": "Armed custom bypass", - "pending": "Pending", - "arming": "Arming", - "disarming": "Disarming", - "triggered": "Triggered" + "name": "[%key:component::alarm_control_panel::title%]", + "state": { + "armed": "Armed", + "disarmed": "Disarmed", + "armed_home": "Armed home", + "armed_away": "Armed away", + "armed_night": "Armed night", + "armed_vacation": "Armed vacation", + "armed_custom_bypass": "Armed custom bypass", + "pending": "Pending", + "arming": "Arming", + "disarming": "Disarming", + "triggered": "Triggered" + }, + "state_attributes": { + "code_format": { + "name": "Code format", + "state": { + "text": "Text", + "number": "Number" + } + }, + "changed_by": { + "name": "Changed by" + }, + "code_arm_required": { + "name": "Code for arming", + "state": { + "true": "Required", + "false": "Not required" + } + } + } } } } 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/alert/strings.json b/homeassistant/components/alert/strings.json index fb31ecd057..4d948b2f4d 100644 --- a/homeassistant/components/alert/strings.json +++ b/homeassistant/components/alert/strings.json @@ -1,10 +1,13 @@ { "title": "Alert", - "state": { + "entity_component": { "_": { - "idle": "[%key:common::state::idle%]", - "off": "Acknowledged", - "on": "[%key:common::state::active%]" + "name": "[%key:component::alert::title%]", + "state": { + "idle": "[%key:common::state::idle%]", + "off": "Acknowledged", + "on": "[%key:common::state::active%]" + } } } } diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index cdbea2ca34..e086d525cf 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod import asyncio import logging -from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.storage import Store from .const import DOMAIN @@ -17,11 +17,12 @@ _LOGGER = logging.getLogger(__name__) class AbstractConfig(ABC): """Hold the configuration for Alexa.""" - _unsub_proactive_report: asyncio.Task[CALLBACK_TYPE] | None = None + _unsub_proactive_report: CALLBACK_TYPE | None = None - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize abstract config.""" self.hass = hass + self._enable_proactive_mode_lock = asyncio.Lock() self._store = None async def async_initialize(self): @@ -67,20 +68,17 @@ class AbstractConfig(ABC): async def async_enable_proactive_mode(self): """Enable proactive mode.""" _LOGGER.debug("Enable proactive mode") - if self._unsub_proactive_report is None: - self._unsub_proactive_report = self.hass.async_create_task( - async_enable_proactive_mode(self.hass, self) + async with self._enable_proactive_mode_lock: + if self._unsub_proactive_report is not None: + return + self._unsub_proactive_report = await async_enable_proactive_mode( + self.hass, self ) - try: - await self._unsub_proactive_report - except Exception: - self._unsub_proactive_report = None - raise async def async_disable_proactive_mode(self): """Disable proactive mode.""" _LOGGER.debug("Disable proactive mode") - if unsub_func := await self._unsub_proactive_report: + if unsub_func := self._unsub_proactive_report: unsub_func() self._unsub_proactive_report = None diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index 5f0de6f746..7f4b41b9ec 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -9,7 +9,7 @@ from .const import API_TEMP_UNITS class UnsupportedProperty(HomeAssistantError): - """This entity does not support the requested Smart Home API property.""" + """Does not support the requested Smart Home API property.""" class NoTokenAvailable(HomeAssistantError): diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py index 1521afcae5..6f53d86d44 100644 --- a/homeassistant/components/alexa/flash_briefings.py +++ b/homeassistant/components/alexa/flash_briefings.py @@ -1,5 +1,4 @@ """Support for Alexa skill service end point.""" -import copy import hmac from http import HTTPStatus import logging @@ -48,7 +47,7 @@ class AlexaFlashBriefingView(http.HomeAssistantView): def __init__(self, hass, flash_briefings): """Initialize Alexa view.""" super().__init__() - self.flash_briefings = copy.deepcopy(flash_briefings) + self.flash_briefings = flash_briefings template.attach(hass, self.flash_briefings) @callback diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index 4c87223474..06f76b8806 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -1,6 +1,7 @@ """Support for Alexa skill service end point.""" import enum import logging +from typing import Any from homeassistant.components import http from homeassistant.core import callback @@ -180,12 +181,15 @@ async def async_handle_intent(hass, message): return alexa_response.as_dict() -def resolve_slot_synonyms(key, request): +def resolve_slot_data(key: str, request: dict[str, Any]) -> dict[str, str]: """Check slot request for synonym resolutions.""" - # Default to the spoken slot value if more than one or none are found. For + # Default to the spoken slot value if more than one or none are found. Always + # passes the id and name of the nearest possible slot resolution. For # reference to the request object structure, see the Alexa docs: # https://tinyurl.com/ybvm7jhs - resolved_value = request["value"] + resolved_data = {} + resolved_data["value"] = request["value"] + resolved_data["id"] = "" if ( "resolutions" in request @@ -200,20 +204,26 @@ def resolve_slot_synonyms(key, request): if entry["status"]["code"] != SYN_RESOLUTION_MATCH: continue - possible_values.extend([item["value"]["name"] for item in entry["values"]]) + possible_values.extend([item["value"] for item in entry["values"]]) + + # Always set id if available, otherwise an empty string is used as id + if len(possible_values) >= 1: + # Set ID if available + if "id" in possible_values[0]: + resolved_data["id"] = possible_values[0]["id"] # If there is only one match use the resolved value, otherwise the - # resolution cannot be determined, so use the spoken slot value + # resolution cannot be determined, so use the spoken slot value and empty string as id if len(possible_values) == 1: - resolved_value = possible_values[0] + resolved_data["value"] = possible_values[0]["name"] else: _LOGGER.debug( "Found multiple synonym resolutions for slot value: {%s: %s}", key, - resolved_value, + resolved_data["value"], ) - return resolved_value + return resolved_data class AlexaResponse: @@ -237,8 +247,10 @@ class AlexaResponse: continue _key = key.replace(".", "_") + _slot_data = resolve_slot_data(key, value) - self.variables[_key] = resolve_slot_synonyms(key, value) + self.variables[_key] = _slot_data["value"] + self.variables[_key + "_Id"] = _slot_data["id"] def add_card(self, card_type, title, content): """Add a card to the response.""" diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py index 9be7381adb..3a702421d9 100644 --- a/homeassistant/components/alexa/smart_home_http.py +++ b/homeassistant/components/alexa/smart_home_http.py @@ -60,6 +60,7 @@ class AlexaConfig(AbstractConfig): """Return an identifier for the user that represents this config.""" return "" + @core.callback def should_expose(self, entity_id): """If an entity should be exposed.""" if not self._config[CONF_FILTER].empty_filter: diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 783397ca04..a189c364c0 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -5,6 +5,7 @@ import asyncio from http import HTTPStatus import json import logging +from typing import cast import aiohttp import async_timeout @@ -15,6 +16,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.significant_change import create_checker import homeassistant.util.dt as dt_util +from homeassistant.util.json import JsonObjectType, json_loads_object from .const import API_CHANGE, DATE_FORMAT, DOMAIN, Cause from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id @@ -162,9 +164,10 @@ async def async_send_changereport_message( if response.status == HTTPStatus.ACCEPTED: return - response_json = json.loads(response_text) + response_json = json_loads_object(response_text) + response_payload = cast(JsonObjectType, response_json["payload"]) - if response_json["payload"]["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION": + if response_payload["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION": if invalidate_access_token: # Invalidate the access token and try again config.async_invalidate_access_token() @@ -180,8 +183,8 @@ async def async_send_changereport_message( _LOGGER.error( "Error when sending ChangeReport for %s to Alexa: %s: %s", alexa_entity.entity_id, - response_json["payload"]["code"], - response_json["payload"]["description"], + response_payload["code"], + response_payload["description"], ) @@ -299,11 +302,12 @@ async def async_send_doorbell_event_message(hass, config, alexa_entity): if response.status == HTTPStatus.ACCEPTED: return - response_json = json.loads(response_text) + response_json = json_loads_object(response_text) + response_payload = cast(JsonObjectType, response_json["payload"]) _LOGGER.error( "Error when sending DoorbellPress event for %s to Alexa: %s: %s", alexa_entity.entity_id, - response_json["payload"]["code"], - response_json["payload"]["description"], + response_payload["code"], + response_payload["description"], ) diff --git a/homeassistant/components/amazon_polly/const.py b/homeassistant/components/amazon_polly/const.py index a0250938fb..e1f7afce17 100644 --- a/homeassistant/components/amazon_polly/const.py +++ b/homeassistant/components/amazon_polly/const.py @@ -34,49 +34,49 @@ CONF_TEXT_TYPE: Final = "text_type" SUPPORTED_VOICES: Final[list[str]] = [ "Aditi", # Hindi - "Amy", - "Aria", + "Amy", # English (British) + "Aria", # English (New Zealand), Neural "Arlet", # Catalan, Neural "Arthur", # English, Neural "Astrid", # Swedish - "Ayanda", + "Ayanda", # English (South African), Neural "Bianca", # Italian - "Brian", + "Brian", # English (British) "Camila", # Portuguese, Brazilian - "Carla", + "Carla", # Italian "Carmen", # Romanian - "Celine", + "Celine", # French "Chantal", # French Canadian - "Conchita", - "Cristiano", + "Conchita", # Spanish (European) + "Cristiano", # Portuguese (European) "Daniel", # German, Neural "Dora", # Icelandic "Elin", # Swedish, Neural "Emma", # English - "Enrique", - "Ewa", + "Enrique", # Spanish (European) + "Ewa", # Polish "Filiz", # Turkish - "Gabrielle", + "Gabrielle", # French (Canadian) "Geraint", # English Welsh - "Giorgio", + "Giorgio", # Italian "Gwyneth", # Welsh "Hala", # Arabic (Gulf), Neural "Hannah", # German (Austrian), Neural - "Hans", + "Hans", # German "Hiujin", # Chinese (Cantonese), Neural "Ida", # Norwegian, Neural "Ines", # Portuguese, European - "Ivy", - "Jacek", - "Jan", - "Joanna", - "Joey", - "Justin", + "Ivy", # English + "Jacek", # Polish + "Jan", # Polish + "Joanna", # English + "Joey", # English + "Justin", # English "Kajal", # English (Indian)/Hindi (Bilingual ), Neural - "Karl", - "Kendra", - "Kevin", - "Kimberly", + "Karl", # Icelandic + "Kendra", # English + "Kevin", # English, Neural + "Kimberly", # English "Laura", # Dutch, Neural "Lea", # French "Liam", # Canadian French, Neural @@ -84,12 +84,12 @@ SUPPORTED_VOICES: Final[list[str]] = [ "Lotte", # Dutch "Lucia", # Spanish European "Lupe", # Spanish US - "Mads", + "Mads", # Danish "Maja", # Polish - "Marlene", - "Mathieu", - "Matthew", - "Maxim", + "Marlene", # German + "Mathieu", # French + "Matthew", # English + "Maxim", # Russian "Mia", # Spanish Mexican "Miguel", # Spanish US "Mizuki", # Japanese @@ -100,17 +100,19 @@ SUPPORTED_VOICES: Final[list[str]] = [ "Penelope", # Spanish US "Pedro", # Spanish US, Neural "Raveena", # English, Indian - "Ricardo", - "Ruben", - "Russell", + "Ricardo", # Portuguese (Brazilian) + "Ruben", # Dutch + "Russell", # English (Australian) + "Ruth", # English, Neural "Salli", # English "Seoyeon", # Korean + "Stephen", # English, Neural "Suvi", # Finnish - "Takumi", + "Takumi", # Japanese "Tatyana", # Russian "Vicki", # German "Vitoria", # Portuguese, Brazilian - "Zeina", + "Zeina", # Arabic "Zhiyu", # Chinese ] 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/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 9162d7841d..43201aba77 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -20,13 +20,12 @@ from homeassistant.components.camera import ( from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream, async_aiohttp_proxy_web, async_get_clientsession, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -146,7 +145,7 @@ async def async_setup_platform( # with this version, update the old entity with the new unique id. serial_number = await device.api.async_serial_number serial_number = serial_number.strip() - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) entity_id = registry.async_get_entity_id(CAMERA_DOMAIN, DOMAIN, serial_number) if entity_id is not None: _LOGGER.debug("Updating unique id for camera %s", entity_id) 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..bdcf08bb2f --- /dev/null +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -0,0 +1,86 @@ +"""The Android TV Remote integration.""" +from __future__ import annotations + +import logging + +from androidtvremote2 import ( + AndroidTVRemote, + CannotConnect, + ConnectionClosed, + InvalidAuth, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME, 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 + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, 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]) + + @callback + def is_available_updated(is_available: bool) -> None: + if is_available: + _LOGGER.info( + "Reconnected to %s at %s", entry.data[CONF_NAME], entry.data[CONF_HOST] + ) + else: + _LOGGER.warning( + "Disconnected from %s at %s", + entry.data[CONF_NAME], + entry.data[CONF_HOST], + ) + + api.add_is_available_updated_callback(is_available_updated) + + 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/entity.py b/homeassistant/components/androidtv_remote/entity.py new file mode 100644 index 0000000000..862f317ee8 --- /dev/null +++ b/homeassistant/components/androidtv_remote/entity.py @@ -0,0 +1,84 @@ +"""Base entity for Android TV Remote.""" +from __future__ import annotations + +from androidtvremote2 import AndroidTVRemote, ConnectionClosed + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN + + +class AndroidTVRemoteBaseEntity(Entity): + """Android TV Remote Base Entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None: + """Initialize the entity.""" + 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_is_on = api.is_on + 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_available_updated(self, is_available: bool) -> None: + """Update the state when the device is ready to receive commands or is unavailable.""" + self._attr_available = is_available + self.async_write_ha_state() + + @callback + def _is_on_updated(self, is_on: bool) -> None: + """Update the state when device turns on or off.""" + self._attr_is_on = is_on + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self._api.add_is_available_updated_callback(self._is_available_updated) + self._api.add_is_on_updated_callback(self._is_on_updated) + + async def async_will_remove_from_hass(self) -> None: + """Remove callbacks.""" + self._api.remove_is_available_updated_callback(self._is_available_updated) + self._api.remove_is_on_updated_callback(self._is_on_updated) + + 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/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..9273e82a51 --- /dev/null +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "androidtv_remote", + "name": "Android TV Remote", + "codeowners": ["@tronikos", "@Drafteed"], + "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.8"], + "zeroconf": ["_androidtvremote2._tcp.local."] +} diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py new file mode 100644 index 0000000000..eccfc8ce25 --- /dev/null +++ b/homeassistant/components/androidtv_remote/media_player.py @@ -0,0 +1,198 @@ +"""Media player support for Android TV Remote.""" +from __future__ import annotations + +import asyncio +from typing import Any + +from androidtvremote2 import AndroidTVRemote, ConnectionClosed + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, +) +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 .const import DOMAIN +from .entity import AndroidTVRemoteBaseEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Android TV media player entity based on a config entry.""" + api: AndroidTVRemote = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([AndroidTVRemoteMediaPlayerEntity(api, config_entry)]) + + +class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEntity): + """Android TV Remote Media Player Entity.""" + + _attr_assumed_state = True + _attr_device_class = MediaPlayerDeviceClass.TV + _attr_supported_features = ( + MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.PLAY_MEDIA + ) + + def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None: + """Initialize the entity.""" + super().__init__(api, config_entry) + + # This task is needed to create a job that sends a key press + # sequence that can be canceled if concurrency occurs + self._channel_set_task: asyncio.Task | None = None + + def _update_current_app(self, current_app: str) -> None: + """Update current app info.""" + self._attr_app_id = current_app + self._attr_app_name = current_app + + def _update_volume_info(self, volume_info: dict[str, str | bool]) -> None: + """Update volume info.""" + if volume_info.get("max"): + self._attr_volume_level = int(volume_info["level"]) / int( + volume_info["max"] + ) + self._attr_is_volume_muted = bool(volume_info["muted"]) + else: + self._attr_volume_level = None + self._attr_is_volume_muted = None + + @callback + def _current_app_updated(self, current_app: str) -> None: + """Update the state when the current app changes.""" + self._update_current_app(current_app) + self.async_write_ha_state() + + @callback + def _volume_info_updated(self, volume_info: dict[str, str | bool]) -> None: + """Update the state when the volume info changes.""" + self._update_volume_info(volume_info) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + self._update_current_app(self._api.current_app) + self._update_volume_info(self._api.volume_info) + + self._api.add_current_app_updated_callback(self._current_app_updated) + self._api.add_volume_info_updated_callback(self._volume_info_updated) + + async def async_will_remove_from_hass(self) -> None: + """Remove callbacks.""" + await super().async_will_remove_from_hass() + + self._api.remove_current_app_updated_callback(self._current_app_updated) + self._api.remove_volume_info_updated_callback(self._volume_info_updated) + + @property + def state(self) -> MediaPlayerState: + """Return the state of the device.""" + if self._attr_is_on: + return MediaPlayerState.ON + return MediaPlayerState.OFF + + async def async_turn_on(self) -> None: + """Turn the Android TV on.""" + if not self._attr_is_on: + self._send_key_command("POWER") + + async def async_turn_off(self) -> None: + """Turn the Android TV off.""" + if self._attr_is_on: + self._send_key_command("POWER") + + async def async_volume_up(self) -> None: + """Turn volume up for media player.""" + self._send_key_command("VOLUME_UP") + + async def async_volume_down(self) -> None: + """Turn volume down for media player.""" + self._send_key_command("VOLUME_DOWN") + + async def async_mute_volume(self, mute: bool) -> None: + """Mute the volume.""" + if mute != self.is_volume_muted: + self._send_key_command("VOLUME_MUTE") + + async def async_media_play(self) -> None: + """Send play command.""" + self._send_key_command("MEDIA_PLAY") + + async def async_media_pause(self) -> None: + """Send pause command.""" + self._send_key_command("MEDIA_PAUSE") + + async def async_media_play_pause(self) -> None: + """Send play/pause command.""" + self._send_key_command("MEDIA_PLAY_PAUSE") + + async def async_media_stop(self) -> None: + """Send stop command.""" + self._send_key_command("MEDIA_STOP") + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + self._send_key_command("MEDIA_PREVIOUS") + + async def async_media_next_track(self) -> None: + """Send next track command.""" + self._send_key_command("MEDIA_NEXT") + + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: + """Play a piece of media.""" + if media_type == MediaType.CHANNEL: + if not media_id.isnumeric(): + raise ValueError(f"Channel must be numeric: {media_id}") + if self._channel_set_task: + self._channel_set_task.cancel() + self._channel_set_task = asyncio.create_task( + self._send_key_commands(list(media_id)) + ) + await self._channel_set_task + return + + if media_type == MediaType.URL: + self._send_launch_app_command(media_id) + return + + raise ValueError(f"Invalid media type: {media_type}") + + async def _send_key_commands( + self, key_codes: list[str], delay_secs: float = 0.1 + ) -> None: + """Send a key press sequence to Android TV. + + The delay is necessary because device may ignore + some commands if we send the sequence without delay. + """ + try: + for key_code in key_codes: + self._api.send_key_command(key_code) + await asyncio.sleep(delay_secs) + except ConnectionClosed as exc: + raise HomeAssistantError( + "Connection to Android TV device is closed" + ) from exc diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py new file mode 100644 index 0000000000..f4c2ae51ce --- /dev/null +++ b/homeassistant/components/androidtv_remote/remote.py @@ -0,0 +1,92 @@ +"""Remote control support for Android TV Remote.""" +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +from typing import Any + +from androidtvremote2 import AndroidTVRemote + +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.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import AndroidTVRemoteBaseEntity + +PARALLEL_UPDATES = 0 + + +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(AndroidTVRemoteBaseEntity, RemoteEntity): + """Android TV Remote Entity.""" + + _attr_supported_features = RemoteEntityFeature.ACTIVITY + + @callback + def _current_app_updated(self, current_app: str) -> None: + """Update the state when the current app changes.""" + self._attr_current_activity = current_app + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + self._attr_current_activity = self._api.current_app + self._api.add_current_app_updated_callback(self._current_app_updated) + + async def async_will_remove_from_hass(self) -> None: + """Remove callbacks.""" + await super().async_will_remove_from_hass() + + self._api.remove_current_app_updated_callback(self._current_app_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) 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..2fee8a6bee --- /dev/null +++ b/homeassistant/components/anova/__init__.py @@ -0,0 +1,78 @@ +"""The Anova integration.""" +from __future__ import annotations + +import logging + +from anova_wifi import AnovaApi, AnovaPrecisionCooker, 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.sensor.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..2e5505a9fd --- /dev/null +++ b/homeassistant/components/anova/coordinator.py @@ -0,0 +1,53 @@ +"""Support for Anova Coordinators.""" +from datetime import timedelta +import logging + +from anova_wifi import AnovaOffline, AnovaPrecisionCooker, APCUpdate +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[APCUpdate]): + """Anova custom coordinator.""" + + 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) -> APCUpdate: + 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..7c4509e2f2 --- /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.10.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..6336aa61e1 --- /dev/null +++ b/homeassistant/components/anova/sensor.py @@ -0,0 +1,125 @@ +"""Support for Anova Sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from anova_wifi import APCUpdateSensor + +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 + + +@dataclass +class AnovaSensorEntityDescriptionMixin: + """Describes the mixin variables for anova sensors.""" + + value_fn: Callable[[APCUpdateSensor], float | int | str] + + +@dataclass +class AnovaSensorEntityDescription( + SensorEntityDescription, AnovaSensorEntityDescriptionMixin +): + """Describes a Anova sensor.""" + + +SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ + AnovaSensorEntityDescription( + key="cook_time", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfTime.SECONDS, + icon="mdi:clock-outline", + translation_key="cook_time", + device_class=SensorDeviceClass.DURATION, + value_fn=lambda data: data.cook_time, + ), + AnovaSensorEntityDescription( + key="state", translation_key="state", value_fn=lambda data: data.state + ), + AnovaSensorEntityDescription( + key="mode", translation_key="mode", value_fn=lambda data: data.mode + ), + AnovaSensorEntityDescription( + key="target_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="target_temperature", + value_fn=lambda data: data.target_temperature, + ), + AnovaSensorEntityDescription( + key="cook_time_remaining", + native_unit_of_measurement=UnitOfTime.SECONDS, + icon="mdi:clock-outline", + translation_key="cook_time_remaining", + device_class=SensorDeviceClass.DURATION, + value_fn=lambda data: data.cook_time_remaining, + ), + AnovaSensorEntityDescription( + key="heater_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="heater_temperature", + value_fn=lambda data: data.heater_temperature, + ), + AnovaSensorEntityDescription( + key="triac_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="triac_temperature", + value_fn=lambda data: data.triac_temperature, + ), + AnovaSensorEntityDescription( + key="water_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="water_temperature", + value_fn=lambda data: data.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.""" + + entity_description: AnovaSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state.""" + return self.entity_description.value_fn(self.coordinator.data.sensor) diff --git a/homeassistant/components/anova/strings.json b/homeassistant/components/anova/strings.json new file mode 100644 index 0000000000..b14246a392 --- /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/api/__init__.py b/homeassistant/components/api/__init__.py index 56a07a6bcf..5c0a60ecef 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -1,5 +1,6 @@ """Rest API for Home Assistant.""" import asyncio +from functools import lru_cache from http import HTTPStatus import logging @@ -350,6 +351,12 @@ class APIComponentsView(HomeAssistantView): return self.json(request.app["hass"].config.components) +@lru_cache +def _cached_template(template_str: str, hass: ha.HomeAssistant) -> template.Template: + """Return a cached template.""" + return template.Template(template_str, hass) + + class APITemplateView(HomeAssistantView): """View to handle Template requests.""" @@ -362,7 +369,7 @@ class APITemplateView(HomeAssistantView): raise Unauthorized() try: data = await request.json() - tpl = template.Template(data["template"], request.app["hass"]) + tpl = _cached_template(data["template"], request.app["hass"]) return tpl.async_render(variables=data.get("variables"), parse_result=False) except (ValueError, TemplateError) as ex: return self.json_message( diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index d000c0346a..9b80d992cd 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -324,18 +324,29 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): all_identifiers = set(self.atv.all_identifiers) discovered_ip_address = str(self.atv.address) for entry in self._async_current_entries(): - if not all_identifiers.intersection( + existing_identifiers = set( entry.data.get(CONF_IDENTIFIERS, [entry.unique_id]) - ): + ) + if not all_identifiers.intersection(existing_identifiers): continue - if entry.data.get(CONF_ADDRESS) != discovered_ip_address: + combined_identifiers = existing_identifiers | all_identifiers + if entry.data.get( + CONF_ADDRESS + ) != discovered_ip_address or combined_identifiers != set( + entry.data.get(CONF_IDENTIFIERS, []) + ): self.hass.config_entries.async_update_entry( entry, - data={**entry.data, CONF_ADDRESS: discovered_ip_address}, - ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) + data={ + **entry.data, + CONF_ADDRESS: discovered_ip_address, + CONF_IDENTIFIERS: list(combined_identifiers), + }, ) + if entry.source != config_entries.SOURCE_IGNORE: + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) if not allow_exist: raise DeviceAlreadyConfigured() diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index aa73bcc7ba..c534c63531 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "iot_class": "local_push", "loggers": ["pyatv", "srptools"], - "requirements": ["pyatv==0.10.3"], + "requirements": ["pyatv==0.11.0"], "zeroconf": [ "_mediaremotetv._tcp.local.", "_companion-link._tcp.local.", 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/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index a462d433c7..453fc77351 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/apprise", "iot_class": "cloud_push", "loggers": ["apprise"], - "requirements": ["apprise==1.2.1"] + "requirements": ["apprise==1.3.0"] } diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index 6ac27b1652..4596a7fd8a 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -105,6 +105,13 @@ def sensor_update_to_bluetooth_data_update( adv: Aranet4Advertisement, ) -> PassiveBluetoothDataUpdate: """Convert a sensor update to a Bluetooth data update.""" + entity_names: dict[PassiveBluetoothEntityKey, str | None] = {} + for key, desc in SENSOR_DESCRIPTIONS.items(): + # PassiveBluetoothDataUpdate does not support DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + assert desc.name is not DEVICE_CLASS_NAME + entity_names[_device_key_to_bluetooth_entity_key(adv.device, key)] = desc.name return PassiveBluetoothDataUpdate( devices={adv.device.address: _sensor_device_info_to_hass(adv)}, entity_descriptions={ @@ -117,10 +124,7 @@ def sensor_update_to_bluetooth_data_update( ) for key in SENSOR_DESCRIPTIONS }, - entity_names={ - _device_key_to_bluetooth_entity_key(adv.device, key): desc.name - for key, desc in SENSOR_DESCRIPTIONS.items() - }, + entity_names=entity_names, ) diff --git a/homeassistant/components/arcam_fmj/device_trigger.py b/homeassistant/components/arcam_fmj/device_trigger.py index f3722c81ec..ecaec0e0e7 100644 --- a/homeassistant/components/arcam_fmj/device_trigger.py +++ b/homeassistant/components/arcam_fmj/device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -32,11 +32,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Arcam FMJ Receiver control devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain == "media_player": triggers.append( { 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/arest/sensor.py b/homeassistant/components/arest/sensor.py index 5c95fd63c3..2e6012e0e6 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -180,7 +180,7 @@ class ArestData: self._resource = resource self._pin = pin self.data = {} - self._attr_available = True + self.available = True @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): @@ -201,7 +201,7 @@ class ArestData: f"{self._resource}/digital/{self._pin}", timeout=10 ) self.data = {"value": response.json()["return_value"]} - self._attr_available = True + self.available = True except requests.exceptions.ConnectionError: _LOGGER.error("No route to device %s", self._resource) - self._attr_available = False + self.available = False diff --git a/homeassistant/components/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py index b456aa3f70..48b8d9f13c 100644 --- a/homeassistant/components/arris_tg2492lg/device_tracker.py +++ b/homeassistant/components/arris_tg2492lg/device_tracker.py @@ -33,7 +33,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> ArrisDeviceScanner: class ArrisDeviceScanner(DeviceScanner): - """This class queries a Arris TG2492LG router for connected devices.""" + """Class which queries a Arris TG2492LG router for connected devices.""" def __init__(self, connect_box: ConnectBox) -> None: """Initialize the scanner.""" diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index d0794553b4..7b8c547fd5 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -42,7 +42,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> ArubaDeviceScanner | class ArubaDeviceScanner(DeviceScanner): - """This class queries a Aruba Access Point for connected devices.""" + """Class which queries a Aruba Access Point for connected devices.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index 420ffb2d8a..d468a93eca 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -1,7 +1,6 @@ """Support for collecting data from the ARWN project.""" from __future__ import annotations -import json import logging from homeassistant.components import mqtt @@ -11,6 +10,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify +from homeassistant.util.json import json_loads_object _LOGGER = logging.getLogger(__name__) @@ -101,8 +101,13 @@ async def async_setup_platform( ) -> None: """Set up the ARWN platform.""" + # Make sure MQTT integration is enabled and the client is available + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return + @callback - def async_sensor_event_received(msg): + def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None: """Process events as sensors. When a new event on our topic (arwn/#) is received we map it @@ -115,7 +120,7 @@ async def async_setup_platform( This lets us dynamically incorporate sensors without any configuration on our side. """ - event = json.loads(msg.payload) + event = json_loads_object(msg.payload) sensors = discover_sensors(msg.topic, event) if not sensors: return 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..6a4bbdf61e --- /dev/null +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -0,0 +1,967 @@ +"""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, cast + +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 + intent_agent: str | None = None + tts_audio_output: str | None = None + + id: str = field(default_factory=ulid_util.ulid) + stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False) + tts_engine: str = field(init=False) + tts_options: dict | None = field(init=False, default=None) + + def __post_init__(self) -> None: + """Set language for pipeline.""" + self.language = self.pipeline.language or self.hass.config.language + + # stt -> intent -> tts + if PIPELINE_STAGE_ORDER.index(self.end_stage) < PIPELINE_STAGE_ORDER.index( + self.start_stage + ): + raise InvalidPipelineStagesError(self.start_stage, self.end_stage) + + pipeline_data: PipelineData = self.hass.data[DOMAIN] + if self.pipeline.id not in pipeline_data.pipeline_runs: + pipeline_data.pipeline_runs[self.pipeline.id] = LimitedSizeDict( + size_limit=STORED_PIPELINE_RUNS + ) + pipeline_data.pipeline_runs[self.pipeline.id][self.id] = PipelineRunDebug() + + @callback + def process_event(self, event: PipelineEvent) -> None: + """Log an event and call listener.""" + self.event_callback(event) + pipeline_data: PipelineData = self.hass.data[DOMAIN] + if self.id not in pipeline_data.pipeline_runs[self.pipeline.id]: + # This run has been evicted from the logged pipeline runs already + return + pipeline_data.pipeline_runs[self.pipeline.id][self.id].events.append(event) + + def start(self) -> None: + """Emit run start event.""" + data = { + "pipeline": self.pipeline.id, + "language": self.language, + } + if self.runner_data is not None: + data["runner_data"] = self.runner_data + + self.process_event(PipelineEvent(PipelineEventType.RUN_START, data)) + + def end(self) -> None: + """Emit run end event.""" + self.process_event( + PipelineEvent( + PipelineEventType.RUN_END, + ) + ) + + async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: + """Prepare speech to text.""" + # 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 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.""" + # pipeline.tts_engine can't be None or this function is not called + engine = cast(str, 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: + options_supported = await tts.async_support_options( + self.hass, + engine, + self.pipeline.tts_language, + tts_options, + ) + except HomeAssistantError as err: + raise TextToSpeechError( + code="tts-not-supported", + message=f"Text to speech engine '{engine}' not found", + ) from err + if not options_supported: + raise TextToSpeechError( + code="tts-not-supported", + message=( + f"Text to speech engine {engine} " + f"does not support language {self.pipeline.tts_language} or options {tts_options}" + ), + ) + + 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.""" + 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..d85eb1aaed --- /dev/null +++ b/homeassistant/components/assist_pipeline/strings.json @@ -0,0 +1,17 @@ +{ + "entity": { + "binary_sensor": { + "assist_in_progress": { + "name": "Assist in progress" + } + }, + "select": { + "pipeline": { + "name": "Assist pipeline", + "state": { + "preferred": "Preferred" + } + } + } + } +} diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py new file mode 100644 index 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/aten_pe/switch.py b/homeassistant/components/aten_pe/switch.py index d49201f6d7..cdf45db035 100644 --- a/homeassistant/components/aten_pe/switch.py +++ b/homeassistant/components/aten_pe/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from atenpdu import AtenPE, AtenPEError +from atenpdu import AtenPE, AtenPEError # pylint: disable=import-error import voluptuous as vol from homeassistant.components.switch import ( diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index 687afdab4c..ad9045a3d0 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -3,6 +3,7 @@ import asyncio import logging from aiohttp import ClientError +from yalexs.util import get_latest_activity from homeassistant.core import callback from homeassistant.helpers.debounce import Debouncer @@ -169,12 +170,11 @@ class ActivityStream(AugustSubscriberMixin): device_id = activity.device_id activity_type = activity.activity_type device_activities = self._latest_activities.setdefault(device_id, {}) - lastest_activity = device_activities.get(activity_type) - - # Ignore activities that are older than the latest one + # Ignore activities that are older than the latest one unless it is a non + # locking or unlocking activity with the exact same start time. if ( - lastest_activity - and lastest_activity.activity_start_time >= activity.activity_start_time + get_latest_activity(activity, device_activities.get(activity_type)) + != activity ): continue diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index d77a61a065..b11550dccd 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -5,7 +5,7 @@ from typing import Any from aiohttp import ClientResponseError from yalexs.activity import SOURCE_PUBNUB, ActivityType from yalexs.lock import LockStatus -from yalexs.util import update_lock_detail_from_activity +from yalexs.util import get_latest_activity, update_lock_detail_from_activity from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity from homeassistant.config_entries import ConfigEntry @@ -90,17 +90,26 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): @callback def _update_from_data(self): """Get the latest state of the sensor and update activity.""" - lock_activity = self._data.activity_stream.get_latest_device_activity( - self._device_id, - {ActivityType.LOCK_OPERATION, ActivityType.LOCK_OPERATION_WITHOUT_OPERATOR}, + activity_stream = self._data.activity_stream + device_id = self._device_id + if lock_activity := activity_stream.get_latest_device_activity( + device_id, + {ActivityType.LOCK_OPERATION}, + ): + self._attr_changed_by = lock_activity.operated_by + + lock_activity_without_operator = activity_stream.get_latest_device_activity( + device_id, + {ActivityType.LOCK_OPERATION_WITHOUT_OPERATOR}, ) - if lock_activity is not None: - self._attr_changed_by = lock_activity.operated_by - update_lock_detail_from_activity(self._detail, lock_activity) - # If the source is pubnub the lock must be online since its a live update - if lock_activity.source == SOURCE_PUBNUB: + if latest_activity := get_latest_activity( + lock_activity_without_operator, lock_activity + ): + if latest_activity.source == SOURCE_PUBNUB: + # If the source is pubnub the lock must be online since its a live update self._detail.set_online(True) + update_lock_detail_from_activity(self._detail, latest_activity) bridge_activity = self._data.activity_stream.get_latest_device_activity( self._device_id, {ActivityType.BRIDGE_OPERATION} diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index e8b00ac921..9f766c91df 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.0.1"] + "requirements": ["yalexs==1.3.3", "yalexs-ble==2.1.17"] } 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/aurora/manifest.json b/homeassistant/components/aurora/manifest.json index a5bb332733..018e8ab813 100644 --- a/homeassistant/components/aurora/manifest.json +++ b/homeassistant/components/aurora/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aurora", "iot_class": "cloud_polling", "loggers": ["auroranoaa"], - "requirements": ["auroranoaa==0.0.2"] + "requirements": ["auroranoaa==0.0.3"] } diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index 2710badb51..de5e566e26 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -1,5 +1,5 @@ """Support for Aurora Forecast sensor.""" -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant @@ -28,6 +28,7 @@ class AuroraSensor(AuroraEntity, SensorEntity): """Implementation of an aurora sensor.""" _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT @property def native_value(self): diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 305a42d4dc..b5dc236dfa 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -7,6 +7,8 @@ # Developer note: # vscode devcontainer: use the following to access USB device: # "runArgs": ["-e", "GIT_EDITOR=code --wait", "--device=/dev/ttyUSB0"], +# and add the following to the end of script/bootstrap: +# sudo chmod 777 /dev/ttyUSB0 import logging 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/automation/strings.json b/homeassistant/components/automation/strings.json index ea03868e63..4e433119a2 100644 --- a/homeassistant/components/automation/strings.json +++ b/homeassistant/components/automation/strings.json @@ -1,9 +1,35 @@ { "title": "Automation", - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "name": "[%key:component::automation::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "current": { + "name": "Running automations" + }, + "id": { + "name": "ID" + }, + "last_triggered": { + "name": "Last triggered" + }, + "max": { + "name": "Max running automations" + }, + "mode": { + "name": "Run mode", + "state": { + "parallel": "Parallel", + "queued": "Queued", + "restart": "Restart", + "single": "Single" + } + } + } } }, "issues": { diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index dc48e0f92c..f42a46999f 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -1,7 +1,7 @@ """Support for Awair sensors.""" from __future__ import annotations -from typing import cast +from typing import Any, cast from python_awair.air_data import AirData from python_awair.devices import AwairBaseDevice, AwairLocalDevice @@ -156,7 +156,7 @@ class AwairSensor(CoordinatorEntity[AwairDataUpdateCoordinator], SensorEntity): return round(state, 2) @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the Awair Index alongside state attributes. The Awair Index is a subjective score ranging from 0-4 (inclusive) that @@ -178,7 +178,7 @@ class AwairSensor(CoordinatorEntity[AwairDataUpdateCoordinator], SensorEntity): https://docs.developer.getawair.com/?version=latest#awair-score-and-index """ sensor_type = self.entity_description.key - attrs: dict = {} + attrs: dict[str, Any] = {} if not self._air_data: return attrs if sensor_type in self._air_data.indices: 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/axis/device.py b/homeassistant/components/axis/device.py index 1a65b31ef7..f53e69fba9 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -218,7 +218,7 @@ class AxisNetworkDevice: """Stop stream.""" if self.api.stream.state != State.STOPPED: self.api.stream.connection_status_callback.clear() - self.api.stream.stop() + self.api.stream.stop() async def shutdown(self, event) -> None: """Stop the event stream.""" diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 7b4e9be030..296a3da8b6 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -26,7 +26,7 @@ "iot_class": "local_push", "loggers": ["axis"], "quality_scale": "platinum", - "requirements": ["axis==46"], + "requirements": ["axis==48"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py index 01c0cd123e..7c63b9ffaf 100644 --- a/homeassistant/components/azure_devops/__init__.py +++ b/homeassistant/components/azure_devops/__init__.py @@ -66,7 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PROJECT], BUILDS_QUERY, ) - except (aiohttp.ClientError, aiohttp.ClientError) as exception: + except aiohttp.ClientError as exception: raise UpdateFailed from exception coordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index 30417a7f94..4e69bea1a3 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -155,7 +155,6 @@ class AzureEventHub: Suppress the INFO and below logging on the underlying packages, they are very verbose, even at INFO. """ - logging.getLogger("uamqp").setLevel(logging.WARNING) logging.getLogger("azure.eventhub").setLevel(logging.WARNING) self._listener_remover = self.hass.bus.async_listen( MATCH_ALL, self.async_listen diff --git a/homeassistant/components/azure_service_bus/manifest.json b/homeassistant/components/azure_service_bus/manifest.json index 1ec3c690c4..059f6300ae 100644 --- a/homeassistant/components/azure_service_bus/manifest.json +++ b/homeassistant/components/azure_service_bus/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/azure_service_bus", "iot_class": "cloud_push", "loggers": ["azure"], - "requirements": ["azure-servicebus==7.8.0"] + "requirements": ["azure-servicebus==7.10.0"] } 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 e38312dd6e..1f8b70f4d3 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -9,7 +9,7 @@ from pathlib import Path import tarfile from tarfile import TarError from tempfile import TemporaryDirectory -from typing import Any, Protocol +from typing import Any, Protocol, cast from securetar import SecureTarFile, atomic_contents_add @@ -19,11 +19,14 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import integration_platform from homeassistant.helpers.json import save_json from homeassistant.util import dt +from homeassistant.util.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.""" @@ -98,13 +101,13 @@ 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(data_file.read()) + data = json_loads_object(data_file.read()) backup = Backup( - slug=data["slug"], - name=data["name"], - date=data["date"], + slug=cast(str, data["slug"]), + name=cast(str, data["name"]), + date=cast(str, data["date"]), path=backup_path, size=round(backup_path.stat().st_size / 1_048_576, 2), ) @@ -186,13 +189,8 @@ class BackupManager: "compressed": True, } tar_file_path = Path(self.backup_dir, f"{backup_data['slug']}.tar") - - if not self.backup_dir.exists(): - LOGGER.debug("Creating backup directory") - self.hass.async_add_executor_job(self.backup_dir.mkdir) - - await self.hass.async_add_executor_job( - self._generate_backup_contents, + size_in_bytes = await self.hass.async_add_executor_job( + self._mkdir_and_generate_backup_contents, tar_file_path, backup_data, ) @@ -201,7 +199,7 @@ class BackupManager: name=backup_name, date=date_str, path=tar_file_path, - size=round(tar_file_path.stat().st_size / 1_048_576, 2), + size=round(size_in_bytes / 1_048_576, 2), ) if self.loaded_backups: self.backups[slug] = backup @@ -220,14 +218,18 @@ class BackupManager: if isinstance(result, Exception): raise result - def _generate_backup_contents( + def _mkdir_and_generate_backup_contents( self, tar_file_path: Path, backup_data: dict[str, Any], - ) -> None: - """Generate backup contents.""" + ) -> int: + """Generate backup contents and return the size.""" + if not self.backup_dir.exists(): + LOGGER.debug("Creating backup directory") + 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, @@ -245,6 +248,7 @@ class BackupManager: arcname="data", ) tar_file.add(tmp_dir_path, arcname=".") + return tar_file_path.stat().st_size def _generate_slug(date: str, name: str) -> str: 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/baf/fan.py b/homeassistant/components/baf/fan.py index 360926363a..a166c346f1 100644 --- a/homeassistant/components/baf/fan.py +++ b/homeassistant/components/baf/fan.py @@ -39,7 +39,11 @@ async def async_setup_entry( class BAFFan(BAFEntity, FanEntity): """BAF ceiling fan component.""" - _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.DIRECTION + | FanEntityFeature.PRESET_MODE + ) _attr_preset_modes = [PRESET_MODE_AUTO] _attr_speed_count = SPEED_COUNT diff --git a/homeassistant/components/baf/manifest.json b/homeassistant/components/baf/manifest.json index 8229a912fb..b5b5b76967 100644 --- a/homeassistant/components/baf/manifest.json +++ b/homeassistant/components/baf/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/baf", "iot_class": "local_push", - "requirements": ["aiobafi6==0.7.3"], + "requirements": ["aiobafi6==0.8.0"], "zeroconf": [ { "type": "_api._tcp.local.", diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index ca4b591cf9..020f34fefa 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -27,7 +27,6 @@ class BAFNumberDescriptionMixin: """Required values for BAF sensors.""" value_fn: Callable[[Device], int | None] - mode: NumberMode @dataclass @@ -39,6 +38,7 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_min_speed", name="Auto Comfort Minimum Speed", + native_step=1, native_min_value=0, native_max_value=SPEED_RANGE[1] - 1, entity_category=EntityCategory.CONFIG, @@ -48,6 +48,7 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_max_speed", name="Auto Comfort Maximum Speed", + native_step=1, native_min_value=1, native_max_value=SPEED_RANGE[1], entity_category=EntityCategory.CONFIG, @@ -57,6 +58,7 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_heat_assist_speed", name="Auto Comfort Heat Assist Speed", + native_step=1, native_min_value=SPEED_RANGE[0], native_max_value=SPEED_RANGE[1], entity_category=EntityCategory.CONFIG, @@ -69,6 +71,7 @@ FAN_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="return_to_auto_timeout", name="Return to Auto Timeout", + native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=HALF_DAY_SECS, entity_category=EntityCategory.CONFIG, @@ -79,6 +82,7 @@ FAN_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="motion_sense_timeout", name="Motion Sense Timeout", + native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=ONE_DAY_SECS, entity_category=EntityCategory.CONFIG, @@ -92,6 +96,7 @@ LIGHT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="light_return_to_auto_timeout", name="Light Return to Auto Timeout", + native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=HALF_DAY_SECS, entity_category=EntityCategory.CONFIG, @@ -102,6 +107,7 @@ LIGHT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="light_auto_motion_timeout", name="Light Motion Sense Timeout", + native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=ONE_DAY_SECS, entity_category=EntityCategory.CONFIG, @@ -140,7 +146,6 @@ class BAFNumber(BAFEntity, NumberEntity): self.entity_description = description super().__init__(device, f"{device.name} {description.name}") self._attr_unique_id = f"{self._device.mac_address}-{description.key}" - self._attr_mode = description.mode @callback def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/balboa/entity.py b/homeassistant/components/balboa/entity.py index e50c35db47..1b2c8d48f0 100644 --- a/homeassistant/components/balboa/entity.py +++ b/homeassistant/components/balboa/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from pybalboa import EVENT_UPDATE, SpaClient from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceClassName, DeviceInfo, Entity from .const import DOMAIN @@ -12,7 +12,9 @@ from .const import DOMAIN class BalboaBaseEntity(Entity): """Balboa base entity.""" - def __init__(self, client: SpaClient, name: str | None = None) -> None: + def __init__( + self, client: SpaClient, name: str | DeviceClassName | None = None + ) -> None: """Initialize the control.""" mac = client.mac_address model = client.model diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json index b81c681f82..152a89bde3 100644 --- a/homeassistant/components/balboa/manifest.json +++ b/homeassistant/components/balboa/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/balboa", "iot_class": "local_push", "loggers": ["pybalboa"], - "requirements": ["pybalboa==1.0.0"] + "requirements": ["pybalboa==1.0.1"] } diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 77571e1a80..06baef1bd0 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -60,7 +60,7 @@ from .const import ( DEFAULT_PROBABILITY_THRESHOLD, ) from .helpers import Observation -from .repairs import raise_mirrored_entries, raise_no_prob_given_false +from .issues import raise_mirrored_entries, raise_no_prob_given_false _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bayesian/repairs.py b/homeassistant/components/bayesian/issues.py similarity index 83% rename from homeassistant/components/bayesian/repairs.py rename to homeassistant/components/bayesian/issues.py index 9a52763694..fbc3a86258 100644 --- a/homeassistant/components/bayesian/repairs.py +++ b/homeassistant/components/bayesian/issues.py @@ -1,8 +1,8 @@ -"""Helpers for generating repairs.""" +"""Helpers for generating issues.""" from __future__ import annotations from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry +from homeassistant.helpers import issue_registry as ir from . import DOMAIN from .helpers import Observation @@ -15,13 +15,13 @@ def raise_mirrored_entries( if len(observations) != 2: return if observations[0].is_mirror(observations[1]): - issue_registry.async_create_issue( + ir.async_create_issue( hass, DOMAIN, "mirrored_entry/" + text, breaks_in_ha_version="2022.10.0", is_fixable=False, - severity=issue_registry.IssueSeverity.WARNING, + severity=ir.IssueSeverity.WARNING, translation_key="manual_migration", translation_placeholders={"entity": text}, learn_more_url="https://github.com/home-assistant/core/pull/67631", @@ -31,13 +31,13 @@ def raise_mirrored_entries( # Should deprecate in some future version (2022.10 at time of writing) & make prob_given_false required in schemas. def raise_no_prob_given_false(hass: HomeAssistant, text: str) -> None: """In previous 2022.9 and earlier, prob_given_false was optional and had a default version.""" - issue_registry.async_create_issue( + ir.async_create_issue( hass, DOMAIN, f"no_prob_given_false/{text}", breaks_in_ha_version="2022.10.0", is_fixable=False, - severity=issue_registry.IssueSeverity.ERROR, + severity=ir.IssueSeverity.ERROR, translation_key="no_prob_given_false", translation_placeholders={"entity": text}, learn_more_url="https://github.com/home-assistant/core/pull/67631", diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py index a9b0312673..9c83aaa173 100644 --- a/homeassistant/components/bbox/device_tracker.py +++ b/homeassistant/components/bbox/device_tracker.py @@ -42,7 +42,7 @@ Device = namedtuple("Device", ["mac", "name", "ip", "last_update"]) class BboxDeviceScanner(DeviceScanner): - """This class scans for devices connected to the bbox.""" + """Scanner for devices connected to the bbox.""" def __init__(self, config): """Get host from config.""" diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 5d17fb92cb..f2bbc72e7a 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -106,114 +106,195 @@ "turned_off": "{entity_name} turned off" } }, - "state": { + "entity_component": { + "_": { + "name": "[%key:component::binary_sensor::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, "battery": { - "off": "Normal", - "on": "Low" + "name": "Battery", + "state": { + "off": "Normal", + "on": "Low" + } }, "battery_charging": { - "off": "Not charging", - "on": "Charging" + "name": "Charging", + "state": { + "off": "Not charging", + "on": "Charging" + } }, "carbon_monoxide": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "name": "Carbon monoxide", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "cold": { - "off": "[%key:component::binary_sensor::state::battery::off%]", - "on": "Cold" + "name": "Cold", + "state": { + "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "on": "Cold" + } }, "connectivity": { - "off": "[%key:common::state::disconnected%]", - "on": "[%key:common::state::connected%]" + "name": "Connectivity", + "state": { + "off": "[%key:common::state::disconnected%]", + "on": "[%key:common::state::connected%]" + } }, "door": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" + "name": "Door", + "state": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + } }, "garage_door": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" + "name": "Garage door", + "state": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + } }, "gas": { - "off": "Clear", - "on": "Detected" + "name": "Gas", + "state": { + "off": "Clear", + "on": "Detected" + } }, "heat": { - "off": "[%key:component::binary_sensor::state::battery::off%]", - "on": "Hot" + "name": "Heat", + "state": { + "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "on": "Hot" + } }, "light": { - "off": "No light", - "on": "Light detected" + "name": "Light", + "state": { + "off": "No light", + "on": "Light detected" + } }, "lock": { - "off": "[%key:common::state::locked%]", - "on": "[%key:common::state::unlocked%]" + "name": "Lock", + "state": { + "off": "[%key:common::state::locked%]", + "on": "[%key:common::state::unlocked%]" + } }, "moisture": { - "off": "Dry", - "on": "Wet" + "name": "Moisture", + "state": { + "off": "Dry", + "on": "Wet" + } }, "motion": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "name": "Motion", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "moving": { - "off": "Not moving", - "on": "Moving" + "name": "Moving", + "state": { + "off": "Not moving", + "on": "Moving" + } }, "occupancy": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "name": "Occupancy", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "opening": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" + "name": "Opening", + "state": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + } }, "plug": { - "off": "Unplugged", - "on": "Plugged in" + "name": "Plug", + "state": { + "off": "Unplugged", + "on": "Plugged in" + } }, "presence": { - "off": "[%key:component::device_tracker::state::_::not_home%]", - "on": "[%key:component::device_tracker::state::_::home%]" + "name": "Presence", + "state": { + "off": "[%key:component::device_tracker::entity_component::_::state::not_home%]", + "on": "[%key:component::device_tracker::entity_component::_::state::home%]" + } }, "problem": { - "off": "OK", - "on": "Problem" + "name": "Problem", + "state": { + "off": "OK", + "on": "Problem" + } }, "running": { - "off": "Not running", - "on": "Running" + "name": "Running", + "state": { + "off": "Not running", + "on": "Running" + } }, "safety": { - "off": "Safe", - "on": "Unsafe" + "name": "Safety", + "state": { + "off": "Safe", + "on": "Unsafe" + } }, "smoke": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "name": "Smoke", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "sound": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "name": "Sound", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "update": { - "off": "Up-to-date", - "on": "Update available" + "name": "Update", + "state": { + "off": "Up-to-date", + "on": "Update available" + } }, "vibration": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "name": "Vibration", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "window": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" - }, - "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "name": "Window", + "state": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + } } }, "device_class": { diff --git a/homeassistant/components/blackbird/manifest.json b/homeassistant/components/blackbird/manifest.json index fd7cc76aad..d75b69dfaf 100644 --- a/homeassistant/components/blackbird/manifest.json +++ b/homeassistant/components/blackbird/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/blackbird", "iot_class": "local_polling", "loggers": ["pyblackbird"], - "requirements": ["pyblackbird==0.5"] + "requirements": ["pyblackbird==0.6"] } 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 78e713ce5e..5fa05b87cc 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -106,6 +106,8 @@ class ActiveBluetoothDataUpdateCoordinator( def needs_poll(self, service_info: BluetoothServiceInfoBleak) -> bool: """Return true if time to try and poll.""" + if self.hass.is_stopping: + return False poll_age: float | None = None if self._last_poll: poll_age = monotonic_time_coarse() - self._last_poll @@ -141,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() @@ -167,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 b91ac2cbf4..8e38191c82 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -99,6 +99,8 @@ class ActiveBluetoothProcessorCoordinator( def needs_poll(self, service_info: BluetoothServiceInfoBleak) -> bool: """Return true if time to try and poll.""" + if self.hass.is_stopping: + return False poll_age: float | None = None if self._last_poll: poll_age = monotonic_time_coarse() - self._last_poll @@ -134,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: @@ -156,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 00cc9fff0f..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 @@ -165,13 +168,13 @@ class BaseHaScanner(ABC): "monotonic_time": MONOTONIC_TIME(), "discovered_devices_and_advertisement_data": [ { - "name": device_adv[0].name, - "address": device_adv[0].address, - "rssi": device_adv[0].rssi, - "advertisement_data": device_adv[1], - "details": device_adv[0].details, + "name": device.name, + "address": device.address, + "rssi": advertisement_data.rssi, + "advertisement_data": advertisement_data, + "details": device.details, } - for device_adv in device_adv_datas + for device, advertisement_data in device_adv_datas ], } @@ -224,23 +227,27 @@ 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._save_history + EVENT_HOMEASSISTANT_STOP, self._async_save_history ) self._async_setup_scanner_watchdog() @hass_callback def _cancel() -> None: - self._save_history() + self._async_save_history() self._async_stop_scanner_watchdog() cancel_track() cancel_stop() return _cancel - def _save_history(self, event: Event | None = None) -> None: + @hass_callback + def _async_save_history(self, event: Event | None = None) -> None: """Save the history.""" self._storage.async_set_advertisement_history( self.source, @@ -252,6 +259,7 @@ class BaseHaRemoteScanner(BaseHaScanner): ), ) + @hass_callback def _async_expire_devices(self, _datetime: datetime.datetime) -> None: """Expire old devices.""" now = MONOTONIC_TIME() @@ -301,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( # type: ignore[no-untyped-call] - 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, @@ -350,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..3210822e79 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -18,6 +18,7 @@ from bluetooth_adapters import ( ) from homeassistant import config_entries +from homeassistant.components.logger import EVENT_LOGGING_CHANGED from homeassistant.core import ( CALLBACK_TYPE, Event, @@ -113,6 +114,7 @@ class BluetoothManager: self.hass = hass self._integration_matcher = integration_matcher self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None + self._cancel_logging_listener: CALLBACK_TYPE | None = None self._advertisement_tracker = AdvertisementTracker() @@ -136,6 +138,7 @@ class BluetoothManager: self._bluetooth_adapters = bluetooth_adapters self.storage = storage self.slot_manager = slot_manager + self._debug = _LOGGER.isEnabledFor(logging.DEBUG) @property def supports_passive_scan(self) -> bool: @@ -201,6 +204,11 @@ class BluetoothManager: self._adapters = self._bluetooth_adapters.adapters return self._find_adapter_by_address(address) + @hass_callback + def _async_logging_changed(self, event: Event) -> None: + """Handle logging change.""" + self._debug = _LOGGER.isEnabledFor(logging.DEBUG) + async def async_setup(self) -> None: """Set up the bluetooth manager.""" await self._bluetooth_adapters.refresh() @@ -208,6 +216,9 @@ class BluetoothManager: self._all_history, self._connectable_history = async_load_history_from_system( self._bluetooth_adapters, self.storage ) + self._cancel_logging_listener = self.hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_logging_changed + ) self.async_setup_unavailable_tracking() seen: set[str] = set() for address, service_info in itertools.chain( @@ -225,6 +236,9 @@ class BluetoothManager: if self._cancel_unavailable_tracking: self._cancel_unavailable_tracking() self._cancel_unavailable_tracking = None + if self._cancel_logging_listener: + self._cancel_logging_listener() + self._cancel_logging_listener = None uninstall_multiple_bleak_catcher() @hass_callback @@ -276,6 +290,7 @@ class BluetoothManager: self.hass, self._async_check_unavailable, timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), + name="Bluetooth manager unavailable tracking", ) @hass_callback @@ -341,7 +356,6 @@ class BluetoothManager: self, old: BluetoothServiceInfoBleak, new: BluetoothServiceInfoBleak, - debug: bool, ) -> bool: """Prefer previous advertisement from a different source if it is better.""" if new.time - old.time > ( @@ -350,7 +364,7 @@ class BluetoothManager: ) ): # If the old advertisement is stale, any new advertisement is preferred - if debug: + if self._debug: _LOGGER.debug( ( "%s (%s): Switching from %s to %s (time elapsed:%s > stale" @@ -369,7 +383,7 @@ class BluetoothManager: ): # If new advertisement is RSSI_SWITCH_THRESHOLD more, # the new one is preferred. - if debug: + if self._debug: _LOGGER.debug( ( "%s (%s): Switching from %s to %s (new rssi:%s - threshold:%s >" @@ -413,7 +427,6 @@ class BluetoothManager: old_connectable_service_info = connectable and connectable_history.get(address) source = service_info.source - debug = _LOGGER.isEnabledFor(logging.DEBUG) # This logic is complex due to the many combinations of scanners # that are supported. # @@ -436,7 +449,7 @@ class BluetoothManager: and (scanner := self._sources.get(old_service_info.source)) and scanner.scanning and self._prefer_previous_adv_from_different_source( - old_service_info, service_info, debug + old_service_info, service_info ) ): # If we are rejecting the new advertisement and the device is connectable @@ -460,7 +473,7 @@ class BluetoothManager: ) and connectable_scanner.scanning and self._prefer_previous_adv_from_different_source( - old_connectable_service_info, service_info, debug + old_connectable_service_info, service_info ) ) ): @@ -522,7 +535,7 @@ class BluetoothManager: ) matched_domains = self._integration_matcher.match_domains(service_info) - if debug: + if self._debug: _LOGGER.debug( "%s: %s %s match: %s", self._async_describe_source(service_info), diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 8331117c9c..e7f95a2b63 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -4,7 +4,7 @@ "after_dependencies": ["hassio"], "codeowners": ["@bdraco"], "config_flow": true, - "dependencies": ["usb"], + "dependencies": ["logger", "usb"], "documentation": "https://www.home-assistant.io/integrations/bluetooth", "iot_class": "local_push", "loggers": [ @@ -15,11 +15,11 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.19.5", - "bleak-retry-connector==2.13.0", - "bluetooth-adapters==0.15.2", - "bluetooth-auto-recovery==1.0.3", - "bluetooth-data-tools==0.3.1", - "dbus-fast==1.84.1" + "bleak==0.20.2", + "bleak-retry-connector==3.0.2", + "bluetooth-adapters==0.15.3", + "bluetooth-auto-recovery==1.2.0", + "bluetooth-data-tools==0.4.0", + "dbus-fast==1.86.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/scanner.py b/homeassistant/components/bluetooth/scanner.py index a80386c25e..911862a422 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -91,12 +91,16 @@ def create_bleak_scanner( "detection_callback": detection_callback, "scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode], } - if platform.system() == "Linux": + system = platform.system() + if system == "Linux": # Only Linux supports multiple adapters if adapter: scanner_kwargs["adapter"] = adapter if scanning_mode == BluetoothScanningMode.PASSIVE: scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS + elif system == "Darwin": + # We want mac address on macOS + scanner_kwargs["cb"] = {"use_bdaddr": True} _LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs) try: 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 6b463423c7..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.""" @@ -224,25 +224,48 @@ class HaBleakClientWrapper(BleakClient): self.__disconnected_callback = callback if self._backend: self._backend.set_disconnected_callback( - callback, # type: ignore[arg-type] + self._make_disconnected_callback(callback), **kwargs, ) + def _make_disconnected_callback( + self, callback: Callable[[BleakClient], None] | None + ) -> Callable[[], None] | None: + """Make the disconnected callback. + + https://github.com/hbldh/bleak/pull/1256 + The disconnected callback needs to get the top level + BleakClientWrapper instance, not the backend instance. + + The signature of the callback for the backend is: + Callable[[], None] + + To make this work we need to wrap the callback in a partial + that passes the BleakClientWrapper instance as the first + argument. + """ + return None if callback is None else partial(callback, self) + async def connect(self, **kwargs: Any) -> bool: """Connect to the specified GATT server.""" 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, - disconnected_callback=self.__disconnected_callback, + device, + disconnected_callback=self._make_disconnected_callback( + self.__disconnected_callback + ), timeout=self.__timeout, hass=manager.hass, ) 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: @@ -251,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..8d5d842e91 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -41,6 +41,8 @@ PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.LOCK, Platform.NOTIFY, + Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index df25efb6d5..640f4e3653 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -189,6 +189,14 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( icon="mdi:car-electric", value_fn=lambda v: v.fuel_and_battery.is_charger_connected, ), + BMWBinarySensorEntityDescription( + key="is_pre_entry_climatization_enabled", + name="Pre entry climatization", + icon="mdi:car-seat-heater", + value_fn=lambda v: v.charging_profile.is_pre_entry_climatization_enabled + if v.charging_profile + else False, + ), ) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 4f05794e31..926706397a 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -1,11 +1,13 @@ """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 from bimmer_connected.api.regions import get_region_from_name -from httpx import HTTPError +from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from httpx import RequestError import voluptuous as vol from homeassistant import config_entries, core, exceptions @@ -14,7 +16,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from . import DOMAIN -from .const import CONF_ALLOWED_REGIONS, CONF_READ_ONLY, CONF_REFRESH_TOKEN +from .const import CONF_ALLOWED_REGIONS, CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN DATA_SCHEMA = vol.Schema( { @@ -40,13 +42,17 @@ async def validate_input( try: await auth.login() - except HTTPError as ex: + except MyBMWAuthError as ex: + raise InvalidAuth from ex + except (MyBMWAPIError, RequestError) as ex: raise CannotConnect from ex # Return info that you want to store in the config entry. retval = {"title": f"{data[CONF_USERNAME]}{data.get(CONF_SOURCE, '')}"} if auth.refresh_token: retval[CONF_REFRESH_TOKEN] = auth.refresh_token + if auth.gcid: + retval[CONF_GCID] = auth.gcid return retval @@ -55,36 +61,64 @@ 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), + CONF_GCID: info.get(CONF_GCID), + } except CannotConnect: errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" 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( @@ -134,3 +168,7 @@ class BMWOptionsFlow(config_entries.OptionsFlowWithConfigEntry): class CannotConnect(exceptions.HomeAssistantError): """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py index 50634ebdb9..37225fc052 100644 --- a/homeassistant/components/bmw_connected_drive/const.py +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -11,6 +11,7 @@ CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"] CONF_READ_ONLY = "read_only" CONF_ACCOUNT = "account" CONF_REFRESH_TOKEN = "refresh_token" +CONF_GCID = "gcid" DATA_HASS_CONFIG = "hass_config" diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 0f03505ff2..f635442231 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -6,15 +6,16 @@ import logging from bimmer_connected.account import MyBMWAccount from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.models import GPSPosition -from httpx import HTTPError, HTTPStatusError, TimeoutException +from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError +from httpx import RequestError 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 +from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN DEFAULT_SCAN_INTERVAL_SECONDS = 300 SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS) @@ -40,7 +41,10 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): self._entry = entry if CONF_REFRESH_TOKEN in entry.data: - self.account.set_refresh_token(entry.data[CONF_REFRESH_TOKEN]) + self.account.set_refresh_token( + refresh_token=entry.data[CONF_REFRESH_TOKEN], + gcid=entry.data.get(CONF_GCID), + ) super().__init__( hass, @@ -55,19 +59,12 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): try: await self.account.get_vehicles() - except (HTTPError, HTTPStatusError, TimeoutException) as err: - if isinstance(err, HTTPStatusError) and err.response.status_code == 429: - # Increase scan interval to not jump to not bring up the issue next time - self.update_interval = timedelta( - seconds=DEFAULT_SCAN_INTERVAL_SECONDS * 3 - ) - if isinstance(err, HTTPStatusError) and err.response.status_code in ( - 401, - 403, - ): - # Clear refresh token only on issues with authorization - self._update_config_entry_refresh_token(None) - raise UpdateFailed(f"Error communicating with BMW API: {err}") from err + except MyBMWAuthError as err: + # Clear refresh token and trigger reauth + self._update_config_entry_refresh_token(None) + raise ConfigEntryAuthFailed(err) from err + except (MyBMWAPIError, RequestError) as err: + raise UpdateFailed(err) from err if self.account.refresh_token != old_refresh_token: self._update_config_entry_refresh_token(self.account.refresh_token) @@ -77,9 +74,6 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): self.account.refresh_token, ) - # Reset scan interval after successful update - self.update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS) - def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None: """Update or delete the refresh_token in the Config Entry.""" data = { diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index cafaced522..c600a1529a 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.5"] } diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py new file mode 100644 index 0000000000..820257f616 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -0,0 +1,118 @@ +"""Number platform for BMW.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from bimmer_connected.models import MyBMWAPIError +from bimmer_connected.vehicle import MyBMWVehicle + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BMWBaseEntity +from .const import DOMAIN +from .coordinator import BMWDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BMWRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[MyBMWVehicle], float | int | None] + remote_service: Callable[[MyBMWVehicle, float | int], Coroutine[Any, Any, Any]] + + +@dataclass +class BMWNumberEntityDescription(NumberEntityDescription, BMWRequiredKeysMixin): + """Describes BMW number entity.""" + + is_available: Callable[[MyBMWVehicle], bool] = lambda _: False + dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None + + +NUMBER_TYPES: list[BMWNumberEntityDescription] = [ + BMWNumberEntityDescription( + key="target_soc", + name="Target SoC", + device_class=NumberDeviceClass.BATTERY, + is_available=lambda v: v.is_remote_set_target_soc_enabled, + native_max_value=100.0, + native_min_value=20.0, + native_step=5.0, + mode=NumberMode.SLIDER, + value_fn=lambda v: v.fuel_and_battery.charging_target, + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + target_soc=int(o) + ), + icon="mdi:battery-charging-medium", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MyBMW number from config entry.""" + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWNumber] = [] + + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.extend( + [ + BMWNumber(coordinator, vehicle, description) + for description in NUMBER_TYPES + if description.is_available(vehicle) + ] + ) + async_add_entities(entities) + + +class BMWNumber(BMWBaseEntity, NumberEntity): + """Representation of BMW Number entity.""" + + entity_description: BMWNumberEntityDescription + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + description: BMWNumberEntityDescription, + ) -> None: + """Initialize an BMW Number.""" + super().__init__(coordinator, vehicle) + self.entity_description = description + self._attr_unique_id = f"{vehicle.vin}-{description.key}" + + @property + def native_value(self) -> float | None: + """Return the entity value to represent the entity state.""" + return self.entity_description.value_fn(self.vehicle) + + async def async_set_native_value(self, value: float) -> None: + """Update to the vehicle.""" + _LOGGER.debug( + "Executing '%s' on vehicle '%s' to value '%s'", + self.entity_description.key, + self.vehicle.vin, + value, + ) + try: + await self.entity_description.remote_service(self.vehicle, value) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py new file mode 100644 index 0000000000..52d35b477a --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -0,0 +1,126 @@ +"""Select platform for BMW.""" +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from bimmer_connected.vehicle import MyBMWVehicle +from bimmer_connected.vehicle.charging_profile import ChargingMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfElectricCurrent +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BMWBaseEntity +from .const import DOMAIN +from .coordinator import BMWDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BMWRequiredKeysMixin: + """Mixin for required keys.""" + + current_option: Callable[[MyBMWVehicle], str] + remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]] + + +@dataclass +class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): + """Describes BMW sensor entity.""" + + is_available: Callable[[MyBMWVehicle], bool] = lambda _: False + dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None + + +SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { + "ac_limit": BMWSelectEntityDescription( + key="ac_limit", + name="AC Charging Limit", + is_available=lambda v: v.is_remote_set_ac_limit_enabled, + dynamic_options=lambda v: [ + str(lim) for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr] + ], + current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr] + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + ac_limit=int(o) + ), + icon="mdi:current-ac", + unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ), + "charging_mode": BMWSelectEntityDescription( + key="charging_mode", + name="Charging Mode", + is_available=lambda v: v.is_charging_plan_supported, + options=[c.value for c in ChargingMode if c != ChargingMode.UNKNOWN], + current_option=lambda v: str(v.charging_profile.charging_mode.value), # type: ignore[union-attr] + remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update( + charging_mode=ChargingMode(o) + ), + icon="mdi:vector-point-select", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MyBMW lock from config entry.""" + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWSelect] = [] + + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.extend( + [ + BMWSelect(coordinator, vehicle, description) + for description in SELECT_TYPES.values() + if description.is_available(vehicle) + ] + ) + async_add_entities(entities) + + +class BMWSelect(BMWBaseEntity, SelectEntity): + """Representation of BMW select entity.""" + + entity_description: BMWSelectEntityDescription + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + description: BMWSelectEntityDescription, + ) -> None: + """Initialize an BMW select.""" + super().__init__(coordinator, vehicle) + self.entity_description = description + self._attr_unique_id = f"{vehicle.vin}-{description.key}" + if description.dynamic_options: + self._attr_options = description.dynamic_options(vehicle) + self._attr_current_option = description.current_option(vehicle) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug( + "Updating select '%s' of %s", self.entity_description.key, self.vehicle.name + ) + self._attr_current_option = self.entity_description.current_option(self.vehicle) + super()._handle_coordinator_update() + + async def async_select_option(self, option: str) -> None: + """Update to the vehicle.""" + _LOGGER.debug( + "Executing '%s' on vehicle '%s' to value '%s'", + self.entity_description.key, + self.vehicle.vin, + option, + ) + await self.entity_description.remote_service(self.vehicle, option) diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index c797de9985..314ff47c14 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import LENGTH, PERCENTAGE, VOLUME +from homeassistant.const import LENGTH, PERCENTAGE, VOLUME, UnitOfElectricCurrent from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -53,6 +53,14 @@ def convert_and_round( SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { # --- Generic --- + "ac_current_limit": BMWSensorEntityDescription( + key="ac_current_limit", + name="AC current limit", + key_class="charging_profile", + unit_type=UnitOfElectricCurrent.AMPERE, + icon="mdi:current-ac", + entity_registry_enabled_default=False, + ), "charging_start_time": BMWSensorEntityDescription( key="charging_start_time", name="Charging start time", @@ -73,6 +81,13 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { icon="mdi:ev-station", value=lambda x, y: x.value, ), + "charging_target": BMWSensorEntityDescription( + key="charging_target", + name="Charging target", + key_class="fuel_and_battery", + icon="mdi:battery-charging-high", + unit_type=PERCENTAGE, + ), "remaining_battery_percent": BMWSensorEntityDescription( key="remaining_battery_percent", name="Remaining battery percent", 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/button.py b/homeassistant/components/bond/button.py index 32b76c6fca..d3fc58a35b 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -35,6 +35,11 @@ class BondButtonEntityDescription( ): """Class to describe a Bond Button entity.""" + # BondEntity does not support DEVICE_CLASS_NAME + # Restrict the type to satisfy the type checker and catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + name: str | None = None + STOP_BUTTON = BondButtonEntityDescription( key=Action.STOP, 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/bond/fan.py b/homeassistant/components/bond/fan.py index a856af83bb..1512cf7b2b 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -89,7 +89,8 @@ class BondFan(BondEntity, FanEntity): features |= FanEntityFeature.SET_SPEED if self._device.supports_direction(): features |= FanEntityFeature.DIRECTION - + if self._device.has_action(Action.BREEZE_ON): + features |= FanEntityFeature.PRESET_MODE return features @property diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index bf343673fd..fc91f8eb72 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["bond_async"], "quality_scale": "platinum", - "requirements": ["bond-async==0.1.22"], + "requirements": ["bond-async==0.1.23"], "zeroconf": ["_bond._tcp.local."] } 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/manifest.json b/homeassistant/components/braviatv/manifest.json index 295a56b324..5a0a9def0a 100644 --- a/homeassistant/components/braviatv/manifest.json +++ b/homeassistant/components/braviatv/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pybravia"], - "requirements": ["pybravia==0.3.1"], + "requirements": ["pybravia==0.3.3"], "ssdp": [ { "st": "urn:schemas-sony-com:service:ScalarWebAPI:1", 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..3b1312a64c 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,24 +29,25 @@ 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] + device = BroadlinkDevice(hass, entry) + if not await device.async_setup(): + return False if data.heartbeat is None: data.heartbeat = BroadlinkHeartbeat(hass) hass.async_create_task(data.heartbeat.async_setup()) - - device = BroadlinkDevice(hass, entry) - return await device.async_setup() + return True 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/__init__.py b/homeassistant/components/brother/__init__.py index e0d0a0ca44..5f05caf0fc 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -5,7 +5,7 @@ from datetime import timedelta import logging import async_timeout -from brother import Brother, BrotherSensors, SnmpError, UnsupportedModel +from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TYPE, Platform @@ -81,6 +81,6 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]): try: async with async_timeout.timeout(20): data = await self.brother.async_update() - except (ConnectionError, SnmpError, UnsupportedModel) as error: + except (ConnectionError, SnmpError, UnsupportedModelError) as error: raise UpdateFailed(error) from error return data diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index 48c73d0c4d..55d47bb0c2 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from brother import Brother, SnmpError, UnsupportedModel +from brother import Brother, SnmpError, UnsupportedModelError import voluptuous as vol from homeassistant import config_entries, exceptions @@ -62,7 +62,7 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except SnmpError: errors["base"] = "snmp_error" - except UnsupportedModel: + except UnsupportedModelError: return self.async_abort(reason="unsupported_model") return self.async_show_form( @@ -86,7 +86,7 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.host, snmp_engine=snmp_engine, model=model ) await self.brother.async_update() - except UnsupportedModel: + except UnsupportedModelError: return self.async_abort(reason="unsupported_model") except (ConnectionError, SnmpError): return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index db8a432e1f..cba44b68c6 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "quality_scale": "platinum", - "requirements": ["brother==2.1.1"], + "requirements": ["brother==2.3.0"], "zeroconf": [ { "type": "_printer._tcp.local.", 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/brunt/cover.py b/homeassistant/components/brunt/cover.py index 3fb328ab7f..1df34929b1 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -88,7 +88,7 @@ class BruntDevice( self._attr_attribution = ATTRIBUTION self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, # type: ignore[arg-type] - name=self._attr_name, + name=self._thing.name, via_device=(DOMAIN, self._entry_id), manufacturer="Brunt", sw_version=self._thing.fw_version, diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index fcff6a925e..cbc6dd0047 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -129,7 +129,7 @@ class BSBLANClimate( return PRESET_ECO return PRESET_NONE - async def async_set_hvac_mode(self, hvac_mode: str) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" await self.async_set_data(hvac_mode=hvac_mode) 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/bt_home_hub_5/device_tracker.py b/homeassistant/components/bt_home_hub_5/device_tracker.py index 4d89c85124..0ffa3bc699 100644 --- a/homeassistant/components/bt_home_hub_5/device_tracker.py +++ b/homeassistant/components/bt_home_hub_5/device_tracker.py @@ -35,7 +35,7 @@ def get_scanner( class BTHomeHub5DeviceScanner(DeviceScanner): - """This class queries a BT Home Hub 5.""" + """Class which queries a BT Home Hub 5.""" def __init__(self, config): """Initialise the scanner.""" diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py index 48475bbeac..65aa1bd6a6 100644 --- a/homeassistant/components/bt_smarthub/device_tracker.py +++ b/homeassistant/components/bt_smarthub/device_tracker.py @@ -54,7 +54,7 @@ _Device = namedtuple("_Device", ["ip_address", "mac", "host", "status", "name"]) class BTSmartHubScanner(DeviceScanner): - """This class queries a BT Smart Hub.""" + """Class which queries a BT Smart Hub.""" def __init__(self, smarthub_client): """Initialise the scanner.""" 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/manifest.json b/homeassistant/components/bthome/manifest.json index 47f980c78f..87a84e5fab 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==2.5.2"] + "requirements": ["bthome-ble==2.9.0"] } 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/sensor.py b/homeassistant/components/bthome/sensor.py index 4b37818348..9b5def3005 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -117,7 +117,17 @@ SENSOR_DESCRIPTIONS = { key=f"{BTHomeSensorDeviceClass.ENERGY}_{Units.ENERGY_KILO_WATT_HOUR}", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, + ), + # Gas (m3) + ( + BTHomeSensorDeviceClass.GAS, + Units.VOLUME_CUBIC_METERS, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.GAS}_{Units.VOLUME_CUBIC_METERS}", + device_class=SensorDeviceClass.GAS, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.TOTAL, ), # Humidity in (percent) (BTHomeSensorDeviceClass.HUMIDITY, Units.PERCENTAGE): SensorEntityDescription( @@ -287,6 +297,16 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, ), + # Water (L) + ( + BTHomeSensorDeviceClass.WATER, + Units.VOLUME_LITERS, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.WATER}_{Units.VOLUME_LITERS}", + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.LITERS, + state_class=SensorStateClass.TOTAL, + ), } 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/button/device_action.py b/homeassistant/components/button/device_action.py index 7003372969..8398b4990c 100644 --- a/homeassistant/components/button/device_action.py +++ b/homeassistant/components/button/device_action.py @@ -11,7 +11,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -31,7 +31,7 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for button devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ { CONF_DEVICE_ID: device_id, @@ -39,7 +39,7 @@ async def async_get_actions( CONF_ENTITY_ID: entry.entity_id, CONF_TYPE: "press", } - for entry in entity_registry.async_entries_for_device(registry, device_id) + for entry in er.async_entries_for_device(registry, device_id) if entry.domain == DOMAIN ] diff --git a/homeassistant/components/button/device_trigger.py b/homeassistant/components/button/device_trigger.py index 673806be7d..fbf054996c 100644 --- a/homeassistant/components/button/device_trigger.py +++ b/homeassistant/components/button/device_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -36,7 +36,7 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for button devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ { CONF_PLATFORM: "device", @@ -45,7 +45,7 @@ async def async_get_triggers( CONF_ENTITY_ID: entry.entity_id, CONF_TYPE: "pressed", } - for entry in entity_registry.async_entries_for_device(registry, device_id) + for entry in er.async_entries_for_device(registry, device_id) if entry.domain == DOMAIN ] diff --git a/homeassistant/components/button/strings.json b/homeassistant/components/button/strings.json index ca774c57d7..4fd8885389 100644 --- a/homeassistant/components/button/strings.json +++ b/homeassistant/components/button/strings.json @@ -7,5 +7,16 @@ "action_type": { "press": "Press {entity_name} button" } + }, + "entity_component": { + "_": { + "name": "[%key:component::button::title%]" + }, + "restart": { + "name": "Restart" + }, + "update": { + "name": "Update" + } } } diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index ab3c47b969..9a01cd2186 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -356,4 +356,10 @@ class WebDavCalendarData: else: enddate = obj.dtstart.value + timedelta(days=1) + # End date for an all day event is exclusive. This fixes the case where + # an all day event has a start and end values are the same, or the event + # has a zero duration. + if not isinstance(enddate, datetime) and obj.dtstart.value == enddate: + enddate += timedelta(days=1) + return enddate diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index 5008325d5e..16624f2af5 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==1.1.1"] + "requirements": ["caldav==1.2.0"] } diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 876b90eac9..0f047bf375 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,116 @@ 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.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + """Validate that all datetime values have a timezone.""" + for k in keys: + if ( + (value := obj.get(k)) + and isinstance(value, datetime.datetime) + and value.tzinfo is None + ): + raise vol.Invalid("Expected all values to have a timezone") + return obj + + return validate + + +def _has_consistent_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Verify that all datetime values have a consistent timezone.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + """Test that all keys that are datetime values have the same timezone.""" + tzinfos = [] + for key in keys: + if not (value := obj.get(key)) or not isinstance(value, datetime.datetime): + return obj + tzinfos.append(value.tzinfo) + uniq_values = groupby(tzinfos) + if len(list(uniq_values)) > 1: + raise vol.Invalid("Expected all values to have the same timezone") + return obj + + return validate + + +def _as_local_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Convert all datetime values to the local timezone.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + """Convert all keys that are datetime values to local timezone.""" + for k in keys: + if (value := obj.get(k)) and isinstance(value, datetime.datetime): + obj[k] = dt.as_local(value) + return obj + + return validate + + +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 has a minimum duration.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + if (start := obj.get(start_key)) and (end := obj.get(end_key)): + duration = end - start + if duration < min_duration: + raise vol.Invalid( + f"Expected minimum event duration of {min_duration} ({start}, {end})" + ) + return obj + + return validate + + +def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Verify that all values are of the same type.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + """Test that all keys in the dict have values of the same type.""" + uniq_values = groupby(type(obj[k]) for k in keys) + if len(list(uniq_values)) > 1: + raise vol.Invalid(f"Expected all values to be the same type: {keys}") + return obj + + return validate + + +def _validate_rrule(value: Any) -> str: + """Validate a recurrence rule string.""" + if value is None: + raise vol.Invalid("rrule value is None") + + if not isinstance(value, str): + raise vol.Invalid("rrule value expected a string") + + try: + rrulestr(value) + except ValueError as err: + raise vol.Invalid(f"Invalid rrule '{value}': {err}") from err + + # Example format: FREQ=DAILY;UNTIL=... + rule_parts = dict(s.split("=", 1) for s in value.split(";")) + if not (freq := rule_parts.get("FREQ")): + raise vol.Invalid("rrule did not contain FREQ") + + if freq not in VALID_FREQS: + raise vol.Invalid(f"Invalid frequency for rule: {value}") + + return str(value) + + CREATE_EVENT_SERVICE = "create_event" CREATE_EVENT_SCHEMA = vol.All( cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), @@ -74,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, @@ -98,6 +210,44 @@ CREATE_EVENT_SCHEMA = vol.All( ), }, ), + _has_consistent_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME), + _as_local_timezone(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( + vol.All( + { + vol.Required(EVENT_START): vol.Any(cv.date, cv.datetime), + 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_min_duration(EVENT_START, EVENT_END, MIN_NEW_EVENT_DURATION), + ) +) + +# Validation for the CalendarEvent dataclass +CALENDAR_EVENT_SCHEMA = vol.Schema( + vol.All( + { + vol.Required("start"): vol.Any(cv.date, cv.datetime), + vol.Required("end"): vol.Any(cv.date, cv.datetime), + vol.Required(EVENT_SUMMARY): cv.string, + vol.Optional(EVENT_RRULE): _validate_rrule, + }, + _has_same_type("start", "end"), + _has_timezone("start", "end"), + _as_local_timezone("start", "end"), + _has_min_duration("start", "end", MIN_EVENT_DURATION), + ), + extra=vol.ALLOW_EXTRA, ) @@ -190,6 +340,29 @@ class CalendarEvent: "all_day": self.all_day, } + def __post_init__(self) -> None: + """Perform validation on the CalendarEvent.""" + + def skip_none(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: + return {k: v for k, v in obj if v is not None} + + try: + CALENDAR_EVENT_SCHEMA(dataclasses.asdict(self, dict_factory=skip_none)) + except vol.Invalid as err: + raise HomeAssistantError( + 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.""" @@ -263,30 +436,6 @@ def is_offset_reached( return start + offset_time <= dt.now(start.tzinfo) -def _validate_rrule(value: Any) -> str: - """Validate a recurrence rule string.""" - if value is None: - raise vol.Invalid("rrule value is None") - - if not isinstance(value, str): - raise vol.Invalid("rrule value expected a string") - - try: - rrulestr(value) - except ValueError as err: - raise vol.Invalid(f"Invalid rrule: {str(err)}") from err - - # Example format: FREQ=DAILY;UNTIL=... - rule_parts = dict(s.split("=", 1) for s in value.split(";")) - if not (freq := rule_parts.get("FREQ")): - raise vol.Invalid("rrule did not contain FREQ") - - if freq not in VALID_FREQS: - raise vol.Invalid(f"Invalid frequency for rule: {value}") - - return str(value) - - class CalendarEntity(Entity): """Base class for calendar event entities.""" @@ -386,12 +535,15 @@ class CalendarEventView(http.HomeAssistantView): return web.Response(status=HTTPStatus.BAD_REQUEST) if start_date is None or end_date is None: return web.Response(status=HTTPStatus.BAD_REQUEST) + if start_date > end_date: + return web.Response(status=HTTPStatus.BAD_REQUEST) 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) return self.json_message( f"Error reading events: {err}", HTTPStatus.INTERNAL_SERVER_ERROR ) @@ -426,67 +578,11 @@ class CalendarListView(http.HomeAssistantView): return self.json(sorted(calendar_list, key=lambda x: cast(str, x["name"]))) -def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: - """Verify that all values are of the same type.""" - - def validate(obj: dict[str, Any]) -> dict[str, Any]: - """Test that all keys in the dict have values of the same type.""" - uniq_values = groupby(type(obj[k]) for k in keys) - if len(list(uniq_values)) > 1: - raise vol.Invalid(f"Expected all values to be the same type: {keys}") - return obj - - return validate - - -def _has_consistent_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: - """Verify that all datetime values have a consistent timezone.""" - - def validate(obj: dict[str, Any]) -> dict[str, Any]: - """Test that all keys that are datetime values have the same timezone.""" - values = [obj[k] for k in keys] - if all(isinstance(value, datetime.datetime) for value in values): - uniq_values = groupby(value.tzinfo for value in values) - if len(list(uniq_values)) > 1: - raise vol.Invalid( - f"Expected all values to have the same timezone: {values}" - ) - return obj - - return validate - - -def _is_sorted(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: - """Verify that the specified values are sequential.""" - - def validate(obj: dict[str, Any]) -> dict[str, Any]: - """Test that all keys in the dict are in order.""" - values = [obj[k] for k in keys] - if values != sorted(values): - raise vol.Invalid(f"Values were not in order: {values}") - return obj - - return validate - - @websocket_api.websocket_command( { vol.Required("type"): "calendar/event/create", vol.Required("entity_id"): cv.entity_id, - CONF_EVENT: vol.Schema( - vol.All( - { - vol.Required(EVENT_START): vol.Any(cv.date, cv.datetime), - 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_RRULE): _validate_rrule, - }, - _has_same_type(EVENT_START, EVENT_END), - _has_consistent_timezone(EVENT_START, EVENT_END), - _is_sorted(EVENT_START, EVENT_END), - ) - ), + CONF_EVENT: WEBSOCKET_EVENT_SCHEMA, } ) @websocket_api.async_response @@ -569,20 +665,7 @@ async def handle_calendar_event_delete( vol.Required(EVENT_UID): cv.string, vol.Optional(EVENT_RECURRENCE_ID): cv.string, vol.Optional(EVENT_RECURRENCE_RANGE): cv.string, - vol.Required(CONF_EVENT): vol.Schema( - vol.All( - { - vol.Required(EVENT_START): vol.Any(cv.date, cv.datetime), - 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_RRULE): _validate_rrule, - }, - _has_same_type(EVENT_START, EVENT_END), - _has_consistent_timezone(EVENT_START, EVENT_END), - _is_sorted(EVENT_START, EVENT_END), - ) - ), + vol.Required(CONF_EVENT): WEBSOCKET_EVENT_SCHEMA, } ) @websocket_api.async_response diff --git a/homeassistant/components/calendar/const.py b/homeassistant/components/calendar/const.py index aa47cb3592..3fbab6742a 100644 --- a/homeassistant/components/calendar/const.py +++ b/homeassistant/components/calendar/const.py @@ -1,11 +1,11 @@ """Constants for calendar components.""" -from enum import IntEnum +from enum import IntFlag CONF_EVENT = "event" -class CalendarEntityFeature(IntEnum): +class CalendarEntityFeature(IntFlag): """Supported features of the calendar entity.""" CREATE_EVENT = 1 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/calendar/strings.json b/homeassistant/components/calendar/strings.json index 3af9a78e60..898953c18a 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -1,9 +1,36 @@ { "title": "Calendar", - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "name": "[%key:component::calendar::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "all_day": { + "name": "All day", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "description": { + "name": "Description" + }, + "end_time": { + "name": "End time" + }, + "location": { + "name": "Location" + }, + "messages": { + "name": "Message" + }, + "start_time": { + "name": "Start time" + } + } } } } diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py index 1e51c746e1..f8a6014e26 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -1,7 +1,8 @@ """Offer calendar automation rules.""" from __future__ import annotations -from collections.abc import Coroutine +from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass import datetime import logging from typing import Any @@ -14,7 +15,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import ( - async_track_point_in_utc_time, + async_track_point_in_time, async_track_time_interval, ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo @@ -41,34 +42,136 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( # mypy: disallow-any-generics +@dataclass +class QueuedCalendarEvent: + """An event that is queued to be fired in the future.""" + + trigger_time: datetime.datetime + event: CalendarEvent + + +@dataclass +class Timespan: + """A time range part of start/end dates, used for considering active events.""" + + start: datetime.datetime + """The start datetime of the interval.""" + + end: datetime.datetime + """The end datetime (exclusive) of the interval.""" + + def with_offset(self, offset: datetime.timedelta) -> Timespan: + """Return a new interval shifted by the specified offset.""" + return Timespan(self.start + offset, self.end + offset) + + def __contains__(self, trigger: datetime.datetime) -> bool: + """Return true if the trigger time is within the time span.""" + return self.start <= trigger < self.end + + def next_upcoming( + self, now: datetime.datetime, interval: datetime.timedelta + ) -> Timespan: + """Return a subsequent time span following the current time span. + + This effectively gives us a cursor like interface for advancing through + time using the interval as a hint. The returned span may have a + different interval than the one specified. For example, time span may + be longer during a daylight saving time transition, or may extend due to + drift if the current interval is old. The returned time span is + adjacent and non-overlapping. + """ + return Timespan(self.end, max(self.end, now) + interval) + + def __str__(self) -> str: + """Return a string representing the half open interval timespan.""" + return f"[{self.start}, {self.end})" + + +EventFetcher = Callable[[Timespan], Awaitable[list[CalendarEvent]]] +QueuedEventFetcher = Callable[[Timespan], Awaitable[list[QueuedCalendarEvent]]] + + +def event_fetcher(hass: HomeAssistant, entity: CalendarEntity) -> EventFetcher: + """Build an async_get_events wrapper to fetch events during a time span.""" + + async def async_get_events(timespan: Timespan) -> list[CalendarEvent]: + """Return events active in the specified time span.""" + # Expand by one second to make the end time exclusive + end_time = timespan.end + datetime.timedelta(seconds=1) + return await entity.async_get_events(hass, timespan.start, end_time) + + return async_get_events + + +def queued_event_fetcher( + fetcher: EventFetcher, event_type: str, offset: datetime.timedelta +) -> QueuedEventFetcher: + """Build a fetcher that produces a schedule of upcoming trigger events.""" + + def get_trigger_time(event: CalendarEvent) -> datetime.datetime: + if event_type == EVENT_START: + return event.start_datetime_local + return event.end_datetime_local + + async def async_get_events(timespan: Timespan) -> list[QueuedCalendarEvent]: + """Get calendar event triggers eligible to fire in the time span.""" + offset_timespan = timespan.with_offset(-1 * offset) + active_events = await fetcher(offset_timespan) + + # Determine the trigger eligibilty of events during this time span. + # Example: For an EVENT_END trigger the event may start during this + # time span, but need to be triggered later when the end happens. + results = [] + for trigger_time, event in zip( + map(get_trigger_time, active_events), active_events + ): + if trigger_time not in offset_timespan: + continue + results.append(QueuedCalendarEvent(trigger_time + offset, event)) + + _LOGGER.debug( + "Scan events @ %s%s found %s eligble of %s active", + offset_timespan, + f" (offset={offset})" if offset else "", + len(results), + len(active_events), + ) + results.sort(key=lambda x: x.trigger_time) + return results + + return async_get_events + + class CalendarEventListener: - """Helper class to listen to calendar events.""" + """Helper class to listen to calendar events. + + This listener will poll every UPDATE_INTERVAL to fetch a set of upcoming + calendar events in the upcoming window of time, putting them into a queue. + The queue is drained by scheduling an alarm for the next upcoming event + trigger time, one event at a time. + """ def __init__( self, hass: HomeAssistant, job: HassJob[..., Coroutine[Any, Any, None]], trigger_data: dict[str, Any], - entity: CalendarEntity, - event_type: str, - offset: datetime.timedelta, + fetcher: QueuedEventFetcher, ) -> None: """Initialize CalendarEventListener.""" self._hass = hass self._job = job self._trigger_data = trigger_data - self._entity = entity - self._offset = offset self._unsub_event: CALLBACK_TYPE | None = None self._unsub_refresh: CALLBACK_TYPE | None = None - # Upcoming set of events with their trigger time - self._events: list[tuple[datetime.datetime, CalendarEvent]] = [] - self._event_type = event_type + self._fetcher = fetcher + now = dt_util.now() + self._timespan = Timespan(now, now + UPDATE_INTERVAL) + self._events: list[QueuedCalendarEvent] = [] async def async_attach(self) -> None: """Attach a calendar event listener.""" - now = dt_util.utcnow() - await self._fetch_events(now) + self._events.extend(await self._fetcher(self._timespan)) self._unsub_refresh = async_track_time_interval( self._hass, self._handle_refresh, UPDATE_INTERVAL ) @@ -82,52 +185,19 @@ class CalendarEventListener: self._unsub_refresh() self._unsub_refresh = None - async def _fetch_events(self, last_endtime: datetime.datetime) -> None: - """Update the set of eligible events.""" - # Use a sliding window for selecting in scope events in the next interval. - # The event search range is offset, then the fire time of the returned events - # are offset again below. Event time ranges are exclusive so the end time - # is expanded by 1sec. - start_time = last_endtime - self._offset - end_time = start_time + UPDATE_INTERVAL + datetime.timedelta(seconds=1) - _LOGGER.debug( - "Fetching events between %s, %s (offset=%s)", - start_time, - end_time, - self._offset, - ) - events = await self._entity.async_get_events(self._hass, start_time, end_time) - - # Build list of events and the appropriate time to trigger an alarm. The - # returned events may have already started but matched the start/end time - # filtering above, so exclude any events that have already passed the - # trigger time. - event_list = [] - for event in events: - event_fire_time = ( - event.start_datetime_local - if self._event_type == EVENT_START - else event.end_datetime_local - ) - event_fire_time += self._offset - if event_fire_time > last_endtime: - event_list.append((event_fire_time, event)) - event_list.sort(key=lambda x: x[0]) - self._events = event_list - _LOGGER.debug("Populated event list %s", self._events) - @callback def _listen_next_calendar_event(self) -> None: """Set up the calendar event listener.""" if not self._events: return - (event_fire_time, _event) = self._events[0] - _LOGGER.debug("Scheduled alarm for %s", event_fire_time) - self._unsub_event = async_track_point_in_utc_time( + _LOGGER.debug( + "Scheduled next event trigger for %s", self._events[0].trigger_time + ) + self._unsub_event = async_track_point_in_time( self._hass, self._handle_calendar_event, - event_fire_time, + self._events[0].trigger_time, ) def _clear_event_listener(self) -> None: @@ -138,29 +208,36 @@ class CalendarEventListener: async def _handle_calendar_event(self, now: datetime.datetime) -> None: """Handle calendar event.""" - _LOGGER.debug("Calendar event @ %s", now) + _LOGGER.debug("Calendar event @ %s", dt_util.as_local(now)) self._dispatch_events(now) self._clear_event_listener() self._listen_next_calendar_event() def _dispatch_events(self, now: datetime.datetime) -> None: """Dispatch all events that are eligible to fire.""" - while self._events and self._events[0][0] <= now: - (_fire_time, event) = self._events.pop(0) - _LOGGER.debug("Event: %s", event) + while self._events and self._events[0].trigger_time <= now: + queued_event = self._events.pop(0) + _LOGGER.debug("Dispatching event: %s", queued_event.event) self._hass.async_run_hass_job( self._job, - {"trigger": {**self._trigger_data, "calendar_event": event.as_dict()}}, + { + "trigger": { + **self._trigger_data, + "calendar_event": queued_event.event.as_dict(), + } + }, ) - async def _handle_refresh(self, now: datetime.datetime) -> None: + async def _handle_refresh(self, now_utc: datetime.datetime) -> None: """Handle core config update.""" + now = dt_util.as_local(now_utc) _LOGGER.debug("Refresh events @ %s", now) # Dispatch any eligible events in the boundary case where refresh # fires before the calendar event. self._dispatch_events(now) self._clear_event_listener() - await self._fetch_events(now) + self._timespan = self._timespan.next_upcoming(now, UPDATE_INTERVAL) + self._events.extend(await self._fetcher(self._timespan)) self._listen_next_calendar_event() @@ -190,7 +267,10 @@ async def async_attach_trigger( "offset": offset, } listener = CalendarEventListener( - hass, HassJob(action), trigger_data, entity, event_type, offset + hass, + HassJob(action), + trigger_data, + queued_event_fetcher(event_fetcher(hass, entity), event_type, offset), ) await listener.async_attach() return listener.async_detach diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 11e75c50cf..c09586848d 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -41,6 +41,7 @@ from homeassistant.const import ( CONF_FILENAME, CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STARTED, + EVENT_HOMEASSISTANT_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) @@ -55,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 @@ -378,7 +380,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity.async_update_token() entity.async_write_ha_state() - 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: + """Unsubscribe track time interval timer.""" + unsub() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unsub_track_time_interval) component.async_register_entity_service( SERVICE_ENABLE_MOTION, {}, "async_enable_motion_detection" @@ -902,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/camera/strings.json b/homeassistant/components/camera/strings.json index 3b8767ec8c..0722ec1c5e 100644 --- a/homeassistant/components/camera/strings.json +++ b/homeassistant/components/camera/strings.json @@ -1,10 +1,38 @@ { "title": "Camera", - "state": { + "entity_component": { "_": { - "recording": "Recording", - "streaming": "Streaming", - "idle": "[%key:common::state::idle%]" + "name": "[%key:component::camera::title%]", + "state": { + "recording": "Recording", + "streaming": "Streaming", + "idle": "[%key:common::state::idle%]" + }, + "state_attributes": { + "access_token": { + "name": "Access token" + }, + "brand": { + "name": "Brand" + }, + "frontend_stream_type": { + "name": "Stream type", + "state": { + "hls": "HLS", + "webrtc": "WebRTC" + } + }, + "motion_detection": { + "name": "Motion detection", + "state": { + "true": "Enabled", + "false": "Disabled" + } + }, + "model_name": { + "name": "[%key:common::generic::model%]" + } + } } } } 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/cast/media_player.py b/homeassistant/components/cast/media_player.py index 0540380bc9..b701890d85 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -819,7 +819,15 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): return MediaType.MOVIE if media_status.media_is_musictrack: return MediaType.MUSIC - return None + + chromecast = self._get_chromecast() + if chromecast.cast_type in ( + pychromecast.const.CAST_TYPE_AUDIO, + pychromecast.const.CAST_TYPE_GROUP, + ): + return MediaType.MUSIC + + return MediaType.VIDEO @property def media_duration(self): diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index 508b2b2d8b..1424d41006 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -39,7 +39,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> CiscoDeviceScanner | class CiscoDeviceScanner(DeviceScanner): - """This class queries a wireless router running Cisco IOS firmware.""" + """Class which queries a wireless router running Cisco IOS firmware.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py index 9ce98ec4fe..a5ca469d10 100644 --- a/homeassistant/components/cisco_mobility_express/device_tracker.py +++ b/homeassistant/components/cisco_mobility_express/device_tracker.py @@ -56,7 +56,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> CiscoMEDeviceScanner class CiscoMEDeviceScanner(DeviceScanner): - """This class scans for devices associated to a Cisco ME controller.""" + """Scanner for devices associated to a Cisco ME controller.""" def __init__(self, controller): """Initialize the scanner.""" diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index 3c9934d5cb..0119ad6580 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -12,7 +12,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -44,11 +44,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Climate devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index c6179d8221..97dc27cfa0 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -13,7 +13,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -45,11 +49,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Climate devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index 0b0bedb49b..005e744b53 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -20,7 +20,7 @@ from homeassistant.const import ( PERCENTAGE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -62,11 +62,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Climate devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 40d518456b..33e114c87f 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -6,6 +6,8 @@ set_aux_heat: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.AUX_HEAT fields: aux_heat: name: Auxiliary heating @@ -20,6 +22,8 @@ set_preset_mode: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.PRESET_MODE fields: preset_mode: name: Preset mode @@ -35,10 +39,16 @@ set_temperature: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE + - climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE fields: temperature: name: Temperature description: New target temperature for HVAC. + filter: + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE selector: number: min: 0 @@ -48,6 +58,9 @@ set_temperature: target_temp_high: name: Target temperature high description: New target high temperature for HVAC. + filter: + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE advanced: true selector: number: @@ -58,6 +71,9 @@ set_temperature: target_temp_low: name: Target temperature low description: New target low temperature for HVAC. + filter: + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE advanced: true selector: number: @@ -92,6 +108,8 @@ set_humidity: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.TARGET_HUMIDITY fields: humidity: name: Humidity @@ -109,6 +127,8 @@ set_fan_mode: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.FAN_MODE fields: fan_mode: name: Fan mode @@ -152,6 +172,8 @@ set_swing_mode: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.SWING_MODE fields: swing_mode: name: Swing mode diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 8c6c8f2d97..5e3fe15d56 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -15,92 +15,93 @@ "set_preset_mode": "Change preset on {entity_name}" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "heat": "Heat", - "cool": "Cool", - "heat_cool": "Heat/Cool", - "auto": "Auto", - "dry": "Dry", - "fan_only": "Fan only" - } - }, - "state_attributes": { - "_": { - "aux_heat": { "name": "Aux heat" }, - "current_humidity": { "name": "Current humidity" }, - "current_temperature": { "name": "Current temperature" }, - "fan_mode": { - "name": "Fan mode", - "state": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]", - "auto": "Auto", - "low": "Low", - "medium": "Medium", - "high": "High", - "top": "Top", - "middle": "Middle", - "focus": "Focus", - "diffuse": "Diffuse" - } + "name": "Thermostat", + "state": { + "off": "[%key:common::state::off%]", + "heat": "Heat", + "cool": "Cool", + "heat_cool": "Heat/Cool", + "auto": "Auto", + "dry": "Dry", + "fan_only": "Fan only" }, - "fan_modes": { - "name": "Fan modes" - }, - "humidity": { "name": "Target humidity" }, - "hvac_action": { - "name": "Current action", - "state": { - "off": "Off", - "heating": "Heating", - "cooling": "Cooling", - "drying": "Drying", - "idle": "Idle", - "fan": "Fan" - } - }, - "hvac_modes": { - "name": "HVAC modes" - }, - "max_humidity": { "name": "Max target humidity" }, - "max_temp": { "name": "Max target temperature" }, - "min_humidity": { "name": "Min target humidity" }, - "min_temp": { "name": "Min target temperature" }, - "preset_mode": { - "name": "Preset", - "state": { - "none": "None", - "eco": "Eco", - "away": "Away", - "boost": "Boost", - "comfort": "Comfort", - "home": "Home", - "sleep": "Sleep", - "activity": "Activity" - } - }, - "preset_modes": { - "name": "Presets" - }, - "swing_mode": { - "name": "Swing mode", - "state": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]", - "both": "Both", - "vertical": "Vertical", - "horizontal": "Horizontal" - } - }, - "swing_modes": { - "name": "Swing modes" - }, - "target_temp_high": { "name": "Upper target temperature" }, - "target_temp_low": { "name": "Lower target temperature" }, - "target_temp_step": { "name": "Target temperature step" }, - "temperature": { "name": "Target temperature" } + "state_attributes": { + "aux_heat": { "name": "Aux heat" }, + "current_humidity": { "name": "Current humidity" }, + "current_temperature": { "name": "Current temperature" }, + "fan_mode": { + "name": "Fan mode", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "auto": "Auto", + "low": "Low", + "medium": "Medium", + "high": "High", + "top": "Top", + "middle": "Middle", + "focus": "Focus", + "diffuse": "Diffuse" + } + }, + "fan_modes": { + "name": "Fan modes" + }, + "humidity": { "name": "Target humidity" }, + "hvac_action": { + "name": "Current action", + "state": { + "off": "Off", + "heating": "Heating", + "cooling": "Cooling", + "drying": "Drying", + "idle": "Idle", + "fan": "Fan" + } + }, + "hvac_modes": { + "name": "HVAC modes" + }, + "max_humidity": { "name": "Max target humidity" }, + "max_temp": { "name": "Max target temperature" }, + "min_humidity": { "name": "Min target humidity" }, + "min_temp": { "name": "Min target temperature" }, + "preset_mode": { + "name": "Preset", + "state": { + "none": "None", + "eco": "Eco", + "away": "Away", + "boost": "Boost", + "comfort": "Comfort", + "home": "Home", + "sleep": "Sleep", + "activity": "Activity" + } + }, + "preset_modes": { + "name": "Presets" + }, + "swing_mode": { + "name": "Swing mode", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "both": "Both", + "vertical": "Vertical", + "horizontal": "Horizontal" + } + }, + "swing_modes": { + "name": "Swing modes" + }, + "target_temp_high": { "name": "Upper target temperature" }, + "target_temp_low": { "name": "Lower target temperature" }, + "target_temp_step": { "name": "Target temperature step" }, + "temperature": { "name": "Target temperature" } + } } } } diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index e9b852ada8..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, @@ -68,6 +68,7 @@ SERVICE_REMOTE_DISCONNECT = "remote_disconnect" SIGNAL_CLOUD_CONNECTION_STATE = "CLOUD_CONNECTION_STATE" +STARTUP_REPAIR_DELAY = 1 # 1 hour ALEXA_ENTITY_SCHEMA = vol.Schema( { @@ -118,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, } ) }, @@ -240,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.""" @@ -261,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: @@ -271,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 @@ -280,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 ) @@ -298,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) @@ -309,8 +320,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_call_later( hass=hass, - delay=timedelta(hours=1), - action=async_startup_repairs, + delay=timedelta(hours=STARTUP_REPAIR_DELAY), + 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..53bf44d8aa 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -20,9 +20,19 @@ from homeassistant.components.alexa import ( errors as alexa_errors, state_report as alexa_state_report, ) +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.homeassistant.exposed_entities import ( + async_expose_entity, + async_get_assistant_settings, + async_listen_entity_updates, + async_should_expose, +) +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, start +from homeassistant.helpers.entity import get_device_class from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -30,21 +40,89 @@ from homeassistant.util.dt import utcnow from .const import ( CONF_ENTITY_CONFIG, CONF_FILTER, - PREF_ALEXA_DEFAULT_EXPOSE, - PREF_ALEXA_ENTITY_CONFIGS, + DOMAIN as CLOUD_DOMAIN, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, PREF_SHOULD_EXPOSE, ) -from .prefs import CloudPreferences +from .prefs import ALEXA_SETTINGS_VERSION, CloudPreferences _LOGGER = logging.getLogger(__name__) +CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}" + # Time to wait when entity preferences have changed before syncing it to # the cloud. SYNC_DELAY = 1 +SUPPORTED_DOMAINS = { + "alarm_control_panel", + "alert", + "automation", + "button", + "camera", + "climate", + "cover", + "fan", + "group", + "humidifier", + "image_processing", + "input_boolean", + "input_button", + "input_number", + "light", + "lock", + "media_player", + "number", + "scene", + "script", + "switch", + "timer", + "vacuum", +} + +SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES = { + BinarySensorDeviceClass.DOOR, + BinarySensorDeviceClass.GARAGE_DOOR, + BinarySensorDeviceClass.MOTION, + BinarySensorDeviceClass.OPENING, + BinarySensorDeviceClass.PRESENCE, + BinarySensorDeviceClass.WINDOW, +} + +SUPPORTED_SENSOR_DEVICE_CLASSES = { + SensorDeviceClass.TEMPERATURE, +} + + +def entity_supported(hass: HomeAssistant, entity_id: str) -> bool: + """Return if the entity is supported. + + This is called when migrating from legacy config format to avoid exposing + all binary sensors and sensors. + """ + domain = split_entity_id(entity_id)[0] + if domain in SUPPORTED_DOMAINS: + return True + + try: + device_class = get_device_class(hass, entity_id) + except HomeAssistantError: + # The entity no longer exists + return False + if ( + domain == "binary_sensor" + and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES + ): + return True + + if domain == "sensor" and device_class in SUPPORTED_SENSOR_DEVICE_CLASSES: + return True + + return False + + class CloudAlexaConfig(alexa_config.AbstractConfig): """Alexa Configuration.""" @@ -64,7 +142,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._cloud = cloud self._token = None self._token_valid = None - self._cur_entity_prefs = prefs.alexa_entity_configs + self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA) self._alexa_sync_unsub: Callable[[], None] | None = None self._endpoint = None @@ -115,15 +193,54 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): """Return an identifier for the user that represents this config.""" return self._cloud_user + def _migrate_alexa_entity_settings_v1(self): + """Migrate alexa entity settings to entity registry options.""" + if not self._config[CONF_FILTER].empty_filter: + # Don't migrate if there's a YAML config + return + + for entity_id in { + *self.hass.states.async_entity_ids(), + *self._prefs.alexa_entity_configs, + }: + async_expose_entity( + self.hass, + CLOUD_ALEXA, + entity_id, + self._should_expose_legacy(entity_id), + ) + async def async_initialize(self): """Initialize the Alexa config.""" await super().async_initialize() - async def hass_started(hass): + async def on_hass_started(hass): + if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION: + if self._prefs.alexa_settings_version < 2 or ( + # Recover from a bug we had in 2023.5.0 where entities didn't get exposed + self._prefs.alexa_settings_version < 3 + and not any( + settings.get("should_expose", False) + for settings in async_get_assistant_settings( + hass, CLOUD_ALEXA + ).values() + ) + ): + self._migrate_alexa_entity_settings_v1() + + await self._prefs.async_update( + alexa_settings_version=ALEXA_SETTINGS_VERSION + ) + async_listen_entity_updates( + self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated + ) + + async def on_hass_start(hass): if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, ALEXA_DOMAIN, {}) - start.async_at_start(self.hass, hass_started) + start.async_at_start(self.hass, on_hass_start) + start.async_at_started(self.hass, on_hass_started) self._prefs.async_listen_updates(self._async_prefs_updated) self.hass.bus.async_listen( @@ -131,14 +248,11 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._handle_entity_registry_updated, ) - def should_expose(self, entity_id): + def _should_expose_legacy(self, entity_id): """If an entity should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - if not self._config[CONF_FILTER].empty_filter: - return self._config[CONF_FILTER](entity_id) - entity_configs = self._prefs.alexa_entity_configs entity_config = entity_configs.get(entity_id, {}) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) @@ -156,9 +270,23 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): # Backwards compat if (default_expose := self._prefs.alexa_default_expose) is None: - return not auxiliary_entity + return not auxiliary_entity and entity_supported(self.hass, entity_id) - return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + return ( + not auxiliary_entity + and split_entity_id(entity_id)[0] in default_expose + and entity_supported(self.hass, entity_id) + ) + + @callback + def should_expose(self, entity_id): + """If an entity should be exposed.""" + if not self._config[CONF_FILTER].empty_filter: + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + return self._config[CONF_FILTER](entity_id) + + return async_should_expose(self.hass, CLOUD_ALEXA, entity_id) @callback def async_invalidate_access_token(self): @@ -233,32 +361,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..f3878fd68a 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -16,7 +16,7 @@ from homeassistant.components.alexa import ( smart_home as alexa_smart_home, ) from homeassistant.components.google_assistant import smart_home as ga -from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.core import Context, HassJob, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.util.aiohttp import MockRequest, serialize_response @@ -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: @@ -75,7 +76,7 @@ class CloudClient(Interface): return self._hass.http.runner @property - def cloudhooks(self) -> dict[str, dict[str, str]]: + def cloudhooks(self) -> dict[str, dict[str, str | bool]]: """Return list of cloudhooks.""" return self._prefs.cloudhooks @@ -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: @@ -91,8 +97,6 @@ class CloudClient(Interface): if self._alexa_config is not None: return self._alexa_config - assert self.cloud is not None - cloud_user = await self._prefs.get_cloud_user() alexa_conf = alexa_config.CloudAlexaConfig( @@ -114,8 +118,6 @@ class CloudClient(Interface): if self._google_config is not None: return self._google_config - assert self.cloud is not None - cloud_user = await self._prefs.get_cloud_user() google_conf = google_config.CloudGoogleConfig( @@ -130,8 +132,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(_): @@ -148,10 +150,12 @@ class CloudClient(Interface): ), err, ) - async_call_later(self._hass, 30, enable_alexa) + async_call_later(self._hass, 30, enable_alexa_job) except (alexa_errors.NoTokenAvailable, alexa_errors.RequireRelink): pass + enable_alexa_job = HassJob(enable_alexa, cancel_on_shutdown=True) + async def enable_google(_): """Enable Google.""" gconf = await self.get_google_config() @@ -175,6 +179,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 +263,13 @@ class CloudClient(Interface): "headers": {"Content-Type": response.content_type}, } - async def async_cloudhooks_update(self, data: dict[str, dict[str, str]]) -> None: + 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 | bool]] + ) -> 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 9fb4ffc704..7aa39efbf0 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -12,15 +12,15 @@ PREF_GOOGLE_ENTITY_CONFIGS = "google_entity_configs" PREF_GOOGLE_REPORT_STATE = "google_report_state" PREF_ALEXA_ENTITY_CONFIGS = "alexa_entity_configs" PREF_ALEXA_REPORT_STATE = "alexa_report_state" -PREF_OVERRIDE_NAME = "override_name" PREF_DISABLE_2FA = "disable_2fa" -PREF_ALIASES = "aliases" PREF_SHOULD_EXPOSE = "should_expose" PREF_GOOGLE_LOCAL_WEBHOOK_ID = "google_local_webhook_id" 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 @@ -58,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..351de5d0e6 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -7,8 +7,18 @@ from typing import Any from hass_nabucasa import Cloud, cloud_api from hass_nabucasa.google_report_state import ErrorResponse +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN from homeassistant.components.google_assistant.helpers import AbstractConfig +from homeassistant.components.homeassistant.exposed_entities import ( + async_expose_entity, + async_get_assistant_settings, + async_get_entity_settings, + async_listen_entity_updates, + async_set_assistant_option, + async_should_expose, +) +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import ( CoreState, @@ -17,19 +27,92 @@ from homeassistant.core import ( callback, split_entity_id, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er, start +from homeassistant.helpers.entity import get_device_class from homeassistant.setup import async_setup_component from .const import ( CONF_ENTITY_CONFIG, + CONF_FILTER, DEFAULT_DISABLE_2FA, + DOMAIN as CLOUD_DOMAIN, PREF_DISABLE_2FA, PREF_SHOULD_EXPOSE, ) -from .prefs import CloudPreferences +from .prefs import GOOGLE_SETTINGS_VERSION, CloudPreferences _LOGGER = logging.getLogger(__name__) +CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}" + + +SUPPORTED_DOMAINS = { + "alarm_control_panel", + "button", + "camera", + "climate", + "cover", + "fan", + "group", + "humidifier", + "input_boolean", + "input_button", + "input_select", + "light", + "lock", + "media_player", + "scene", + "script", + "select", + "switch", + "vacuum", +} + +SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES = { + BinarySensorDeviceClass.DOOR, + BinarySensorDeviceClass.GARAGE_DOOR, + BinarySensorDeviceClass.LOCK, + BinarySensorDeviceClass.MOTION, + BinarySensorDeviceClass.OPENING, + BinarySensorDeviceClass.PRESENCE, + BinarySensorDeviceClass.WINDOW, +} + +SUPPORTED_SENSOR_DEVICE_CLASSES = { + SensorDeviceClass.AQI, + SensorDeviceClass.CO, + SensorDeviceClass.CO2, + SensorDeviceClass.HUMIDITY, + SensorDeviceClass.PM10, + SensorDeviceClass.PM25, + SensorDeviceClass.TEMPERATURE, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, +} + + +def _supported_legacy(hass: HomeAssistant, entity_id: str) -> bool: + """Return if the entity is supported. + + This is called when migrating from legacy config format to avoid exposing + all binary sensors and sensors. + """ + domain = split_entity_id(entity_id)[0] + if domain in SUPPORTED_DOMAINS: + return True + + device_class = get_device_class(hass, entity_id) + if ( + domain == "binary_sensor" + and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES + ): + return True + + if domain == "sensor" and device_class in SUPPORTED_SENSOR_DEVICE_CLASSES: + return True + + return False + class CloudGoogleConfig(AbstractConfig): """HA Cloud Configuration for Google Assistant.""" @@ -48,8 +131,6 @@ class CloudGoogleConfig(AbstractConfig): self._user = cloud_user self._prefs = prefs self._cloud = cloud - self._cur_entity_prefs = self._prefs.google_entity_configs - self._cur_default_expose = self._prefs.google_default_expose self._sync_entities_lock = asyncio.Lock() @property @@ -89,15 +170,62 @@ class CloudGoogleConfig(AbstractConfig): """Return Cloud User account.""" return self._user + def _migrate_google_entity_settings_v1(self): + """Migrate Google entity settings to entity registry options.""" + if not self._config[CONF_FILTER].empty_filter: + # Don't migrate if there's a YAML config + return + + for entity_id in { + *self.hass.states.async_entity_ids(), + *self._prefs.google_entity_configs, + }: + async_expose_entity( + self.hass, + CLOUD_GOOGLE, + entity_id, + self._should_expose_legacy(entity_id), + ) + if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None): + async_set_assistant_option( + self.hass, + CLOUD_GOOGLE, + entity_id, + PREF_DISABLE_2FA, + _2fa_disabled, + ) + async def async_initialize(self): """Perform async initialization of config.""" await super().async_initialize() - async def hass_started(hass): + async def on_hass_started(hass: HomeAssistant) -> None: + if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: + if self._prefs.google_settings_version < 2 or ( + # Recover from a bug we had in 2023.5.0 where entities didn't get exposed + self._prefs.google_settings_version < 3 + and not any( + settings.get("should_expose", False) + for settings in async_get_assistant_settings( + hass, CLOUD_GOOGLE + ).values() + ) + ): + self._migrate_google_entity_settings_v1() + + await self._prefs.async_update( + google_settings_version=GOOGLE_SETTINGS_VERSION + ) + async_listen_entity_updates( + self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated + ) + + async def on_hass_start(hass: HomeAssistant) -> None: if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) - start.async_at_start(self.hass, hass_started) + start.async_at_start(self.hass, on_hass_start) + start.async_at_started(self.hass, on_hass_started) # Remove any stored user agent id that is not ours remove_agent_user_ids = [] @@ -109,7 +237,6 @@ class CloudGoogleConfig(AbstractConfig): await self.async_disconnect_agent_user(agent_user_id) self._prefs.async_listen_updates(self._async_prefs_updated) - self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated, @@ -123,14 +250,11 @@ class CloudGoogleConfig(AbstractConfig): """If a state object should be exposed.""" return self._should_expose_entity_id(state.entity_id) - def _should_expose_entity_id(self, entity_id): + def _should_expose_legacy(self, entity_id): """If an entity ID should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - if not self._config["filter"].empty_filter: - return self._config["filter"](entity_id) - entity_configs = self._prefs.google_entity_configs entity_config = entity_configs.get(entity_id, {}) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) @@ -150,9 +274,22 @@ class CloudGoogleConfig(AbstractConfig): # Backwards compat if default_expose is None: - return not auxiliary_entity + return not auxiliary_entity and _supported_legacy(self.hass, entity_id) - return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + return ( + not auxiliary_entity + and split_entity_id(entity_id)[0] in default_expose + and _supported_legacy(self.hass, entity_id) + ) + + def _should_expose_entity_id(self, entity_id): + """If an entity should be exposed.""" + if not self._config[CONF_FILTER].empty_filter: + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + return self._config[CONF_FILTER](entity_id) + + return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) @property def agent_user_id(self): @@ -168,11 +305,22 @@ class CloudGoogleConfig(AbstractConfig): """Get agent user ID making request.""" return self.agent_user_id - def should_2fa(self, state): + def _2fa_disabled_legacy(self, entity_id): """If an entity should be checked for 2FA.""" entity_configs = self._prefs.google_entity_configs - entity_config = entity_configs.get(state.entity_id, {}) - return not entity_config.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) + entity_config = entity_configs.get(entity_id, {}) + return entity_config.get(PREF_DISABLE_2FA) + + def should_2fa(self, state): + """If an entity should be checked for 2FA.""" + try: + settings = async_get_entity_settings(self.hass, state.entity_id) + except HomeAssistantError: + # Handle the entity has been removed + return False + + assistant_options = settings.get(CLOUD_GOOGLE, {}) + return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) async def async_report_state(self, message, agent_user_id: str): """Send a state report to Google.""" @@ -218,14 +366,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 +373,14 @@ class CloudGoogleConfig(AbstractConfig): self.async_disable_local_sdk() sync_entities = True - self._cur_entity_prefs = prefs.google_entity_configs - self._cur_default_expose = prefs.google_default_expose - if sync_entities and self.hass.is_running: await self.async_sync_entities_all() + @callback + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" + self.async_schedule_google_sync_all() + @callback def _handle_entity_registry_updated(self, event: Event) -> None: """Handle when entity registry updated.""" @@ -263,7 +405,7 @@ class CloudGoogleConfig(AbstractConfig): self.async_schedule_google_sync_all() @callback - def _handle_device_registry_updated(self, event: Event) -> None: + async def _handle_device_registry_updated(self, event: Event) -> None: """Handle when device registry updated.""" if ( not self.enabled diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index ea1a0aa27e..f5d5c98fe1 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,5 +1,7 @@ """The HTTP api to control the cloud integration.""" import asyncio +from collections.abc import Mapping +from contextlib import suppress import dataclasses from functools import wraps from http import HTTPStatus @@ -14,30 +16,34 @@ from hass_nabucasa.const import STATE_DISCONNECTED from hass_nabucasa.voice import MAP_VOICE import voluptuous as vol -from homeassistant.components import websocket_api +from homeassistant.components import assist_pipeline, conversation, websocket_api from homeassistant.components.alexa import ( entities as alexa_entities, errors as alexa_errors, ) from homeassistant.components.google_assistant import helpers as google_helpers +from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.location import async_detect_location_info +from .alexa_config import entity_supported as entity_supported_by_alexa from .const import ( DOMAIN, - PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_REPORT_STATE, + PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_DEFAULT_EXPOSE, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, ) +from .google_config import CLOUD_GOOGLE from .repairs import async_manage_legacy_subscription_issue from .subscription import async_subscription_info @@ -66,11 +72,12 @@ async def async_setup(hass): websocket_api.async_register_command(hass, websocket_remote_connect) websocket_api.async_register_command(hass, websocket_remote_disconnect) + websocket_api.async_register_command(hass, google_assistant_get) websocket_api.async_register_command(hass, google_assistant_list) websocket_api.async_register_command(hass, google_assistant_update) + websocket_api.async_register_command(hass, alexa_get) websocket_api.async_register_command(hass, alexa_list) - websocket_api.async_register_command(hass, alexa_update) websocket_api.async_register_command(hass, alexa_sync) websocket_api.async_register_command(hass, thingtalk_convert) @@ -179,11 +186,32 @@ class CloudLoginView(HomeAssistantView): ) async def post(self, request, data): """Handle login request.""" + + def cloud_assist_pipeline(hass: HomeAssistant) -> str | None: + """Return the ID of a cloud-enabled assist pipeline or None.""" + for pipeline in assist_pipeline.async_get_pipelines(hass): + if ( + pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT + and pipeline.stt_engine == DOMAIN + and pipeline.tts_engine == DOMAIN + ): + return pipeline.id + return None + hass = request.app["hass"] cloud = hass.data[DOMAIN] await cloud.login(data["email"], data["password"]) - return self.json({"success": True}) + # Make sure the pipeline store is loaded, needed because assist_pipeline + # is an after dependency of cloud + await assist_pipeline.async_setup_pipeline_store(hass) + new_cloud_pipeline_id: str | None = None + if (cloud_assist_pipeline(hass)) is None: + if cloud_pipeline := await assist_pipeline.async_create_default_pipeline( + hass, DOMAIN, DOMAIN + ): + new_cloud_pipeline_id = cloud_pipeline.id + return self.json({"success": True, "cloud_pipeline": new_cloud_pipeline_id}) class CloudLogoutView(HomeAssistantView): @@ -350,8 +378,6 @@ async def websocket_subscription( vol.Optional(PREF_ENABLE_ALEXA): bool, vol.Optional(PREF_ALEXA_REPORT_STATE): bool, vol.Optional(PREF_GOOGLE_REPORT_STATE): bool, - vol.Optional(PREF_ALEXA_DEFAULT_EXPOSE): [str], - vol.Optional(PREF_GOOGLE_DEFAULT_EXPOSE): [str], vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( vol.Coerce(tuple), vol.In(MAP_VOICE) @@ -484,6 +510,7 @@ async def _account_data(hass: HomeAssistant, cloud: Cloud): "logged_in": True, "prefs": client.prefs.as_dict(), "remote_certificate": certificate, + "remote_certificate_status": remote.certificate_status, "remote_connected": remote.is_connected, "remote_domain": remote.instance_domain, "http_use_ssl": hass.config.api.use_ssl, @@ -523,6 +550,59 @@ async def websocket_remote_disconnect( connection.send_result(msg["id"], await _account_data(hass, cloud)) +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.websocket_command( + { + "type": "cloud/google_assistant/entities/get", + "entity_id": str, + } +) +@websocket_api.async_response +@_ws_handle_cloud_errors +async def google_assistant_get( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get data for a single google assistant entity.""" + cloud = hass.data[DOMAIN] + gconf = await cloud.client.get_google_config() + entity_id: str = msg["entity_id"] + state = hass.states.get(entity_id) + + if not state: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"{entity_id} unknown", + ) + return + + entity = google_helpers.GoogleEntity(hass, gconf, state) + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity.is_supported(): + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_SUPPORTED, + f"{entity_id} not supported by Google assistant", + ) + return + + assistant_options: Mapping[str, Any] = {} + with suppress(HomeAssistantError, KeyError): + settings = exposed_entities.async_get_entity_settings(hass, entity_id) + assistant_options = settings[CLOUD_GOOGLE] + + result = { + "entity_id": entity.entity_id, + "traits": [trait.name for trait in entity.traits()], + "might_2fa": entity.might_2fa_traits(), + PREF_DISABLE_2FA: assistant_options.get(PREF_DISABLE_2FA), + } + + connection.send_result(msg["id"], result) + + @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({"type": "cloud/google_assistant/entities"}) @@ -558,10 +638,7 @@ async def google_assistant_list( { "type": "cloud/google_assistant/entities/update", "entity_id": str, - vol.Optional("should_expose"): vol.Any(None, bool), - vol.Optional("override_name"): str, - vol.Optional("aliases"): [str], - vol.Optional("disable_2fa"): bool, + vol.Optional(PREF_DISABLE_2FA): bool, } ) @websocket_api.async_response @@ -571,17 +648,53 @@ async def google_assistant_update( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Update google assistant config.""" - cloud = hass.data[DOMAIN] - changes = dict(msg) - changes.pop("type") - changes.pop("id") + """Update google assistant entity config.""" + entity_id: str = msg["entity_id"] - await cloud.client.prefs.async_update_google_entity_config(**changes) + assistant_options: Mapping[str, Any] = {} + with suppress(HomeAssistantError, KeyError): + settings = exposed_entities.async_get_entity_settings(hass, entity_id) + assistant_options = settings[CLOUD_GOOGLE] - connection.send_result( - msg["id"], cloud.client.prefs.google_entity_configs.get(msg["entity_id"]) + disable_2fa = msg[PREF_DISABLE_2FA] + if assistant_options.get(PREF_DISABLE_2FA) == disable_2fa: + return + + exposed_entities.async_set_assistant_option( + hass, CLOUD_GOOGLE, entity_id, PREF_DISABLE_2FA, disable_2fa ) + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.websocket_command( + { + "type": "cloud/alexa/entities/get", + "entity_id": str, + } +) +@websocket_api.async_response +@_ws_handle_cloud_errors +async def alexa_get( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get data for a single alexa entity.""" + entity_id: str = msg["entity_id"] + + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity_supported_by_alexa( + hass, entity_id + ): + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_SUPPORTED, + f"{entity_id} not supported by Alexa", + ) + return + + connection.send_result(msg["id"]) @websocket_api.require_admin @@ -613,35 +726,6 @@ async def alexa_list( connection.send_result(msg["id"], result) -@websocket_api.require_admin -@_require_cloud_login -@websocket_api.websocket_command( - { - "type": "cloud/alexa/entities/update", - "entity_id": str, - vol.Optional("should_expose"): vol.Any(None, bool), - } -) -@websocket_api.async_response -@_ws_handle_cloud_errors -async def alexa_update( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Update alexa entity config.""" - cloud = hass.data[DOMAIN] - changes = dict(msg) - changes.pop("type") - changes.pop("id") - - await cloud.client.prefs.async_update_alexa_entity_config(**changes) - - connection.send_result( - msg["id"], cloud.client.prefs.alexa_entity_configs.get(msg["entity_id"]) - ) - - @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({"type": "cloud/alexa/sync"}) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index ce8377f187..d352b7226f 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"], "documentation": "https://www.home-assistant.io/integrations/cloud", "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.61.0"] + "requirements": ["hass-nabucasa==0.67.1"] } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 17ec00026b..5ccc007e52 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,10 +20,9 @@ from .const import ( PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, - PREF_ALIASES, + PREF_ALEXA_SETTINGS_VERSION, PREF_CLOUD_USER, PREF_CLOUDHOOKS, - PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, @@ -30,15 +31,33 @@ from .const import ( PREF_GOOGLE_LOCAL_WEBHOOK_ID, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, - PREF_OVERRIDE_NAME, + 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 = 3 +GOOGLE_SETTINGS_VERSION = 3 + + +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: @@ -47,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() @@ -81,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} @@ -100,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), ): @@ -114,57 +131,6 @@ class CloudPreferences: await self._save_prefs(prefs) - async def async_update_google_entity_config( - self, - *, - entity_id, - override_name=UNDEFINED, - disable_2fa=UNDEFINED, - aliases=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_OVERRIDE_NAME, override_name), - (PREF_DISABLE_2FA, disable_2fa), - (PREF_ALIASES, aliases), - (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. @@ -192,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, @@ -241,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.""" @@ -261,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.""" @@ -325,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, @@ -332,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/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index ecba1900b6..69d2bd9e90 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -10,8 +10,7 @@ from coinbase.wallet.error import AuthenticationError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.util import Throttle from .const import ( @@ -71,10 +70,8 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non await hass.config_entries.async_reload(config_entry.entry_id) - registry = entity_registry.async_get(hass) - entities = entity_registry.async_entries_for_config_entry( - registry, config_entry.entry_id - ) + registry = er.async_get(hass) + entities = er.async_entries_for_config_entry(registry, config_entry.entry_id) # Remove orphaned entities for entity in entities: 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 f4a3a29f29..0c2edb8f19 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -23,7 +23,7 @@ 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 @@ -51,17 +51,17 @@ 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 Command line Binary Sensor.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - name: str = config[CONF_NAME] + name: str = config.get(CONF_NAME, DEFAULT_NAME) command: str = config[CONF_COMMAND] payload_off: str = config[CONF_PAYLOAD_OFF] payload_on: str = config[CONF_PAYLOAD_ON] @@ -73,7 +73,7 @@ def setup_platform( value_template.hass = hass data = CommandSensorData(hass, command, command_timeout) - add_entities( + async_add_entities( [ CommandBinarySensor( data, @@ -112,13 +112,16 @@ class CommandBinarySensor(BinarySensorEntity): self._value_template = value_template self._attr_unique_id = unique_id - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data and updates the state.""" - self.data.update() + await self.hass.async_add_executor_job(self.data.update) value = self.data.value if self._value_template is not None: - value = 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 8298201228..e477affc85 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -20,12 +20,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__) @@ -47,15 +47,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: """Set up cover 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_COVERS, {}) covers = [] @@ -82,7 +82,7 @@ def setup_platform( _LOGGER.error("No covers added") return - add_entities(covers) + async_add_entities(covers) class CommandCover(CoverEntity): @@ -148,13 +148,17 @@ class CommandCover(CoverEntity): 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._value_template: - payload = self._value_template.render_with_possible_json_value(payload) - self._state = int(payload) + payload = self._value_template.async_render_with_possible_json_value( + payload, None + ) + 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/notify.py b/homeassistant/components/command_line/notify.py index c41e26c21b..412456ff6e 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import subprocess +from typing import Any import voluptuous as vol @@ -46,7 +47,7 @@ class CommandLineNotificationService(BaseNotificationService): self.command = command self._timeout = timeout - def send_message(self, message="", **kwargs) -> None: + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a command line.""" with subprocess.Popen( self.command, diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 5dbbbf88e5..b6a2b8d83f 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -8,25 +8,31 @@ import logging import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA, + PLATFORM_SCHEMA, + STATE_CLASSES_SCHEMA, + SensorEntity, +) from homeassistant.const import ( CONF_COMMAND, + CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError 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 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__) @@ -45,19 +51,21 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, } ) -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 Command Sensor.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) name: str = config[CONF_NAME] command: str = config[CONF_COMMAND] @@ -70,8 +78,17 @@ def setup_platform( json_attributes: list[str] | None = config.get(CONF_JSON_ATTRIBUTES) data = CommandSensorData(hass, command, command_timeout) - add_entities( - [CommandSensor(data, name, unit, value_template, json_attributes, unique_id)], + async_add_entities( + [ + CommandSensor( + data, + name, + unit, + value_template, + json_attributes, + unique_id, + ) + ], True, ) @@ -89,18 +106,18 @@ class CommandSensor(SensorEntity): unique_id: str | None, ) -> None: """Initialize the sensor.""" + self._attr_name = name self.data = data self._attr_extra_state_attributes = {} self._json_attributes = json_attributes - self._attr_name = name self._attr_native_value = None - self._attr_native_unit_of_measurement = unit_of_measurement self._value_template = value_template + self._attr_native_unit_of_measurement = unit_of_measurement self._attr_unique_id = unique_id - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data and updates the state.""" - self.data.update() + await self.hass.async_add_executor_job(self.data.update) value = self.data.value if self._json_attributes: @@ -120,13 +137,15 @@ class CommandSensor(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: + if self._value_template is not None: self._attr_native_value = ( - self._value_template.render_with_possible_json_value( - value, STATE_UNKNOWN + self._value_template.async_render_with_possible_json_value( + value, + None, ) ) else: diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 7142f14e82..bfb45f5b5c 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -16,7 +16,9 @@ from homeassistant.const import ( CONF_COMMAND_ON, CONF_COMMAND_STATE, CONF_FRIENDLY_NAME, + CONF_ICON, CONF_ICON_TEMPLATE, + CONF_NAME, CONF_SWITCHES, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -24,12 +26,13 @@ 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.template_entity import ManualTriggerEntity 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,40 +54,40 @@ 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 = [] for object_id, device_config in devices.items(): + trigger_entity_config = { + CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), + CONF_NAME: Template(device_config.get(CONF_FRIENDLY_NAME, object_id), hass), + CONF_ICON: device_config.get(CONF_ICON_TEMPLATE), + } + value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass - icon_template: Template | None = device_config.get(CONF_ICON_TEMPLATE) - if icon_template is not None: - icon_template.hass = hass - switches.append( CommandSwitch( + trigger_entity_config, object_id, - device_config.get(CONF_FRIENDLY_NAME, object_id), device_config[CONF_COMMAND_ON], device_config[CONF_COMMAND_OFF], device_config.get(CONF_COMMAND_STATE), - icon_template, value_template, device_config[CONF_COMMAND_TIMEOUT], - device_config.get(CONF_UNIQUE_ID), ) ) @@ -92,42 +95,43 @@ def setup_platform( _LOGGER.error("No switches added") return - add_entities(switches) + async_add_entities(switches) -class CommandSwitch(SwitchEntity): +class CommandSwitch(ManualTriggerEntity, SwitchEntity): """Representation a switch that can be toggled using shell commands.""" def __init__( self, + config: ConfigType, object_id: str, - friendly_name: str, command_on: str, command_off: str, command_state: str | None, - icon_template: Template | None, value_template: Template | None, timeout: int, - unique_id: str | None, ) -> None: """Initialize the switch.""" + super().__init__(self.hass, config) self.entity_id = ENTITY_ID_FORMAT.format(object_id) - self._attr_name = friendly_name self._attr_is_on = False self._command_on = command_on self._command_off = command_off self._command_state = command_state - self._icon_template = icon_template self._value_template = value_template self._timeout = timeout - 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 +164,28 @@ 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()) - if self._icon_template: - self._attr_icon = self._icon_template.render_with_possible_json_value( - payload - ) + payload = str(await self.hass.async_add_executor_job(self._query_state)) + value = None if self._value_template: - payload = self._value_template.render_with_possible_json_value(payload) - self._attr_is_on = payload.lower() == "true" + value = self._value_template.async_render_with_possible_json_value( + payload, None + ) + self._attr_is_on = None + if payload or value: + self._attr_is_on = (value or payload).lower() == "true" + self._process_manual_data(payload) - 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/automation.py b/homeassistant/components/config/automation.py index 5a39b786e2..72a493f8c1 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -8,7 +8,7 @@ from homeassistant.components.automation.config import ( ) from homeassistant.config import AUTOMATION_CONFIG_PATH from homeassistant.const import CONF_ID, SERVICE_RELOAD -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from . import ACTION_DELETE, EditIdBasedConfigView @@ -23,7 +23,7 @@ async def async_setup(hass): if action != ACTION_DELETE: return - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_id = ent_reg.async_get_entity_id(DOMAIN, DOMAIN, config_key) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 39c5bce25c..c6fd400315 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,7 +1,6 @@ """Http views to control the config manager.""" from __future__ import annotations -import asyncio from http import HTTPStatus from typing import Any @@ -26,6 +25,7 @@ from homeassistant.loader import ( IntegrationNotFound, async_get_config_flows, async_get_integration, + async_get_integrations, ) @@ -493,14 +493,12 @@ async def async_matching_config_entries( integrations = {} # Fetch all the integrations so we can check their type - tasks = ( - async_get_integration(hass, domain) - for domain in {entry.domain for entry in entries} - ) - results = await asyncio.gather(*tasks, return_exceptions=True) - for integration_or_exc in results: + domains = {entry.domain for entry in entries} + for domain_key, integration_or_exc in ( + await async_get_integrations(hass, domains) + ).items(): if isinstance(integration_or_exc, Integration): - integrations[integration_or_exc.domain] = integration_or_exc + integrations[domain_key] = integration_or_exc elif not isinstance(integration_or_exc, IntegrationNotFound): raise integration_or_exc @@ -537,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/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 0f48c64ae4..20e00ec11e 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -15,16 +15,18 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) +from homeassistant.helpers.json import json_dumps async def async_setup(hass: HomeAssistant) -> bool: """Enable the Entity Registry views.""" - websocket_api.async_register_command(hass, websocket_list_entities) - websocket_api.async_register_command(hass, websocket_get_entity) websocket_api.async_register_command(hass, websocket_get_entities) - websocket_api.async_register_command(hass, websocket_update_entity) + websocket_api.async_register_command(hass, websocket_get_entity) + websocket_api.async_register_command(hass, websocket_list_entities_for_display) + websocket_api.async_register_command(hass, websocket_list_entities) websocket_api.async_register_command(hass, websocket_remove_entity) + websocket_api.async_register_command(hass, websocket_update_entity) return True @@ -40,7 +42,7 @@ def websocket_list_entities( # Build start of response message msg_json_prefix = ( f'{{"id":{msg["id"]},"type": "{websocket_api.const.TYPE_RESULT}",' - f'"success":true,"result": [' + '"success":true,"result": [' ) # Concatenate cached entity registry item JSON serializations msg_json = ( @@ -55,6 +57,36 @@ def websocket_list_entities( connection.send_message(msg_json) +@websocket_api.websocket_command( + {vol.Required("type"): "config/entity_registry/list_for_display"} +) +@callback +def websocket_list_entities_for_display( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle list registry entries command.""" + registry = er.async_get(hass) + # Build start of response message + entity_categories = json_dumps(er.ENTITY_CATEGORY_INDEX_TO_VALUE) + msg_json_prefix = ( + f'{{"id":{msg["id"]},"type":"{websocket_api.const.TYPE_RESULT}","success":true,' + f'"result":{{"entity_categories":{entity_categories},"entities":[' + ) + # Concatenate cached entity registry item JSON serializations + msg_json = ( + msg_json_prefix + + ",".join( + entry.display_json_repr + for entry in registry.entities.values() + if entry.disabled_by is None and entry.display_json_repr is not None + ) + + "]}}" + ) + connection.send_message(msg_json) + + @websocket_api.websocket_command( { vol.Required("type"): "config/entity_registry/get", diff --git a/homeassistant/components/config/scene.py b/homeassistant/components/config/scene.py index befbfd052a..037cd55d6a 100644 --- a/homeassistant/components/config/scene.py +++ b/homeassistant/components/config/scene.py @@ -5,7 +5,7 @@ from homeassistant.components.scene import DOMAIN, PLATFORM_SCHEMA from homeassistant.config import SCENE_CONFIG_PATH from homeassistant.const import CONF_ID, SERVICE_RELOAD from homeassistant.core import DOMAIN as HA_DOMAIN -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from . import ACTION_DELETE, EditIdBasedConfigView @@ -19,7 +19,7 @@ async def async_setup(hass): await hass.services.async_call(DOMAIN, SERVICE_RELOAD) return - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_id = ent_reg.async_get_entity_id(DOMAIN, HA_DOMAIN, config_key) diff --git a/homeassistant/components/configurator/strings.json b/homeassistant/components/configurator/strings.json index 570c18d3cd..0574e4bfce 100644 --- a/homeassistant/components/configurator/strings.json +++ b/homeassistant/components/configurator/strings.json @@ -1,9 +1,12 @@ { "title": "Configurator", - "state": { + "entity_component": { "_": { - "configure": "Configure", - "configured": "Configured" + "name": "[%key:component::configurator::title%]", + "state": { + "configure": "Configure", + "configured": "Configured" + } } } } 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..f156acfd56 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -2,9 +2,11 @@ from __future__ import annotations import asyncio +from collections.abc import Iterable +from dataclasses import dataclass import logging import re -from typing import Any +from typing import Any, Literal import voluptuous as vol @@ -12,13 +14,26 @@ from homeassistant import core from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.config_entries import ConfigEntry +from homeassistant.const import MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, intent, singleton from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from homeassistant.util import language as language_util from .agent import AbstractConversationAgent, ConversationInput, ConversationResult -from .default_agent import DefaultAgent +from .const import HOME_ASSISTANT_AGENT +from .default_agent import DefaultAgent, async_setup as async_setup_default_agent + +__all__ = [ + "DOMAIN", + "HOME_ASSISTANT_AGENT", + "async_converse", + "async_get_agent_info", + "async_set_agent", + "async_unset_agent", + "async_setup", +] _LOGGER = logging.getLogger(__name__) @@ -78,7 +93,9 @@ CONFIG_SCHEMA = vol.Schema( @core.callback def _get_agent_manager(hass: HomeAssistant) -> AgentManager: """Get the active agent.""" - return AgentManager(hass) + manager = AgentManager(hass) + manager.async_setup() + return manager @core.callback @@ -102,6 +119,34 @@ def async_unset_agent( _get_agent_manager(hass).async_unset_agent(config_entry.entry_id) +async def async_get_conversation_languages( + hass: HomeAssistant, agent_id: str | None = None +) -> set[str] | Literal["*"]: + """Return languages supported by conversation agents. + + If an agent is specified, returns a set of languages supported by that agent. + If no agent is specified, return a set with the union of languages supported by + all conversation agents. + """ + agent_manager = _get_agent_manager(hass) + languages = set() + + agent_ids: Iterable[str] + if agent_id is None: + agent_ids = iter(info.id for info in agent_manager.async_get_agent_info()) + else: + agent_ids = (agent_id,) + + for _agent_id in agent_ids: + agent = await agent_manager.async_get_agent(_agent_id) + if agent.supported_languages == MATCH_ALL: + return MATCH_ALL + for language_tag in agent.supported_languages: + languages.add(language_tag) + + return languages + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the process service.""" agent_manager = _get_agent_manager(hass) @@ -218,24 +263,38 @@ async def websocket_get_agent_info( @websocket_api.websocket_command( { vol.Required("type"): "conversation/agent/list", + vol.Optional("language"): str, + vol.Optional("country"): str, } ) -@core.callback -def websocket_list_agents( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], +@websocket_api.async_response +async def websocket_list_agents( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: - """List available agents.""" + """List conversation agents and, optionally, if they support a given language.""" manager = _get_agent_manager(hass) - connection.send_result( - msg["id"], - { - "default_agent": manager.default_agent, - "agents": manager.async_get_agent_info(), - }, - ) + country = msg.get("country") + language = msg.get("language") + agents = [] + + for agent_info in manager.async_get_agent_info(): + agent = await manager.async_get_agent(agent_info.id) + + supported_languages = agent.supported_languages + if language and supported_languages != MATCH_ALL: + supported_languages = language_util.matches( + language, supported_languages, country + ) + + agent_dict: dict[str, Any] = { + "id": agent_info.id, + "name": agent_info.name, + "supported_languages": supported_languages, + } + agents.append(agent_dict) + + connection.send_message(websocket_api.result_message(msg["id"], {"agents": agents})) class ConversationProcessView(http.HomeAssistantView): @@ -270,6 +329,32 @@ class ConversationProcessView(http.HomeAssistantView): return self.json(result.as_dict()) +@dataclass(frozen=True) +class AgentInfo: + """Container for conversation agent info.""" + + id: str + name: str + + +@core.callback +def async_get_agent_info( + hass: core.HomeAssistant, + agent_id: str | None = None, +) -> AgentInfo | None: + """Get information on the agent or None if not found.""" + manager = _get_agent_manager(hass) + + if agent_id is None: + agent_id = manager.default_agent + + for agent_info in manager.async_get_agent_info(): + if agent_info.id == agent_id: + return agent_info + + return None + + async def async_converse( hass: core.HomeAssistant, text: str, @@ -299,8 +384,6 @@ async def async_converse( class AgentManager: """Class to manage conversation agents.""" - HOME_ASSISTANT_AGENT = "homeassistant" - default_agent: str = HOME_ASSISTANT_AGENT _builtin_agent: AbstractConversationAgent | None = None @@ -308,7 +391,11 @@ class AgentManager: """Initialize the conversation agents.""" self.hass = hass self._agents: dict[str, AbstractConversationAgent] = {} - self._default_agent_init_lock = asyncio.Lock() + self._builtin_agent_init_lock = asyncio.Lock() + + def async_setup(self) -> None: + """Set up the conversation agents.""" + async_setup_default_agent(self.hass) async def async_get_agent( self, agent_id: str | None = None @@ -317,11 +404,11 @@ class AgentManager: if agent_id is None: agent_id = self.default_agent - if agent_id == AgentManager.HOME_ASSISTANT_AGENT: + if agent_id == HOME_ASSISTANT_AGENT: if self._builtin_agent is not None: return self._builtin_agent - async with self._default_agent_init_lock: + async with self._builtin_agent_init_lock: if self._builtin_agent is not None: return self._builtin_agent @@ -332,50 +419,55 @@ class AgentManager: return self._builtin_agent + if agent_id not in self._agents: + raise ValueError(f"Agent {agent_id} not found") + return self._agents[agent_id] @core.callback - def async_get_agent_info(self) -> list[dict[str, Any]]: + def async_get_agent_info(self) -> list[AgentInfo]: """List all agents.""" - agents = [ - { - "id": AgentManager.HOME_ASSISTANT_AGENT, - "name": "Home Assistant", - } + agents: list[AgentInfo] = [ + AgentInfo( + id=HOME_ASSISTANT_AGENT, + name="Home Assistant", + ) ] for agent_id, agent in self._agents.items(): config_entry = self.hass.config_entries.async_get_entry(agent_id) - # This is a bug, agent should have been unset when config entry was unloaded + # Guard against potential bugs in conversation agents where the agent is not + # removed from the manager when the config entry is removed if config_entry is None: _LOGGER.warning( - "Agent was still loaded while config entry is gone: %s", agent + "Conversation agent %s is still loaded after config entry removal", + agent, ) continue agents.append( - { - "id": agent_id, - "name": config_entry.title, - } + AgentInfo( + id=agent_id, + name=config_entry.title or config_entry.domain, + ) ) return agents @core.callback def async_is_valid_agent_id(self, agent_id: str) -> bool: """Check if the agent id is valid.""" - return agent_id in self._agents or agent_id == AgentManager.HOME_ASSISTANT_AGENT + return agent_id in self._agents or agent_id == HOME_ASSISTANT_AGENT @core.callback def async_set_agent(self, agent_id: str, agent: AbstractConversationAgent) -> None: """Set the agent.""" self._agents[agent_id] = agent - if self.default_agent == AgentManager.HOME_ASSISTANT_AGENT: + if self.default_agent == HOME_ASSISTANT_AGENT: self.default_agent = agent_id @core.callback def async_unset_agent(self, agent_id: str) -> None: """Unset the agent.""" if self.default_agent == agent_id: - self.default_agent = AgentManager.HOME_ASSISTANT_AGENT + self.default_agent = HOME_ASSISTANT_AGENT self._agents.pop(agent_id, None) diff --git a/homeassistant/components/conversation/agent.py b/homeassistant/components/conversation/agent.py index 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 b79a557698..a8828fcc0e 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -1,18 +1,5 @@ """Const for conversation integration.""" DOMAIN = "conversation" - -DEFAULT_EXPOSED_DOMAINS = { - "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 b418275f85..dccf394ab3 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Iterable, Mapping +from collections.abc import Iterable from dataclasses import dataclass import logging from pathlib import Path @@ -13,40 +13,43 @@ from typing import IO, Any from hassil.intents import Intents, ResponseType, SlotList, TextSlotList from hassil.recognize import RecognizeResult, recognize_all from hassil.util import merge_dict -from home_assistant_intents import get_intents +from home_assistant_intents import get_domains_and_languages, get_intents import yaml from homeassistant import core, setup +from homeassistant.components.homeassistant.exposed_entities import ( + async_listen_entity_updates, + async_should_expose, +) +from homeassistant.const import MATCH_ALL from homeassistant.helpers import ( - area_registry, - device_registry, - entity_registry, + area_registry as ar, + device_registry as dr, + entity_registry as er, intent, + start, template, translation, ) +from homeassistant.helpers.event import async_track_state_change from homeassistant.util.json import JsonObjectType, json_loads_object from .agent import AbstractConversationAgent, ConversationInput, ConversationResult -from .const import DEFAULT_EXPOSED_DOMAINS, DOMAIN +from .const import DEFAULT_EXPOSED_ATTRIBUTES, 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.""" @@ -72,6 +75,34 @@ def _get_language_variations(language: str) -> Iterable[str]: yield lang +@core.callback +def async_setup(hass: core.HomeAssistant) -> None: + """Set up entity registry listener for the default agent.""" + entity_registry = er.async_get(hass) + for entity_id in entity_registry.entities: + async_should_expose(hass, DOMAIN, entity_id) + + @core.callback + def async_entity_state_listener( + changed_entity: str, + old_state: core.State | None, + new_state: core.State | None, + ): + """Set expose flag on new entities.""" + if old_state is not None or new_state is None: + return + async_should_expose(hass, DOMAIN, changed_entity) + + @core.callback + def async_hass_started(hass: core.HomeAssistant) -> None: + """Set expose flag on all entities.""" + for state in hass.states.async_all(): + async_should_expose(hass, DOMAIN, state.entity_id) + async_track_state_change(hass, MATCH_ALL, async_entity_state_listener) + + start.async_at_started(hass, async_hass_started) + + class DefaultAgent(AbstractConversationAgent): """Default agent for conversation agent.""" @@ -83,7 +114,12 @@ class DefaultAgent(AbstractConversationAgent): # intent -> [sentences] self._config_intents: dict[str, Any] = {} - self._slot_lists: dict[str, TextSlotList] | None = None + 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.""" @@ -95,12 +131,12 @@ class DefaultAgent(AbstractConversationAgent): self._config_intents = config_intents self.hass.bus.async_listen( - area_registry.EVENT_AREA_REGISTRY_UPDATED, + ar.EVENT_AREA_REGISTRY_UPDATED, self._async_handle_area_registry_changed, run_immediately=True, ) self.hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + er.EVENT_ENTITY_REGISTRY_UPDATED, self._async_handle_entity_registry_changed, run_immediately=True, ) @@ -109,6 +145,9 @@ class DefaultAgent(AbstractConversationAgent): self._async_handle_state_changed, run_immediately=True, ) + async_listen_entity_updates( + self.hass, DOMAIN, self._async_exposed_entities_updated + ) async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" @@ -133,7 +172,7 @@ class DefaultAgent(AbstractConversationAgent): conversation_id, ) - slot_lists: Mapping[str, SlotList] = self._make_slot_lists() + slot_lists = self._make_slot_lists() result = await self.hass.async_add_executor_job( self._recognize, @@ -162,6 +201,7 @@ class DefaultAgent(AbstractConversationAgent): user_input.text, user_input.context, language, + assistant=DOMAIN, ) except intent.IntentHandleError: _LOGGER.exception("Intent handling error") @@ -227,7 +267,21 @@ class DefaultAgent(AbstractConversationAgent): intent_response: intent.IntentResponse, recognize_result: RecognizeResult, ) -> str: - all_states = intent_response.matched_states + intent_response.unmatched_states + # Make copies of the states here so we can add translated names for responses. + matched: list[core.State] = [] + + for state in intent_response.matched_states: + state_copy = core.State.from_dict(state.as_dict()) + if state_copy is not None: + matched.append(state_copy) + + unmatched: list[core.State] = [] + for state in intent_response.unmatched_states: + state_copy = core.State.from_dict(state.as_dict()) + if state_copy is not None: + unmatched.append(state_copy) + + all_states = matched + unmatched domains = {state.domain for state in all_states} translations = await translation.async_get_translations( self.hass, language, "state", domains @@ -243,9 +297,9 @@ class DefaultAgent(AbstractConversationAgent): # This is available in the response template as "state". state1: core.State | None = None if intent_response.matched_states: - state1 = intent_response.matched_states[0] + state1 = matched[0] elif intent_response.unmatched_states: - state1 = intent_response.unmatched_states[0] + state1 = unmatched[0] # Render response template speech = response_template.async_render( @@ -262,13 +316,11 @@ class DefaultAgent(AbstractConversationAgent): "query": { # Entity states that matched the query (e.g, "on") "matched": [ - template.TemplateState(self.hass, state) - for state in intent_response.matched_states + template.TemplateState(self.hass, state) for state in matched ], # Entity states that did not match the query "unmatched": [ - template.TemplateState(self.hass, state) - for state in intent_response.unmatched_states + template.TemplateState(self.hass, state) for state in unmatched ], }, } @@ -321,6 +373,9 @@ class DefaultAgent(AbstractConversationAgent): intents_dict = lang_intents.intents_dict loaded_components = lang_intents.loaded_components + # en-US, en_US, en, ... + language_variations = list(_get_language_variations(language)) + # Check if any new components have been loaded intents_changed = False for component in hass_components: @@ -332,7 +387,7 @@ class DefaultAgent(AbstractConversationAgent): # Check for intents for this component with the target language. # Try en-US, en, etc. - for language_variation in _get_language_variations(language): + for language_variation in language_variations: component_intents = get_intents( component, language_variation, json_load=json_load ) @@ -343,7 +398,10 @@ class DefaultAgent(AbstractConversationAgent): # Will need to recreate graph intents_changed = True _LOGGER.debug( - "Loaded intents component=%s, language=%s", component, language + "Loaded intents component=%s, language=%s (%s)", + component, + language, + language_variation, ) break @@ -351,24 +409,31 @@ class DefaultAgent(AbstractConversationAgent): if lang_intents is None: # Only load custom sentences once, otherwise they will be re-loaded # when components change. - custom_sentences_dir = Path( - self.hass.config.path("custom_sentences", language) - ) - if custom_sentences_dir.is_dir(): - for custom_sentences_path in custom_sentences_dir.rglob("*.yaml"): - with custom_sentences_path.open( - encoding="utf-8" - ) as custom_sentences_file: - # Merge custom sentences - merge_dict(intents_dict, yaml.safe_load(custom_sentences_file)) + for language_variation in language_variations: + custom_sentences_dir = Path( + self.hass.config.path("custom_sentences", language_variation) + ) + if custom_sentences_dir.is_dir(): + for custom_sentences_path in custom_sentences_dir.rglob("*.yaml"): + with custom_sentences_path.open( + encoding="utf-8" + ) as custom_sentences_file: + # Merge custom sentences + merge_dict( + intents_dict, yaml.safe_load(custom_sentences_file) + ) - # Will need to recreate graph - intents_changed = True - _LOGGER.debug( - "Loaded custom sentences language=%s, path=%s", - language, - custom_sentences_path, - ) + # Will need to recreate graph + intents_changed = True + _LOGGER.debug( + "Loaded custom sentences language=%s (%s), path=%s", + language, + language_variation, + custom_sentences_path, + ) + + # Stop after first matched language variation + break # Load sentences from HA config for default language only if self._config_intents and (language == self.hass.config.language): @@ -425,8 +490,10 @@ 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" or not any( + field in event.data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS + ): return self._slot_lists = None @@ -437,51 +504,62 @@ class DefaultAgent(AbstractConversationAgent): return self._slot_lists = None - def _make_slot_lists(self) -> Mapping[str, SlotList]: + @core.callback + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" + self._slot_lists = None + + def _make_slot_lists(self) -> dict[str, SlotList]: """Create slot lists with areas and entity names/aliases.""" if self._slot_lists is not None: return self._slot_lists area_ids_with_entities: set[str] = set() + entity_registry = er.async_get(self.hass) states = [ - state for state in self.hass.states.async_all() if is_entity_exposed(state) + state + for state in self.hass.states.async_all() + if async_should_expose(self.hass, DOMAIN, state.entity_id) ] - entities = entity_registry.async_get(self.hass) - devices = device_registry.async_get(self.hass) + devices = dr.async_get(self.hass) # Gather exposed entity names entity_names = [] for state in states: # Checked against "requires_context" and "excludes_context" in hassil context = {"domain": state.domain} + if state.attributes: + # Include some attributes + for attr in DEFAULT_EXPOSED_ATTRIBUTES: + if attr not in state.attributes: + continue + context[attr] = state.attributes[attr] - entity = entities.async_get(state.entity_id) - if entity is not None: - if entity.entity_category or entity.hidden: - # Skip configuration/diagnostic/hidden entities - continue - - if entity.aliases: - for alias in entity.aliases: - entity_names.append((alias, alias, context)) + entity = entity_registry.async_get(state.entity_id) + if not entity: # Default name entity_names.append((state.name, state.name, context)) + continue - if entity.area_id: - # Expose area too - area_ids_with_entities.add(entity.area_id) - elif entity.device_id: - # Check device for area as well - device = devices.async_get(entity.device_id) - if (device is not None) and device.area_id: - area_ids_with_entities.add(device.area_id) - else: - # Default name - entity_names.append((state.name, state.name, context)) + if entity.aliases: + for alias in entity.aliases: + entity_names.append((alias, alias, context)) + + # Default name + entity_names.append((state.name, state.name, context)) + + if entity.area_id: + # Expose area too + area_ids_with_entities.add(entity.area_id) + elif entity.device_id: + # Check device for area as well + device = devices.async_get(entity.device_id) + if (device is not None) and device.area_id: + area_ids_with_entities.add(device.area_id) # Gather areas from exposed entities - areas = area_registry.async_get(self.hass) + areas = ar.async_get(self.hass) area_names = [] for area_id in area_ids_with_entities: area = areas.async_get_area(area_id) @@ -493,6 +571,9 @@ class DefaultAgent(AbstractConversationAgent): for alias in area.aliases: area_names.append((alias, area.id)) + _LOGGER.debug("Exposed areas: %s", area_names) + _LOGGER.debug("Exposed entities: %s", entity_names) + self._slot_lists = { "area": TextSlotList.from_tuples(area_names, allow_template=False), "name": TextSlotList.from_tuples(entity_names, allow_template=False), diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index d33e2dc5f5..209f239df0 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==0.2.6", "home-assistant-intents==2023.1.31"] + "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.4.26"] } diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index 129797c356..289e70e806 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -21,9 +21,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Coolmaster from a config entry.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] - coolmaster = CoolMasterNet( - host, port, swing_support=entry.data.get(CONF_SWING_SUPPORT, False) - ) + if not entry.data.get(CONF_SWING_SUPPORT): + coolmaster = CoolMasterNet( + host, + port, + ) + else: + # Swing support adds an additional request per unit. The requests are + # done in parallel, which can cause delays on the server. Therefore, + # we increase the request timeout to 5 seconds instead of 1. + coolmaster = CoolMasterNet( + host, + port, + read_timeout=5, + swing_support=True, + ) try: info = await coolmaster.info() if not info: diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py deleted file mode 100644 index a1c4f876f6..0000000000 --- a/homeassistant/components/coronavirus/__init__.py +++ /dev/null @@ -1,86 +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, 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: entity_registry.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 entity_registry.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..a77e434088 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -44,6 +44,7 @@ SERVICE_DECREMENT = "decrement" SERVICE_INCREMENT = "increment" SERVICE_RESET = "reset" SERVICE_CONFIGURE = "configure" +SERVICE_SET_VALUE = "set_value" STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -106,7 +107,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,13 +118,18 @@ 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) component.async_register_entity_service(SERVICE_INCREMENT, {}, "async_increment") component.async_register_entity_service(SERVICE_DECREMENT, {}, "async_decrement") component.async_register_entity_service(SERVICE_RESET, {}, "async_reset") + component.async_register_entity_service( + SERVICE_SET_VALUE, + {vol.Required(VALUE): cv.positive_int}, + "async_set_value", + ) component.async_register_entity_service( SERVICE_CONFIGURE, { @@ -140,7 +145,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 +159,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): @@ -262,6 +267,27 @@ class Counter(collection.CollectionEntity, RestoreEntity): self._state = self.compute_next_state(self._config[CONF_INITIAL]) self.async_write_ha_state() + @callback + def async_set_value(self, value: int) -> None: + """Set counter to value.""" + if (maximum := self._config.get(CONF_MAXIMUM)) is not None and value > maximum: + raise ValueError( + f"Value {value} for {self.entity_id} exceeding the maximum value of {maximum}" + ) + + if (minimum := self._config.get(CONF_MINIMUM)) is not None and value < minimum: + raise ValueError( + f"Value {value} for {self.entity_id} exceeding the minimum value of {minimum}" + ) + + if (step := self._config.get(CONF_STEP)) is not None and value % step != 0: + raise ValueError( + f"Value {value} for {self.entity_id} is not a multiple of the step size {step}" + ) + + self._state = value + self.async_write_ha_state() + @callback def async_configure(self, **kwargs) -> None: """Change the counter's settings with a service.""" diff --git a/homeassistant/components/counter/services.yaml b/homeassistant/components/counter/services.yaml index 1930ba0d45..d94d05dd7b 100644 --- a/homeassistant/components/counter/services.yaml +++ b/homeassistant/components/counter/services.yaml @@ -21,6 +21,23 @@ reset: entity: domain: counter +set_value: + name: Set + description: Set the counter value + target: + entity: + domain: counter + fields: + value: + name: Value + required: true + description: The new counter value the entity should be set to. + selector: + number: + min: 0 + max: 9223372036854775807 + mode: box + configure: name: Configure description: Change counter parameters. diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json new file mode 100644 index 0000000000..548d155408 --- /dev/null +++ b/homeassistant/components/counter/strings.json @@ -0,0 +1,29 @@ +{ + "title": "Counter", + "entity_component": { + "_": { + "name": "[%key:component::counter::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" + }, + "maximum": { + "name": "Maximum" + }, + "minimum": { + "name": "Minimum" + }, + "step": { + "name": "Step" + } + } + } + } +} diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index c3c0e928f0..dd22821d5e 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -18,7 +18,7 @@ from homeassistant.const import ( SERVICE_STOP_COVER, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -63,11 +63,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Cover devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue @@ -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/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index bb66d54b79..6144bdb6db 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -18,7 +18,11 @@ from homeassistant.const import ( STATE_OPENING, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -66,11 +70,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Cover devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions: list[dict[str, str]] = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index b0be418f31..aad225c803 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -24,7 +24,7 @@ from homeassistant.const import ( STATE_OPENING, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -71,11 +71,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Cover devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index cb98c542d4..2f61bd9508 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -27,13 +27,54 @@ "tilt_position": "{entity_name} tilt position changes" } }, - "state": { + "entity_component": { "_": { - "open": "[%key:common::state::open%]", - "opening": "Opening", - "closed": "[%key:common::state::closed%]", - "closing": "Closing", - "stopped": "Stopped" + "name": "[%key:component::cover::title%]", + "state": { + "open": "[%key:common::state::open%]", + "opening": "Opening", + "closed": "[%key:common::state::closed%]", + "closing": "Closing", + "stopped": "Stopped" + }, + "state_attributes": { + "current_position": { + "name": "Position" + }, + "current_tilt_position": { + "name": "Tilt position" + } + } + }, + "awning": { + "name": "Awning" + }, + "blind": { + "name": "Blind" + }, + "curtain": { + "name": "Curtain" + }, + "damper": { + "name": "Damper" + }, + "door": { + "name": "Door" + }, + "garage": { + "name": "Garage" + }, + "gate": { + "name": "Gate" + }, + "shade": { + "name": "Shade" + }, + "shutter": { + "name": "Shutter" + }, + "window": { + "name": "Window" } } } 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/date/__init__.py b/homeassistant/components/date/__init__.py new file mode 100644 index 0000000000..51f3a492c4 --- /dev/null +++ b/homeassistant/components/date/__init__.py @@ -0,0 +1,109 @@ +"""Component to allow setting date as platforms.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +import logging +from typing import final + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_DATE +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, SERVICE_SET_VALUE + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +_LOGGER = logging.getLogger(__name__) + +__all__ = ["DOMAIN", "DateEntity", "DateEntityDescription"] + + +async def _async_set_value(entity: DateEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new date.""" + return await entity.async_set_value(service_call.data[ATTR_DATE]) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Date entities.""" + component = hass.data[DOMAIN] = EntityComponent[DateEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_SET_VALUE, {vol.Required(ATTR_DATE): cv.date}, _async_set_value + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[DateEntity] = 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[DateEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class DateEntityDescription(EntityDescription): + """A class that describes date entities.""" + + +class DateEntity(Entity): + """Representation of a Date entity.""" + + entity_description: DateEntityDescription + _attr_device_class: None + _attr_native_value: date | None + _attr_state: None = None + + @property + @final + def device_class(self) -> None: + """Return the device class for the entity.""" + return None + + @property + @final + def state_attributes(self) -> None: + """Return the state attributes.""" + return None + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if self.native_value is None: + return None + return self.native_value.isoformat() + + @property + def native_value(self) -> date | None: + """Return the value reported by the date.""" + return self._attr_native_value + + def set_value(self, value: date) -> None: + """Change the date.""" + raise NotImplementedError() + + async def async_set_value(self, value: date) -> None: + """Change the date.""" + await self.hass.async_add_executor_job(self.set_value, value) diff --git a/homeassistant/components/date/const.py b/homeassistant/components/date/const.py new file mode 100644 index 0000000000..aa87b330e0 --- /dev/null +++ b/homeassistant/components/date/const.py @@ -0,0 +1,5 @@ +"""Provides the constants needed for the component.""" + +DOMAIN = "date" + +SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/date/manifest.json b/homeassistant/components/date/manifest.json new file mode 100644 index 0000000000..f0e51390eb --- /dev/null +++ b/homeassistant/components/date/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "date", + "name": "Date", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/date", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/date/services.yaml b/homeassistant/components/date/services.yaml new file mode 100644 index 0000000000..7ce1210f80 --- /dev/null +++ b/homeassistant/components/date/services.yaml @@ -0,0 +1,14 @@ +set_value: + name: Set Date + description: Set the date for a date entity. + target: + entity: + domain: date + fields: + date: + name: Date + description: The date to set. + required: true + example: "2022/11/01" + selector: + date: diff --git a/homeassistant/components/date/strings.json b/homeassistant/components/date/strings.json new file mode 100644 index 0000000000..110a4cabb9 --- /dev/null +++ b/homeassistant/components/date/strings.json @@ -0,0 +1,8 @@ +{ + "title": "Date", + "entity_component": { + "_": { + "name": "[%key:component::date::title%]" + } + } +} diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index ba34ec48e0..7874786adb 100644 --- a/homeassistant/components/ddwrt/device_tracker.py +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -55,7 +55,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> DdWrtDeviceScanner | class DdWrtDeviceScanner(DeviceScanner): - """This class queries a wireless router running DD-WRT firmware.""" + """Class which queries a wireless router running DD-WRT firmware.""" def __init__(self, config): """Initialize the DD-WRT scanner.""" 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 05d4e81a4e..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==107"], + "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/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 12b7ce0dd8..eed194640d 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, Platform, UnitOfDataRate from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import DelugeEntity @@ -71,9 +71,7 @@ SENSOR_TYPES: tuple[DelugeSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Deluge sensor.""" async_add_entities( diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py index 5b3989384c..f9e89543d2 100644 --- a/homeassistant/components/deluge/switch.py +++ b/homeassistant/components/deluge/switch.py @@ -7,7 +7,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DelugeEntity from .const import DOMAIN @@ -15,9 +15,7 @@ from .coordinator import DelugeDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Deluge switch.""" async_add_entities([DelugeSwitch(hass.data[DOMAIN][entry.entry_id])]) diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 13e8e13539..85f7b89483 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -27,6 +27,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.DATE, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, @@ -36,8 +37,10 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.SELECT, Platform.SENSOR, Platform.SIREN, + Platform.STT, Platform.SWITCH, Platform.TEXT, + Platform.TIME, Platform.UPDATE, Platform.VACUUM, Platform.WATER_HEATER, diff --git a/homeassistant/components/demo/date.py b/homeassistant/components/demo/date.py new file mode 100644 index 0000000000..eb96bc4903 --- /dev/null +++ b/homeassistant/components/demo/date.py @@ -0,0 +1,73 @@ +"""Demo platform that offers a fake Date entity.""" +from __future__ import annotations + +from datetime import date + +from homeassistant.components.date import DateEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import DOMAIN + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Demo date entity.""" + async_add_entities( + [ + DemoDate( + "date", + "Date", + date(2020, 1, 1), + "mdi:calendar", + False, + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoDate(DateEntity): + """Representation of a Demo date entity.""" + + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + name: str, + state: date, + icon: str, + assumed_state: bool, + ) -> None: + """Initialize the Demo date entity.""" + self._attr_assumed_state = assumed_state + self._attr_icon = icon + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_native_value = state + self._attr_unique_id = unique_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, name=self.name + ) + + async def async_set_value(self, value: date) -> None: + """Update the date.""" + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/demo/geo_location.py b/homeassistant/components/demo/geo_location.py index cc29205b72..cd020d1bb8 100644 --- a/homeassistant/components/demo/geo_location.py +++ b/homeassistant/components/demo/geo_location.py @@ -86,7 +86,10 @@ class DemoManager: def _init_regular_updates(self) -> None: """Schedule regular updates based on configured time interval.""" track_time_interval( - self._hass, lambda now: self._update(), DEFAULT_UPDATE_INTERVAL + self._hass, + lambda now: self._update(), + DEFAULT_UPDATE_INTERVAL, + cancel_on_shutdown=True, ) def _update(self, count: int = 1) -> None: @@ -110,7 +113,7 @@ class DemoManager: class DemoGeolocationEvent(GeolocationEvent): - """This represents a demo geolocation event.""" + """Represents a demo geolocation event.""" _attr_should_poll = False diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index 67a7b346a3..84758f0c29 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -126,7 +126,7 @@ async def async_setup_platform( ), DemoSensor( unique_id="sensor_10", - name="Thermostat mode", + name=None, state="eco", device_class=SensorDeviceClass.ENUM, state_class=None, @@ -156,7 +156,7 @@ class DemoSensor(SensorEntity): def __init__( self, unique_id: str, - name: str, + name: str | None, state: StateType, device_class: SensorDeviceClass, state_class: SensorStateClass | None, @@ -167,7 +167,10 @@ class DemoSensor(SensorEntity): ) -> None: """Initialize the sensor.""" self._attr_device_class = device_class - self._attr_name = name + if name is not None: + self._attr_name = name + else: + self._attr_has_entity_name = True self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_value = state self._attr_state_class = state_class diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index 286bd5d093..add04c236e 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -51,7 +51,8 @@ "constant": "Constant", "bool": "Optional boolean", "int": "Numeric input" - } + }, + "submit": "Save!" }, "options_2": { "data": { @@ -97,6 +98,7 @@ }, "sensor": { "thermostat_mode": { + "name": "Thermostat mode", "state": { "away": "Away", "comfort": "Comfort", 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/demo/time.py b/homeassistant/components/demo/time.py new file mode 100644 index 0000000000..aafd425a02 --- /dev/null +++ b/homeassistant/components/demo/time.py @@ -0,0 +1,63 @@ +"""Demo platform that offers a fake time entity.""" +from __future__ import annotations + +from datetime import time + +from homeassistant.components.time import TimeEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import DOMAIN + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Demo time entity.""" + async_add_entities([DemoTime("time", "Time", time(12, 0, 0), "mdi:clock", False)]) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoTime(TimeEntity): + """Representation of a Demo time entity.""" + + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + name: str, + state: time, + icon: str, + assumed_state: bool, + ) -> None: + """Initialize the Demo time entity.""" + self._attr_assumed_state = assumed_state + self._attr_icon = icon + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_native_value = state + self._attr_unique_id = unique_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, name=self.name + ) + + async def async_set_value(self, value: time) -> None: + """Update the time.""" + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index 2d6a127ff3..660e4c770b 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/denonavr", "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==0.11.1"], + "requirements": ["denonavr==0.11.2"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 5e636c5cfa..eab4c1df3a 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -249,11 +249,19 @@ class DenonDevice(MediaPlayerEntity): self._telnet_was_healthy: bool | None = None - async def _telnet_callback(self, zone, event, parameter): + async def _telnet_callback(self, zone, event, parameter) -> None: """Process a telnet command callback.""" + # There are multiple checks implemented which reduce unnecessary updates of the ha state machine if zone != self._receiver.zone: return - + # Some updates trigger multiple events like one for artist and one for title for one change + # We skip every event except the last one + if event == "NS" and not parameter.startswith("E4"): + return + if event == "TA" and not parameter.startwith("ANNAME"): + return + if event == "HD" and not parameter.startswith("ALBUM"): + return self.async_write_ha_state() async def async_added_to_hass(self) -> None: @@ -276,7 +284,6 @@ class DenonDevice(MediaPlayerEntity): if ( telnet_is_healthy := receiver.telnet_connected and receiver.telnet_healthy ) and self._telnet_was_healthy: - await receiver.input.async_update_media_state() return # if async_update raises an exception, we don't want to skip the next update 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_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 3465e9ae27..d5017ac232 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -29,7 +29,10 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound -from homeassistant.requirements import async_get_integration_with_requirements +from homeassistant.requirements import ( + RequirementsNotFound, + async_get_integration_with_requirements, +) from .const import ( # noqa: F401 CONF_IS_OFF, @@ -171,6 +174,10 @@ async def async_get_device_automation_platform( raise InvalidDeviceAutomationConfig( f"Integration '{domain}' not found" ) from err + except RequirementsNotFound as err: + raise InvalidDeviceAutomationConfig( + f"Integration '{domain}' could not be loaded" + ) from err except ImportError as err: raise InvalidDeviceAutomationConfig( f"Integration '{domain}' does not support device automation " diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 3856458c3d..f819668f09 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.condition import ConditionProtocol, trace_condition_function from homeassistant.helpers.typing import ConfigType from . import DeviceAutomationType, async_get_device_automation_platform @@ -17,24 +18,13 @@ if TYPE_CHECKING: from homeassistant.helpers import condition -class DeviceAutomationConditionProtocol(Protocol): +class DeviceAutomationConditionProtocol(ConditionProtocol, Protocol): """Define the format of device_condition modules. - Each module must define either CONDITION_SCHEMA or async_validate_condition_config. + Each module must define either CONDITION_SCHEMA or async_validate_condition_config + from ConditionProtocol. """ - CONDITION_SCHEMA: vol.Schema - - async def async_validate_condition_config( - self, hass: HomeAssistant, config: ConfigType - ) -> ConfigType: - """Validate config.""" - - def async_condition_from_config( - self, hass: HomeAssistant, config: ConfigType - ) -> condition.ConditionCheckerType: - """Evaluate state based on configuration.""" - async def async_get_condition_capabilities( self, hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: @@ -62,4 +52,4 @@ async def async_condition_from_config( platform = await async_get_device_automation_platform( hass, config[CONF_DOMAIN], DeviceAutomationType.CONDITION ) - return platform.async_condition_from_config(hass, config) + return trace_condition_function(platform.async_condition_from_config(hass, config)) diff --git a/homeassistant/components/device_automation/trigger.py b/homeassistant/components/device_automation/trigger.py index 80e96ddaba..6bbbd6febc 100644 --- a/homeassistant/components/device_automation/trigger.py +++ b/homeassistant/components/device_automation/trigger.py @@ -7,7 +7,11 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + TriggerProtocol, +) from homeassistant.helpers.typing import ConfigType from . import ( @@ -20,28 +24,13 @@ from .helpers import async_validate_device_automation_config TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) -class DeviceAutomationTriggerProtocol(Protocol): +class DeviceAutomationTriggerProtocol(TriggerProtocol, Protocol): """Define the format of device_trigger modules. - Each module must define either TRIGGER_SCHEMA or async_validate_trigger_config. + Each module must define either TRIGGER_SCHEMA or async_validate_trigger_config + from TriggerProtocol. """ - TRIGGER_SCHEMA: vol.Schema - - async def async_validate_trigger_config( - self, hass: HomeAssistant, config: ConfigType - ) -> ConfigType: - """Validate config.""" - - async def async_attach_trigger( - self, - hass: HomeAssistant, - config: ConfigType, - action: TriggerActionType, - trigger_info: TriggerInfo, - ) -> CALLBACK_TYPE: - """Attach a trigger.""" - async def async_get_trigger_capabilities( self, hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: 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/device_condition.py b/homeassistant/components/device_tracker/device_condition.py index 1a6adabda6..96ee70baca 100644 --- a/homeassistant/components/device_tracker/device_condition.py +++ b/homeassistant/components/device_tracker/device_condition.py @@ -13,7 +13,11 @@ from homeassistant.const import ( STATE_HOME, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -33,11 +37,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Device tracker devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index 231fab65d3..150b587227 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_ZONE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -38,11 +38,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Device Tracker devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue 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/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index 48cb667e73..c15b9723c9 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -10,10 +10,36 @@ "leaves": "{entity_name} leaves a zone" } }, - "state": { + "entity_component": { "_": { - "home": "[%key:common::state::home%]", - "not_home": "[%key:common::state::not_home%]" + "name": "[%key:component::device_tracker::title%]", + "state": { + "home": "[%key:common::state::home%]", + "not_home": "[%key:common::state::not_home%]" + }, + "state_attributes": { + "battery": { + "name": "Battery" + }, + "gps_accuracy": { + "name": "GPS accuracy" + }, + "latitude": { + "name": "Latitude" + }, + "longitude": { + "name": "Longitude" + }, + "source_type": { + "name": "Source", + "state": { + "bluetooth_le": "Bluetooth LE", + "bluetooth": "Bluetooth", + "gps": "GPS", + "router": "Router" + } + } + } } } } diff --git a/homeassistant/components/devolo_home_control/diagnostics.py b/homeassistant/components/devolo_home_control/diagnostics.py new file mode 100644 index 0000000000..412effcd5e --- /dev/null +++ b/homeassistant/components/devolo_home_control/diagnostics.py @@ -0,0 +1,49 @@ +"""Diagnostics support for devolo Home Control.""" +from __future__ import annotations + +from typing import Any + +from devolo_home_control_api.homecontrol import HomeControl + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {CONF_PASSWORD, CONF_USERNAME} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + gateways: list[HomeControl] = hass.data[DOMAIN][entry.entry_id]["gateways"] + + device_info = [] + for gateway in gateways: + device_info.append( + { + "gateway": { + "local_connection": gateway.gateway.local_connection, + "firmware_version": gateway.gateway.firmware_version, + }, + "devices": [ + { + "device_id": device_id, + "device_model_uid": properties.device_model_uid, + "device_type": properties.device_type, + "name": properties.name, + } + for device_id, properties in gateway.devices.items() + ], + } + ) + + diag_data = { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "device_info": device_info, + } + + return diag_data 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/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 5fdb75bb5f..00d96ea53b 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -20,8 +20,13 @@ from devolo_plc_api.plcnet_api import LogicalNetwork from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_PASSWORD, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -32,7 +37,6 @@ from .const import ( DOMAIN, LONG_UPDATE_INTERVAL, NEIGHBORING_WIFI_NETWORKS, - PLATFORMS, SHORT_UPDATE_INTERVAL, SWITCH_GUEST_WIFI, SWITCH_LEDS, @@ -156,7 +160,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for coordinator in coordinators.values(): await coordinator.async_config_entry_first_refresh() - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, platforms(device)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect) @@ -167,9 +171,23 @@ 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) + device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + unload_ok = await hass.config_entries.async_unload_platforms( + entry, platforms(device) + ) if unload_ok: - await hass.data[DOMAIN][entry.entry_id]["device"].async_disconnect() + await device.async_disconnect() hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +@callback +def platforms(device: Device) -> set[Platform]: + """Assemble supported platforms.""" + supported_platforms = {Platform.BUTTON, Platform.SENSOR, Platform.SWITCH} + if device.plcnet: + supported_platforms.add(Platform.BINARY_SENSOR) + if device.device and "wifi1" in device.device.features: + supported_platforms.add(Platform.DEVICE_TRACKER) + return supported_platforms diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index e927ea9333..ebe7e60af7 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONNECTED_PLC_DEVICES, CONNECTED_TO_ROUTER, DOMAIN -from .entity import DevoloEntity +from .entity import DevoloCoordinatorEntity def _is_connected_to_router(entity: DevoloBinarySensorEntity) -> bool: @@ -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, ), } @@ -69,19 +68,20 @@ async def async_setup_entry( ]["coordinators"] entities: list[BinarySensorEntity] = [] - if device.plcnet: - entities.append( - DevoloBinarySensorEntity( - entry, - coordinators[CONNECTED_PLC_DEVICES], - SENSOR_TYPES[CONNECTED_TO_ROUTER], - device, - ) + entities.append( + DevoloBinarySensorEntity( + entry, + coordinators[CONNECTED_PLC_DEVICES], + SENSOR_TYPES[CONNECTED_TO_ROUTER], + device, ) + ) async_add_entities(entities) -class DevoloBinarySensorEntity(DevoloEntity[LogicalNetwork], BinarySensorEntity): +class DevoloBinarySensorEntity( + DevoloCoordinatorEntity[LogicalNetwork], BinarySensorEntity +): """Representation of a devolo binary sensor.""" def __init__( diff --git a/homeassistant/components/devolo_home_network/button.py b/homeassistant/components/devolo_home_network/button.py new file mode 100644 index 0000000000..463356268a --- /dev/null +++ b/homeassistant/components/devolo_home_network/button.py @@ -0,0 +1,133 @@ +"""Platform for button integration.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from devolo_plc_api.device import Device +from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, IDENTIFY, PAIRING, RESTART, START_WPS +from .entity import DevoloEntity + + +@dataclass +class DevoloButtonRequiredKeysMixin: + """Mixin for required keys.""" + + press_func: Callable[[Device], Awaitable[bool]] + + +@dataclass +class DevoloButtonEntityDescription( + ButtonEntityDescription, DevoloButtonRequiredKeysMixin +): + """Describes devolo button entity.""" + + +BUTTON_TYPES: dict[str, DevoloButtonEntityDescription] = { + IDENTIFY: DevoloButtonEntityDescription( + key=IDENTIFY, + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:led-on", + press_func=lambda device: device.plcnet.async_identify_device_start(), # type: ignore[union-attr] + ), + PAIRING: DevoloButtonEntityDescription( + key=PAIRING, + icon="mdi:plus-network-outline", + press_func=lambda device: device.plcnet.async_pair_device(), # type: ignore[union-attr] + ), + RESTART: DevoloButtonEntityDescription( + key=RESTART, + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_func=lambda device: device.device.async_restart(), # type: ignore[union-attr] + ), + START_WPS: DevoloButtonEntityDescription( + key=START_WPS, + icon="mdi:wifi-plus", + press_func=lambda device: device.device.async_start_wps(), # type: ignore[union-attr] + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Get all devices and buttons and setup them via config entry.""" + device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + + entities: list[DevoloButtonEntity] = [] + if device.plcnet: + entities.append( + DevoloButtonEntity( + entry, + BUTTON_TYPES[IDENTIFY], + device, + ) + ) + entities.append( + DevoloButtonEntity( + entry, + BUTTON_TYPES[PAIRING], + device, + ) + ) + if device.device and "restart" in device.device.features: + entities.append( + DevoloButtonEntity( + entry, + BUTTON_TYPES[RESTART], + device, + ) + ) + if device.device and "wifi1" in device.device.features: + entities.append( + DevoloButtonEntity( + entry, + BUTTON_TYPES[START_WPS], + device, + ) + ) + async_add_entities(entities) + + +class DevoloButtonEntity(DevoloEntity, ButtonEntity): + """Representation of a devolo button.""" + + entity_description: DevoloButtonEntityDescription + + def __init__( + self, + entry: ConfigEntry, + description: DevoloButtonEntityDescription, + device: Device, + ) -> None: + """Initialize entity.""" + self.entity_description = description + super().__init__(entry, device) + + async def async_press(self) -> None: + """Handle the button press.""" + try: + await self.entity_description.press_func(self.device) + except DevicePasswordProtected as ex: + self.entry.async_start_reauth(self.hass) + raise HomeAssistantError( + f"Device {self.entry.title} require re-authenticatication to set or change the password" + ) from ex + except DeviceUnavailable as ex: + raise HomeAssistantError( + f"Device {self.entry.title} did not respond" + ) from ex diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py index fffe9b5d48..39016ac791 100644 --- a/homeassistant/components/devolo_home_network/const.py +++ b/homeassistant/components/devolo_home_network/const.py @@ -9,16 +9,7 @@ from devolo_plc_api.device_api import ( WIFI_VAP_MAIN_AP, ) -from homeassistant.const import Platform - DOMAIN = "devolo_home_network" -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.DEVICE_TRACKER, - Platform.SENSOR, - Platform.SWITCH, -] - PRODUCT = "product" SERIAL_NUMBER = "serial_number" TITLE = "title" @@ -29,7 +20,11 @@ SHORT_UPDATE_INTERVAL = timedelta(seconds=15) CONNECTED_PLC_DEVICES = "connected_plc_devices" CONNECTED_TO_ROUTER = "connected_to_router" CONNECTED_WIFI_CLIENTS = "connected_wifi_clients" +IDENTIFY = "identify" NEIGHBORING_WIFI_NETWORKS = "neighboring_wifi_networks" +PAIRING = "pairing" +RESTART = "restart" +START_WPS = "start_wps" SWITCH_GUEST_WIFI = "switch_guest_wifi" SWITCH_LEDS = "switch_leds" diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index 79f2eb1f49..c73e08abed 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -12,7 +12,7 @@ from homeassistant.components.device_tracker import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNKNOWN, UnitOfFrequency from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -30,7 +30,7 @@ async def async_setup_entry( coordinators: dict[ str, DataUpdateCoordinator[list[ConnectedStationInfo]] ] = hass.data[DOMAIN][entry.entry_id]["coordinators"] - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) tracked = set() @callback @@ -53,9 +53,7 @@ async def async_setup_entry( def restore_entities() -> None: """Restore clients that are not a part of active clients list.""" missing = [] - for entity in entity_registry.async_entries_for_config_entry( - registry, entry.entry_id - ): + for entity in er.async_entries_for_config_entry(registry, entry.entry_id): if ( entity.platform == DOMAIN and entity.domain == DEVICE_TRACKER_DOMAIN @@ -75,11 +73,10 @@ async def async_setup_entry( async_add_entities(missing) - if device.device and "wifi1" in device.device.features: - restore_entities() - entry.async_on_unload( - coordinators[CONNECTED_WIFI_CLIENTS].async_add_listener(new_device_callback) - ) + restore_entities() + entry.async_on_unload( + coordinators[CONNECTED_WIFI_CLIENTS].async_add_listener(new_device_callback) + ) class DevoloScannerEntity( diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index a26d8dce8f..e477df63bd 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -12,7 +12,7 @@ from devolo_plc_api.device_api import ( from devolo_plc_api.plcnet_api import LogicalNetwork from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -32,7 +32,7 @@ _DataT = TypeVar( ) -class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]): +class DevoloEntity(Entity): """Representation of a devolo home network device.""" _attr_has_entity_name = True @@ -40,12 +40,9 @@ class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]): def __init__( self, entry: ConfigEntry, - coordinator: DataUpdateCoordinator[_DataT], device: Device, ) -> None: """Initialize a devolo home network device.""" - super().__init__(coordinator) - self.device = device self.entry = entry @@ -57,4 +54,21 @@ 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}" + + +class DevoloCoordinatorEntity( + CoordinatorEntity[DataUpdateCoordinator[_DataT]], DevoloEntity +): + """Representation of a coordinated devolo home network device.""" + + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator[_DataT], + device: Device, + ) -> None: + """Initialize a devolo home network device.""" + super().__init__(coordinator) + DevoloEntity.__init__(self, entry, device) diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index e3aaf2beaf..e635b1f702 100644 --- a/homeassistant/components/devolo_home_network/manifest.json +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["devolo_plc_api"], "quality_scale": "platinum", - "requirements": ["devolo-plc-api==1.2.0"], + "requirements": ["devolo-plc-api==1.3.1"], "zeroconf": [ { "type": "_dvl-deviceapi._tcp.local.", diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 2c2637c2f8..7a6da1f41a 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -26,7 +26,7 @@ from .const import ( DOMAIN, NEIGHBORING_WIFI_NETWORKS, ) -from .entity import DevoloEntity +from .entity import DevoloCoordinatorEntity _DataT = TypeVar( "_DataT", @@ -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, ), } @@ -116,7 +113,7 @@ async def async_setup_entry( async_add_entities(entities) -class DevoloSensorEntity(DevoloEntity[_DataT], SensorEntity): +class DevoloSensorEntity(DevoloCoordinatorEntity[_DataT], SensorEntity): """Representation of a devolo sensor.""" entity_description: DevoloSensorEntityDescription[_DataT] diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 6c320710a1..e2954c1c7e 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -27,5 +27,45 @@ "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" + } + }, + "button": { + "identify": { + "name": "Identify device with a blinking LED" + }, + "pairing": { + "name": "Start PLC pairing" + }, + "restart": { + "name": "Restart device" + }, + "start_wps": { + "name": "Start WPS" + } + }, + "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..e7bcee3f2e 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -13,11 +13,12 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, SWITCH_GUEST_WIFI, SWITCH_LEDS -from .entity import DevoloEntity +from .entity import DevoloCoordinatorEntity _DataT = TypeVar("_DataT", bound=WifiGuestAccessGet | bool) @@ -42,7 +43,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 +51,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] @@ -90,7 +89,7 @@ async def async_setup_entry( async_add_entities(entities) -class DevoloSwitchEntity(DevoloEntity[_DataT], SwitchEntity): +class DevoloSwitchEntity(DevoloCoordinatorEntity[_DataT], SwitchEntity): """Representation of a devolo switch.""" entity_description: DevoloSwitchEntityDescription[_DataT] @@ -115,8 +114,11 @@ class DevoloSwitchEntity(DevoloEntity[_DataT], SwitchEntity): """Turn the entity on.""" try: await self.entity_description.turn_on_func(self.device) - except DevicePasswordProtected: + except DevicePasswordProtected as ex: self.entry.async_start_reauth(self.hass) + raise HomeAssistantError( + f"Device {self.entry.title} require re-authenticatication to set or change the password" + ) from ex except DeviceUnavailable: pass # The coordinator will handle this await self.coordinator.async_request_refresh() @@ -125,8 +127,11 @@ class DevoloSwitchEntity(DevoloEntity[_DataT], SwitchEntity): """Turn the entity off.""" try: await self.entity_description.turn_off_func(self.device) - except DevicePasswordProtected: + except DevicePasswordProtected as ex: self.entry.async_start_reauth(self.hass) + raise HomeAssistantError( + f"Device {self.entry.title} require re-authenticatication to set or change the password" + ) from ex except DeviceUnavailable: pass # The coordinator will handle this await self.coordinator.async_request_refresh() 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 0765c762b8..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.13"] + "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/dlink/config_flow.py b/homeassistant/components/dlink/config_flow.py index 4499e2efff..09df5571a7 100644 --- a/homeassistant/components/dlink/config_flow.py +++ b/homeassistant/components/dlink/config_flow.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult from .const import CONF_USE_LEGACY_PROTOCOL, DEFAULT_NAME, DEFAULT_USERNAME, DOMAIN @@ -72,15 +72,6 @@ class DLinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a config entry.""" - self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) - title = config.pop(CONF_NAME, DEFAULT_NAME) - return self.async_create_entry( - title=title, - data=config, - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/dlink/entity.py b/homeassistant/components/dlink/entity.py index 33302f7fff..bfe16abd78 100644 --- a/homeassistant/components/dlink/entity.py +++ b/homeassistant/components/dlink/entity.py @@ -1,7 +1,7 @@ """Entity representing a D-Link Power Plug device.""" from __future__ import annotations -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription @@ -14,6 +14,7 @@ class DLinkEntity(Entity): """Representation of a D-Link Power Plug entity.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, @@ -24,10 +25,6 @@ class DLinkEntity(Entity): """Initialize a D-Link Power Plug entity.""" self.data = data self.entity_description = description - if config_entry.source == SOURCE_IMPORT: - self._attr_name = config_entry.title - else: - self._attr_has_entity_name = True self._attr_unique_id = f"{config_entry.entry_id}_{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, config_entry.entry_id)}, diff --git a/homeassistant/components/dlink/switch.py b/homeassistant/components/dlink/switch.py index e6b9a4c788..d06372bb28 100644 --- a/homeassistant/components/dlink/switch.py +++ b/homeassistant/components/dlink/switch.py @@ -4,80 +4,23 @@ from __future__ import annotations from datetime import timedelta from typing import Any -import voluptuous as vol - -from homeassistant.components.switch import ( - PLATFORM_SCHEMA, - SwitchEntity, - SwitchEntityDescription, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_USERNAME, - UnitOfTemperature, -) +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature 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, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ( - ATTR_TOTAL_CONSUMPTION, - CONF_USE_LEGACY_PROTOCOL, - DEFAULT_NAME, - DEFAULT_USERNAME, - DOMAIN, -) +from .const import ATTR_TOTAL_CONSUMPTION, DOMAIN from .entity import DLinkEntity SCAN_INTERVAL = timedelta(minutes=2) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD, default=""): cv.string, - vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_USE_LEGACY_PROTOCOL, default=False): cv.boolean, - } -) - SWITCH_TYPE = SwitchEntityDescription( key="switch", name="Switch", ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up a D-Link Smart Plug.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.4.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index 219f0497ff..bcd402e6a6 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -21,8 +21,7 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_TYPE, from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import IntegrationError -from homeassistant.helpers import device_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( CONF_BROWSE_UNFILTERED, @@ -501,4 +500,4 @@ async def _async_get_mac_address(hass: HomeAssistant, host: str) -> str | None: if not mac_address: return None - return device_registry.format_mac(mac_address) + return dr.format_mac(mac_address) diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 63bdb8fa60..eddb2633be 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -29,7 +29,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_DEVICE_ID, CONF_MAC, CONF_TYPE, CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -363,21 +363,21 @@ class DlnaDmrEntity(MediaPlayerEntity): # device's UDN. They may be the same, if the DMR is the root device. connections.add( ( - device_registry.CONNECTION_UPNP, + dr.CONNECTION_UPNP, self._device.profile_device.root_device.udn, ) ) - connections.add((device_registry.CONNECTION_UPNP, self._device.udn)) + connections.add((dr.CONNECTION_UPNP, self._device.udn)) if self.mac_address: # Connection based on MAC address, if known connections.add( # Device MAC is obtained from the config entry, which uses getmac - (device_registry.CONNECTION_NETWORK_MAC, self.mac_address) + (dr.CONNECTION_NETWORK_MAC, self.mac_address) ) # Create linked HA DeviceEntry now the information is known. - dev_reg = device_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) device_entry = dev_reg.async_get_or_create( config_entry_id=self.registry_entry.config_entry_id, connections=connections, @@ -388,7 +388,7 @@ class DlnaDmrEntity(MediaPlayerEntity): ) # Update entity registry to link to the device - ent_reg = entity_registry.async_get(self.hass) + ent_reg = er.async_get(self.hass) ent_reg.async_get_or_create( self.registry_entry.domain, self.registry_entry.platform, @@ -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 1f2d83a258..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,13 +13,13 @@ 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 from .models import DormakabaDkeyData -PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -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/binary_sensor.py b/homeassistant/components/dormakaba_dkey/binary_sensor.py index 95e26a3eeb..e21e35da1e 100644 --- a/homeassistant/components/dormakaba_dkey/binary_sensor.py +++ b/homeassistant/components/dormakaba_dkey/binary_sensor.py @@ -45,9 +45,10 @@ BINARY_SENSOR_DESCRIPTIONS = ( ), DormakabaDkeyBinarySensorDescription( key="security_locked", - name="Dead bolt", + name="Deadbolt", device_class=BinarySensorDeviceClass.LOCK, - is_on=lambda state: state.unlock_status != UnlockStatus.SECURITY_LOCKED, + is_on=lambda state: state.unlock_status + not in (UnlockStatus.SECURITY_LOCKED, UnlockStatus.UNLOCKED_SECURITY_LOCKED), ), ) diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index dca19c802b..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: @@ -132,7 +166,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: association_data = await lock.associate(user_input["activation_code"]) - except BleakError: + except BleakError as err: + _LOGGER.warning("BleakError", exc_info=err) return self.async_abort(reason="cannot_connect") except dkey_errors.InvalidActivationCode: errors["base"] = "invalid_code" @@ -142,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/manifest.json b/homeassistant/components/dormakaba_dkey/manifest.json index 206e575b7a..7a4f6b9d90 100644 --- a/homeassistant/components/dormakaba_dkey/manifest.json +++ b/homeassistant/components/dormakaba_dkey/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["py-dormakaba-dkey==1.0.2"] + "requirements": ["py-dormakaba-dkey==1.0.4"] } 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/sensor.py b/homeassistant/components/dsmr/sensor.py index 15c075454f..524f5c4ffc 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -80,7 +80,7 @@ class DSMRSensorEntityDescription( SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key="current_electricity_usage", - name="Power consumption", + translation_key="current_electricity_usage", obis_reference=obis_references.CURRENT_ELECTRICITY_USAGE, device_class=SensorDeviceClass.POWER, force_update=True, @@ -88,7 +88,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="current_electricity_delivery", - name="Power production", + translation_key="current_electricity_delivery", obis_reference=obis_references.CURRENT_ELECTRICITY_DELIVERY, device_class=SensorDeviceClass.POWER, force_update=True, @@ -96,17 +96,16 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_active_tariff", - name="Active tariff", + translation_key="electricity_active_tariff", obis_reference=obis_references.ELECTRICITY_ACTIVE_TARIFF, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, device_class=SensorDeviceClass.ENUM, options=["low", "normal"], - translation_key="electricity_tariff", icon="mdi:flash", ), DSMRSensorEntityDescription( key="electricity_used_tariff_1", - name="Energy consumption (tarif 1)", + translation_key="electricity_used_tariff_1", obis_reference=obis_references.ELECTRICITY_USED_TARIFF_1, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, device_class=SensorDeviceClass.ENERGY, @@ -115,7 +114,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_used_tariff_2", - name="Energy consumption (tarif 2)", + translation_key="electricity_used_tariff_2", obis_reference=obis_references.ELECTRICITY_USED_TARIFF_2, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, @@ -124,7 +123,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_delivered_tariff_1", - name="Energy production (tarif 1)", + translation_key="electricity_delivered_tariff_1", obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_1, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, @@ -133,7 +132,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_delivered_tariff_2", - name="Energy production (tarif 2)", + translation_key="electricity_delivered_tariff_2", obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_2, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, @@ -142,7 +141,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l1_positive", - name="Power consumption phase L1", + translation_key="instantaneous_active_power_l1_positive", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -150,7 +149,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l2_positive", - name="Power consumption phase L2", + translation_key="instantaneous_active_power_l2_positive", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -158,7 +157,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l3_positive", - name="Power consumption phase L3", + translation_key="instantaneous_active_power_l3_positive", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -166,7 +165,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l1_negative", - name="Power production phase L1", + translation_key="instantaneous_active_power_l1_negative", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -174,7 +173,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l2_negative", - name="Power production phase L2", + translation_key="instantaneous_active_power_l2_negative", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -182,7 +181,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l3_negative", - name="Power production phase L3", + translation_key="instantaneous_active_power_l3_negative", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -190,7 +189,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="short_power_failure_count", - name="Short power failure count", + translation_key="short_power_failure_count", obis_reference=obis_references.SHORT_POWER_FAILURE_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -199,7 +198,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="long_power_failure_count", - name="Long power failure count", + translation_key="long_power_failure_count", obis_reference=obis_references.LONG_POWER_FAILURE_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -208,7 +207,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_sag_l1_count", - name="Voltage sags phase L1", + translation_key="voltage_sag_l1_count", obis_reference=obis_references.VOLTAGE_SAG_L1_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -216,7 +215,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_sag_l2_count", - name="Voltage sags phase L2", + translation_key="voltage_sag_l2_count", obis_reference=obis_references.VOLTAGE_SAG_L2_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -224,7 +223,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_sag_l3_count", - name="Voltage sags phase L3", + translation_key="voltage_sag_l3_count", obis_reference=obis_references.VOLTAGE_SAG_L3_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -232,7 +231,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_swell_l1_count", - name="Voltage swells phase L1", + translation_key="voltage_swell_l1_count", obis_reference=obis_references.VOLTAGE_SWELL_L1_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -241,7 +240,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_swell_l2_count", - name="Voltage swells phase L2", + translation_key="voltage_swell_l2_count", obis_reference=obis_references.VOLTAGE_SWELL_L2_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -250,7 +249,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_swell_l3_count", - name="Voltage swells phase L3", + translation_key="voltage_swell_l3_count", obis_reference=obis_references.VOLTAGE_SWELL_L3_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -259,7 +258,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_voltage_l1", - name="Voltage phase L1", + translation_key="instantaneous_voltage_l1", obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L1, device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, @@ -268,7 +267,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_voltage_l2", - name="Voltage phase L2", + translation_key="instantaneous_voltage_l2", obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L2, device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, @@ -277,7 +276,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_voltage_l3", - name="Voltage phase L3", + translation_key="instantaneous_voltage_l3", obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L3, device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, @@ -286,7 +285,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_current_l1", - name="Current phase L1", + translation_key="instantaneous_current_l1", obis_reference=obis_references.INSTANTANEOUS_CURRENT_L1, device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, @@ -295,7 +294,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_current_l2", - name="Current phase L2", + translation_key="instantaneous_current_l2", obis_reference=obis_references.INSTANTANEOUS_CURRENT_L2, device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, @@ -304,7 +303,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_current_l3", - name="Current phase L3", + translation_key="instantaneous_current_l3", obis_reference=obis_references.INSTANTANEOUS_CURRENT_L3, device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, @@ -313,7 +312,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="belgium_max_power_per_phase", - name="Max power per phase", + translation_key="max_power_per_phase", obis_reference=obis_references.BELGIUM_MAX_POWER_PER_PHASE, dsmr_versions={"5B"}, device_class=SensorDeviceClass.POWER, @@ -323,7 +322,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="belgium_max_current_per_phase", - name="Max current per phase", + translation_key="max_current_per_phase", obis_reference=obis_references.BELGIUM_MAX_CURRENT_PER_PHASE, dsmr_versions={"5B"}, device_class=SensorDeviceClass.POWER, @@ -333,7 +332,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_imported_total", - name="Energy consumption (total)", + translation_key="electricity_imported_total", obis_reference=obis_references.ELECTRICITY_IMPORTED_TOTAL, dsmr_versions={"5L", "5S", "Q3D"}, force_update=True, @@ -342,7 +341,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_exported_total", - name="Energy production (total)", + translation_key="electricity_exported_total", obis_reference=obis_references.ELECTRICITY_EXPORTED_TOTAL, dsmr_versions={"5L", "5S", "Q3D"}, force_update=True, @@ -351,7 +350,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="hourly_gas_meter_reading", - name="Gas consumption", + translation_key="gas_meter_reading", obis_reference=obis_references.HOURLY_GAS_METER_READING, dsmr_versions={"4", "5", "5L"}, is_gas=True, @@ -361,7 +360,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="belgium_5min_gas_meter_reading", - name="Gas consumption", + translation_key="gas_meter_reading", obis_reference=obis_references.BELGIUM_5MIN_GAS_METER_READING, dsmr_versions={"5B"}, is_gas=True, @@ -371,7 +370,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="gas_meter_reading", - name="Gas consumption", + translation_key="gas_meter_reading", obis_reference=obis_references.GAS_METER_READING, dsmr_versions={"2.2"}, is_gas=True, diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 5db3a8bb86..5724ad643f 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -42,11 +42,105 @@ }, "entity": { "sensor": { - "electricity_tariff": { + "current_electricity_delivery": { + "name": "Power production" + }, + "current_electricity_usage": { + "name": "Power consumption" + }, + "electricity_active_tariff": { + "name": "Active tariff", "state": { "low": "Low", "normal": "Normal" } + }, + "electricity_delivered_tariff_1": { + "name": "Energy production (tarif 1)" + }, + "electricity_delivered_tariff_2": { + "name": "Energy production (tarif 2)" + }, + "electricity_exported_total": { + "name": "Energy production (total)" + }, + "electricity_imported_total": { + "name": "Energy consumption (total)" + }, + "electricity_used_tariff_1": { + "name": "Energy consumption (tarif 1)" + }, + "electricity_used_tariff_2": { + "name": "Energy consumption (tarif 2)" + }, + "gas_meter_reading": { + "name": "Gas consumption" + }, + "instantaneous_active_power_l1_negative": { + "name": "Power production phase L1" + }, + "instantaneous_active_power_l1_positive": { + "name": "Power consumption phase L1" + }, + "instantaneous_active_power_l2_negative": { + "name": "Power production phase L2" + }, + "instantaneous_active_power_l2_positive": { + "name": "Power consumption phase L2" + }, + "instantaneous_active_power_l3_negative": { + "name": "Power production phase L3" + }, + "instantaneous_active_power_l3_positive": { + "name": "Power consumption phase L3" + }, + "instantaneous_current_l1": { + "name": "Current phase L1" + }, + "instantaneous_current_l2": { + "name": "Current phase L2" + }, + "instantaneous_current_l3": { + "name": "Current phase L3" + }, + "instantaneous_voltage_l1": { + "name": "Voltage phase L1" + }, + "instantaneous_voltage_l2": { + "name": "Voltage phase L2" + }, + "instantaneous_voltage_l3": { + "name": "Voltage phase L3" + }, + "long_power_failure_count": { + "name": "Long power failure count" + }, + "max_current_per_phase": { + "name": "Max current per phase" + }, + "max_power_per_phase": { + "name": "Max power per phase" + }, + "short_power_failure_count": { + "name": "Short power failure count" + }, + "voltage_sag_l1_count": { + "name": "Voltage sags phase L1" + }, + "voltage_sag_l2_count": { + "name": "Voltage sags phase L2" + }, + "voltage_sag_l3_count": { + "name": "Voltage sags phase L3" + }, + "voltage_swell_l1_count": { + "name": "Voltage swells phase L1" + }, + "voltage_swell_l2_count": { + "name": "Voltage swells phase L2" + }, + "voltage_swell_l3_count": { + "name": "Voltage swells phase L3" } } }, diff --git a/homeassistant/components/dsmr_reader/config_flow.py b/homeassistant/components/dsmr_reader/config_flow.py index 2f08894d12..44ff666365 100644 --- a/homeassistant/components/dsmr_reader/config_flow.py +++ b/homeassistant/components/dsmr_reader/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Awaitable -import logging from typing import Any from homeassistant.core import HomeAssistant @@ -11,8 +10,6 @@ from homeassistant.helpers.config_entry_flow import DiscoveryFlowHandler from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - async def _async_has_devices(_: HomeAssistant) -> bool: """MQTT is set as dependency, so that should be sufficient.""" 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 72e24c5272..c618995ed4 100644 --- a/homeassistant/components/dsmr_reader/sensor.py +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -3,42 +3,14 @@ from __future__ import annotations from homeassistant.components import mqtt from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify -from .const import DOMAIN from .definitions import SENSORS, DSMRReaderSensorEntityDescription -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up DSMR Reader sensors via configuration.yaml and show deprecation warning.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( _: HomeAssistant, config_entry: ConfigEntry, @@ -51,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 17e28cca88..fce274e891 100644 --- a/homeassistant/components/dsmr_reader/strings.json +++ b/homeassistant/components/dsmr_reader/strings.json @@ -9,10 +9,255 @@ } } }, - "issues": { - "deprecated_yaml": { - "title": "The DSMR Reader configuration is being removed", - "description": "Configuring DSMR Reader using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the DSMR Reader YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + "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/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index 2ba826d37e..278c3c989d 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -7,7 +7,13 @@ from typing import Any import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + CALLBACK_TYPE, + HassJob, + HomeAssistant, + ServiceCall, + callback, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later @@ -110,7 +116,7 @@ def async_track_time_interval_backoff( """Add a listener that fires repetitively at every timedelta interval.""" if not isinstance(intervals, (list, tuple)): intervals = (intervals,) - remove = None + remove: CALLBACK_TYPE | None = None failed = 0 async def interval_listener(now: datetime) -> None: @@ -122,8 +128,11 @@ def async_track_time_interval_backoff( failed = 0 finally: delay = intervals[failed] if failed < len(intervals) else intervals[-1] - remove = async_call_later(hass, delay.total_seconds(), interval_listener) + remove = async_call_later( + hass, delay.total_seconds(), interval_listener_job + ) + interval_listener_job = HassJob(interval_listener, cancel_on_shutdown=True) hass.async_run_job(interval_listener, dt_util.utcnow()) def remove_listener() -> None: diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py index 1841291f7a..99c3a110ca 100644 --- a/homeassistant/components/dwd_weather_warnings/__init__.py +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -1 +1,33 @@ """The dwd_weather_warnings component.""" + +from __future__ import annotations + +from dwdwfsapi import DwdWeatherWarningsAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import CONF_REGION_IDENTIFIER, DOMAIN, PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + region_identifier: str = entry.data[CONF_REGION_IDENTIFIER] + + # Initialize the API. + api = await hass.async_add_executor_job(DwdWeatherWarningsAPI, region_identifier) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = api + + 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/dwd_weather_warnings/config_flow.py b/homeassistant/components/dwd_weather_warnings/config_flow.py new file mode 100644 index 0000000000..653812632c --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/config_flow.py @@ -0,0 +1,88 @@ +"""Config flow for the dwd_weather_warnings integration.""" + +from __future__ import annotations + +from typing import Any, Final + +from dwdwfsapi import DwdWeatherWarningsAPI +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_REGION_IDENTIFIER, + CONF_REGION_NAME, + DEFAULT_NAME, + DOMAIN, + LOGGER, +) + +CONFIG_SCHEMA: Final = vol.Schema( + { + vol.Required(CONF_REGION_IDENTIFIER): cv.string, + } +) + + +class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the config flow for the dwd_weather_warnings integration.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict = {} + + if user_input is not None: + region_identifier = user_input[CONF_REGION_IDENTIFIER] + + # Validate region identifier using the API + if not await self.hass.async_add_executor_job( + DwdWeatherWarningsAPI, region_identifier + ): + errors["base"] = "invalid_identifier" + + if not errors: + # Set the unique ID for this config entry. + await self.async_set_unique_id(region_identifier) + self._abort_if_unique_id_configured() + + # Set the name for this config entry. + name = f"{DEFAULT_NAME} {region_identifier}" + + return self.async_create_entry(title=name, data=user_input) + + return self.async_show_form( + step_id="user", errors=errors, data_schema=CONFIG_SCHEMA + ) + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + LOGGER.debug( + "Starting import of sensor from configuration.yaml - %s", import_config + ) + + # Adjust data to new format. + region_identifier = import_config.pop(CONF_REGION_NAME) + import_config[CONF_REGION_IDENTIFIER] = region_identifier + + # Set the unique ID for this imported entry. + await self.async_set_unique_id(import_config[CONF_REGION_IDENTIFIER]) + self._abort_if_unique_id_configured() + + # Validate region identifier using the API + if not await self.hass.async_add_executor_job( + DwdWeatherWarningsAPI, region_identifier + ): + return self.async_abort(reason="invalid_identifier") + + name = import_config.get( + CONF_NAME, f"{DEFAULT_NAME} {import_config[CONF_REGION_IDENTIFIER]}" + ) + + return self.async_create_entry(title=name, data=import_config) diff --git a/homeassistant/components/dwd_weather_warnings/const.py b/homeassistant/components/dwd_weather_warnings/const.py new file mode 100644 index 0000000000..75969dee11 --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/const.py @@ -0,0 +1,40 @@ +"""Constants for the dwd_weather_warnings integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +from homeassistant.const import Platform + +LOGGER = logging.getLogger(__package__) + +DOMAIN: Final = "dwd_weather_warnings" + +CONF_REGION_NAME: Final = "region_name" +CONF_REGION_IDENTIFIER: Final = "region_identifier" + +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) + +PLATFORMS: Final[list[Platform]] = [Platform.SENSOR] diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index a76b8eeee8..a383e33eab 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -1,9 +1,10 @@ { "domain": "dwd_weather_warnings", "name": "Deutscher Wetterdienst (DWD) Weather Warnings", - "codeowners": ["@runningman84", "@stephan192", "@Hummel95"], + "codeowners": ["@runningman84", "@stephan192", "@Hummel95", "@andarotajo"], + "config_flow": true, "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..de96b3e9e0 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -8,12 +8,11 @@ Unwetterwarnungen (Stufe 3) Warnungen vor markantem Wetter (Stufe 2) Wetterwarnungen (Stufe 1) """ + from __future__ import annotations -from datetime import timedelta -import logging +from typing import Final -from dwdwfsapi import DwdWeatherWarningsAPI import voluptuous as vol from homeassistant.components.sensor import ( @@ -21,40 +20,37 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import 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, async_create_issue 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_SCAN_INTERVAL, + DOMAIN, + LOGGER, +) SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -68,39 +64,59 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( icon="mdi:close-octagon-outline", ), ) -MONITORED_CONDITIONS: list[str] = [desc.key for desc in SENSOR_TYPES] +# Should be removed together with the old YAML configuration. +YAML_MONITORED_CONDITIONS: Final = [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_REGION_NAME): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Optional( - CONF_MONITORED_CONDITIONS, default=list(MONITORED_CONDITIONS) - ): vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), + CONF_MONITORED_CONDITIONS, default=YAML_MONITORED_CONDITIONS + ): vol.All(cv.ensure_list, [vol.In(YAML_MONITORED_CONDITIONS)]), } ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the DWD-Weather-Warnings sensor.""" - name = config.get(CONF_NAME) - region_name = config.get(CONF_REGION_NAME) + """Import the configurations from YAML to config flows.""" + # Show issue as long as the YAML configuration exists. + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) - api = WrappedDwDWWAPI(DwdWeatherWarningsAPI(region_name)) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) - sensors = [ - DwdWeatherWarningsSensor(api, name, description) - for description in SENSOR_TYPES - if description.key in config[CONF_MONITORED_CONDITIONS] - ] - add_entities(sensors, True) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up entities from config entry.""" + api = WrappedDwDWWAPI(hass.data[DOMAIN][entry.entry_id]) + + async_add_entities( + [ + DwdWeatherWarningsSensor(api, entry.title, entry.unique_id, description) + for description in SENSOR_TYPES + ], + True, + ) class DwdWeatherWarningsSensor(SensorEntity): @@ -112,23 +128,26 @@ class DwdWeatherWarningsSensor(SensorEntity): self, api, name, + unique_id, description: SensorEntityDescription, ) -> None: """Initialize a DWD-Weather-Warnings sensor.""" self._api = api self.entity_description = description self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{unique_id}-{description.key}" @property def native_value(self): - """Return the state of the device.""" + """Return the state of the sensor.""" if self.entity_description.key == CURRENT_WARNING_SENSOR: return self._api.api.current_warning_level + return self._api.api.expected_warning_level @property def extra_state_attributes(self): - """Return the state attributes of the DWD-Weather-Warnings.""" + """Return the state attributes of the sensor.""" data = { ATTR_REGION_NAME: self._api.api.warncell_name, ATTR_REGION_ID: self._api.api.warncell_id, @@ -169,7 +188,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 +204,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/dwd_weather_warnings/strings.json b/homeassistant/components/dwd_weather_warnings/strings.json new file mode 100644 index 0000000000..c5c954a9f8 --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "description": "To identify the desired region, the warncell ID / name is required.", + "data": { + "region_identifier": "Warncell ID or name" + } + } + }, + "error": { + "invalid_identifier": "The specified region identifier is invalid." + }, + "abort": { + "already_configured": "Warncell ID / name is already configured.", + "invalid_identifier": "[%key:component::dwd_weather_warnings::config::error::invalid_identifier%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Deutscher Wetterdienst (DWD) Weather Warnings YAML configuration is being removed", + "description": "Configuring Deutscher Wetterdienst (DWD) Weather Warnings using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Deutscher Wetterdienst (DWD) Weather Warnings YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index fe1872e1fe..77880fd74c 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -1,14 +1,11 @@ """Support for the Dynalite networks.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.cover import DEVICE_CLASSES_SCHEMA from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv @@ -17,36 +14,10 @@ from homeassistant.helpers.typing import ConfigType # Loading the config flow file will register the flow from .bridge import DynaliteBridge from .const import ( - ACTIVE_INIT, - ACTIVE_OFF, - ACTIVE_ON, ATTR_AREA, ATTR_CHANNEL, ATTR_HOST, - CONF_ACTIVE, - CONF_AREA, - CONF_AUTO_DISCOVER, CONF_BRIDGES, - CONF_CHANNEL, - CONF_CHANNEL_COVER, - CONF_CLOSE_PRESET, - CONF_DEVICE_CLASS, - CONF_DURATION, - CONF_FADE, - CONF_LEVEL, - CONF_NO_DEFAULT, - CONF_OPEN_PRESET, - CONF_POLL_TIMER, - CONF_PRESET, - CONF_ROOM_OFF, - CONF_ROOM_ON, - CONF_STOP_PRESET, - CONF_TEMPLATE, - CONF_TILT_TIME, - DEFAULT_CHANNEL_TYPE, - DEFAULT_NAME, - DEFAULT_PORT, - DEFAULT_TEMPLATES, DOMAIN, LOGGER, PLATFORMS, @@ -54,147 +25,29 @@ from .const import ( SERVICE_REQUEST_CHANNEL_LEVEL, ) from .convert_config import convert_config - - -def num_string(value: int | str) -> str: - """Test if value is a string of digits, aka an integer.""" - new_value = str(value) - if new_value.isdigit(): - return new_value - raise vol.Invalid("Not a string with numbers") - - -CHANNEL_DATA_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any( - "light", "switch" - ), - } -) - -CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA}) - -PRESET_DATA_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_LEVEL): vol.Coerce(float), - } -) - -PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)}) - -TEMPLATE_ROOM_SCHEMA = vol.Schema( - {vol.Optional(CONF_ROOM_ON): num_string, vol.Optional(CONF_ROOM_OFF): num_string} -) - -TEMPLATE_TIMECOVER_SCHEMA = vol.Schema( - { - vol.Optional(CONF_CHANNEL_COVER): num_string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPEN_PRESET): num_string, - vol.Optional(CONF_CLOSE_PRESET): num_string, - vol.Optional(CONF_STOP_PRESET): num_string, - vol.Optional(CONF_DURATION): vol.Coerce(float), - vol.Optional(CONF_TILT_TIME): vol.Coerce(float), - } -) - -TEMPLATE_DATA_SCHEMA = vol.Any(TEMPLATE_ROOM_SCHEMA, TEMPLATE_TIMECOVER_SCHEMA) - -TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA}) - - -def validate_area(config: dict[str, Any]) -> dict[str, Any]: - """Validate that template parameters are only used if area is using the relevant template.""" - conf_set = set() - for configs in DEFAULT_TEMPLATES.values(): - for conf in configs: - conf_set.add(conf) - if config.get(CONF_TEMPLATE): - for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]: - conf_set.remove(conf) - for conf in conf_set: - if config.get(conf): - raise vol.Invalid( - f"{conf} should not be part of area {config[CONF_NAME]} config" - ) - return config - - -AREA_DATA_SCHEMA = vol.Schema( - vol.All( - { - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_TEMPLATE): vol.In(DEFAULT_TEMPLATES), - vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_NO_DEFAULT): cv.boolean, - vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, - vol.Optional(CONF_PRESET): PRESET_SCHEMA, - # the next ones can be part of the templates - vol.Optional(CONF_ROOM_ON): num_string, - vol.Optional(CONF_ROOM_OFF): num_string, - vol.Optional(CONF_CHANNEL_COVER): num_string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPEN_PRESET): num_string, - vol.Optional(CONF_CLOSE_PRESET): num_string, - vol.Optional(CONF_STOP_PRESET): num_string, - vol.Optional(CONF_DURATION): vol.Coerce(float), - vol.Optional(CONF_TILT_TIME): vol.Coerce(float), - }, - validate_area, - ) -) - -AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)}) - -PLATFORM_DEFAULTS_SCHEMA = vol.Schema({vol.Optional(CONF_FADE): vol.Coerce(float)}) - - -BRIDGE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool), - vol.Optional(CONF_POLL_TIMER, default=1.0): vol.Coerce(float), - vol.Optional(CONF_AREA): AREA_SCHEMA, - vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA, - vol.Optional(CONF_ACTIVE, default=False): vol.Any( - ACTIVE_ON, ACTIVE_OFF, ACTIVE_INIT, cv.boolean - ), - vol.Optional(CONF_PRESET): PRESET_SCHEMA, - vol.Optional(CONF_TEMPLATE): TEMPLATE_SCHEMA, - } -) +from .panel import async_register_dynalite_frontend +from .schema import BRIDGE_SCHEMA CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])} - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])} + ), + }, + ), extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Dynalite platform.""" - conf = config.get(DOMAIN) + conf = config.get(DOMAIN, {}) LOGGER.debug("Setting up dynalite component config = %s", conf) - - if conf is None: - conf = {} - hass.data[DOMAIN] = {} - # User has configured bridges - if CONF_BRIDGES not in conf: - return True - - bridges = conf[CONF_BRIDGES] + bridges = conf.get(CONF_BRIDGES, []) for bridge_conf in bridges: host = bridge_conf[CONF_HOST] @@ -277,6 +130,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await async_register_dynalite_frontend(hass) + return True diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index d723825319..946d4ac653 100644 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -3,12 +3,16 @@ from __future__ import annotations from typing import Any +import voluptuous as vol + from homeassistant import config_entries -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .bridge import DynaliteBridge -from .const import DOMAIN, LOGGER +from .const import DEFAULT_PORT, DOMAIN, LOGGER from .convert_config import convert_config @@ -23,8 +27,20 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: """Import a new bridge as a config entry.""" - LOGGER.debug("Starting async_step_import - %s", import_info) + LOGGER.debug("Starting async_step_import (deprecated) - %s", import_info) + # Raise an issue that this is deprecated and has been imported + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + host = import_info[CONF_HOST] + # Check if host already exists for entry in self._async_current_entries(): if entry.data[CONF_HOST] == host: self.hass.config_entries.async_update_entry( @@ -33,9 +49,34 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_configured") # New entry - bridge = DynaliteBridge(self.hass, convert_config(import_info)) + return await self._try_create(import_info) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Step when user initializes a integration.""" + if user_input is not None: + return await self._try_create(user_input) + + schema = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } + ) + return self.async_show_form(step_id="user", data_schema=schema) + + async def _try_create(self, info: dict[str, Any]) -> FlowResult: + """Try to connect and if successful, create entry.""" + host = info[CONF_HOST] + configured_hosts = [ + entry.data[CONF_HOST] for entry in self._async_current_entries() + ] + if host in configured_hosts: + return self.async_abort(reason="already_configured") + bridge = DynaliteBridge(self.hass, convert_config(info)) if not await bridge.async_setup(): - LOGGER.error("Unable to setup bridge - import info=%s", import_info) - return self.async_abort(reason="no_connection") - LOGGER.debug("Creating entry for the bridge - %s", import_info) - return self.async_create_entry(title=host, data=import_info) + LOGGER.error("Unable to setup bridge - import info=%s", info) + return self.async_abort(reason="cannot_connect") + LOGGER.debug("Creating entry for the bridge - %s", info) + return self.async_create_entry(title=info[CONF_HOST], data=info) diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json index cded6a7e36..a3dd890cc1 100644 --- a/homeassistant/components/dynalite/manifest.json +++ b/homeassistant/components/dynalite/manifest.json @@ -1,10 +1,12 @@ { "domain": "dynalite", "name": "Philips Dynalite", + "after_dependencies": ["panel_custom"], "codeowners": ["@ziv1234"], "config_flow": true, + "dependencies": ["http", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/dynalite", "iot_class": "local_push", "loggers": ["dynalite_devices_lib"], - "requirements": ["dynalite_devices==0.1.47"] + "requirements": ["dynalite_devices==0.1.47", "dynalite_panel==0.0.4"] } diff --git a/homeassistant/components/dynalite/panel.py b/homeassistant/components/dynalite/panel.py new file mode 100644 index 0000000000..e7a0890033 --- /dev/null +++ b/homeassistant/components/dynalite/panel.py @@ -0,0 +1,117 @@ +"""Dynalite API interface for the frontend.""" + +from dynalite_panel import get_build_id, locate_dir +import voluptuous as vol + +from homeassistant.components import panel_custom, websocket_api +from homeassistant.components.cover import DEVICE_CLASSES +from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant, callback + +from .const import ( + CONF_ACTIVE, + CONF_AREA, + CONF_AUTO_DISCOVER, + CONF_PRESET, + CONF_TEMPLATE, + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, + LOGGER, +) +from .schema import BRIDGE_SCHEMA + +URL_BASE = "/dynalite_static" + +RELEVANT_CONFS = [ + CONF_NAME, + CONF_HOST, + CONF_PORT, + CONF_AUTO_DISCOVER, + CONF_AREA, + CONF_DEFAULT, + CONF_ACTIVE, + CONF_PRESET, + CONF_TEMPLATE, +] + + +@websocket_api.websocket_command( + { + vol.Required("type"): "dynalite/get-config", + } +) +@websocket_api.require_admin +@callback +def get_dynalite_config( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Retrieve the Dynalite config for the frontend.""" + entries = hass.config_entries.async_entries(DOMAIN) + relevant_config = { + entry.entry_id: { + conf: entry.data[conf] for conf in RELEVANT_CONFS if conf in entry.data + } + for entry in entries + } + dynalite_defaults = { + "DEFAULT_NAME": DEFAULT_NAME, + "DEVICE_CLASSES": DEVICE_CLASSES, + "DEFAULT_PORT": DEFAULT_PORT, + } + connection.send_result( + msg["id"], {"config": relevant_config, "default": dynalite_defaults} + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "dynalite/save-config", + vol.Required("entry_id"): str, + vol.Required("config"): BRIDGE_SCHEMA, + } +) +@websocket_api.require_admin +@callback +def save_dynalite_config( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Retrieve the Dynalite config for the frontend.""" + entry_id = msg["entry_id"] + entry = hass.config_entries.async_get_entry(entry_id) + if not entry: + LOGGER.error( + "Dynalite - received updated config for invalid entry - %s", entry_id + ) + connection.send_result(msg["id"], {"error": True}) + return + message_conf = msg["config"] + message_data = { + conf: message_conf[conf] for conf in RELEVANT_CONFS if conf in message_conf + } + LOGGER.info("Updating Dynalite config entry") + hass.config_entries.async_update_entry(entry, data=message_data) + connection.send_result(msg["id"], {}) + + +async def async_register_dynalite_frontend(hass: HomeAssistant): + """Register the Dynalite frontend configuration panel.""" + websocket_api.async_register_command(hass, get_dynalite_config) + websocket_api.async_register_command(hass, save_dynalite_config) + if DOMAIN not in hass.data.get("frontend_panels", {}): + path = locate_dir() + build_id = get_build_id() + hass.http.register_static_path( + URL_BASE, path, cache_headers=(build_id != "dev") + ) + + await panel_custom.async_register_panel( + hass=hass, + frontend_url_path=DOMAIN, + webcomponent_name="dynalite-panel", + sidebar_title=DOMAIN.capitalize(), + sidebar_icon="mdi:power", + module_url=f"{URL_BASE}/entrypoint-{build_id}.js", + embed_iframe=True, + require_admin=True, + ) diff --git a/homeassistant/components/dynalite/schema.py b/homeassistant/components/dynalite/schema.py new file mode 100644 index 0000000000..d470243782 --- /dev/null +++ b/homeassistant/components/dynalite/schema.py @@ -0,0 +1,155 @@ +"""Schema for config entries.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.cover import DEVICE_CLASSES_SCHEMA +from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE +from homeassistant.helpers import config_validation as cv + +from .const import ( + ACTIVE_INIT, + ACTIVE_OFF, + ACTIVE_ON, + CONF_ACTIVE, + CONF_AREA, + CONF_AUTO_DISCOVER, + CONF_CHANNEL, + CONF_CHANNEL_COVER, + CONF_CLOSE_PRESET, + CONF_DEVICE_CLASS, + CONF_DURATION, + CONF_FADE, + CONF_LEVEL, + CONF_NO_DEFAULT, + CONF_OPEN_PRESET, + CONF_POLL_TIMER, + CONF_PRESET, + CONF_ROOM_OFF, + CONF_ROOM_ON, + CONF_STOP_PRESET, + CONF_TEMPLATE, + CONF_TILT_TIME, + DEFAULT_CHANNEL_TYPE, + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_TEMPLATES, +) + + +def num_string(value: str | int) -> str: + """Test if value is a string of digits, aka an integer.""" + new_value = str(value) + if new_value.isdigit(): + return new_value + raise vol.Invalid("Not a string with numbers") + + +CHANNEL_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any( + "light", "switch" + ), + } +) + +CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA}) + +PRESET_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_LEVEL): vol.Coerce(float), + } +) + +PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)}) + +TEMPLATE_ROOM_SCHEMA = vol.Schema( + {vol.Optional(CONF_ROOM_ON): num_string, vol.Optional(CONF_ROOM_OFF): num_string} +) + +TEMPLATE_TIMECOVER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_CHANNEL_COVER): num_string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPEN_PRESET): num_string, + vol.Optional(CONF_CLOSE_PRESET): num_string, + vol.Optional(CONF_STOP_PRESET): num_string, + vol.Optional(CONF_DURATION): vol.Coerce(float), + vol.Optional(CONF_TILT_TIME): vol.Coerce(float), + } +) + +TEMPLATE_DATA_SCHEMA = vol.Any(TEMPLATE_ROOM_SCHEMA, TEMPLATE_TIMECOVER_SCHEMA) + +TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA}) + + +def validate_area(config: dict[str, Any]) -> dict[str, Any]: + """Validate that template parameters are only used if area is using the relevant template.""" + conf_set = set() + for configs in DEFAULT_TEMPLATES.values(): + for conf in configs: + conf_set.add(conf) + if config.get(CONF_TEMPLATE): + for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]: + conf_set.remove(conf) + for conf in conf_set: + if config.get(conf): + raise vol.Invalid( + f"{conf} should not be part of area {config[CONF_NAME]} config" + ) + return config + + +AREA_DATA_SCHEMA = vol.Schema( + vol.All( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_TEMPLATE): vol.In(DEFAULT_TEMPLATES), + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_NO_DEFAULT): cv.boolean, + vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, + vol.Optional(CONF_PRESET): PRESET_SCHEMA, + # the next ones can be part of the templates + vol.Optional(CONF_ROOM_ON): num_string, + vol.Optional(CONF_ROOM_OFF): num_string, + vol.Optional(CONF_CHANNEL_COVER): num_string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPEN_PRESET): num_string, + vol.Optional(CONF_CLOSE_PRESET): num_string, + vol.Optional(CONF_STOP_PRESET): num_string, + vol.Optional(CONF_DURATION): vol.Coerce(float), + vol.Optional(CONF_TILT_TIME): vol.Coerce(float), + }, + validate_area, + ) +) + +AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)}) + +PLATFORM_DEFAULTS_SCHEMA = vol.Schema({vol.Optional(CONF_FADE): vol.Coerce(float)}) + + +BRIDGE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool), + vol.Optional(CONF_POLL_TIMER, default=1.0): vol.Coerce(float), + vol.Optional(CONF_AREA): AREA_SCHEMA, + vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA, + vol.Optional(CONF_ACTIVE, default=False): vol.Any( + ACTIVE_ON, ACTIVE_OFF, ACTIVE_INIT, cv.boolean + ), + vol.Optional(CONF_PRESET): PRESET_SCHEMA, + vol.Optional(CONF_TEMPLATE): TEMPLATE_SCHEMA, + } +) diff --git a/homeassistant/components/dynalite/strings.json b/homeassistant/components/dynalite/strings.json new file mode 100644 index 0000000000..1d78108f90 --- /dev/null +++ b/homeassistant/components/dynalite/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "title": "Configure Dynalite Connection", + "description": "Gateway address to connect to DYNET network" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Dynalite YAML configuration is being removed", + "description": "Configuring Dynalite using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Dynalite YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 6b88dd84c8..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.1.2"] + "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/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 2c2225ff9a..7925832953 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -35,6 +35,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter +from . import EcobeeData from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER from .util import ecobee_date, ecobee_time @@ -61,11 +62,14 @@ PRESET_HOLD_INDEFINITE = "indefinite" AWAY_MODE = "awayMode" PRESET_HOME = "home" PRESET_SLEEP = "sleep" +HAS_HEAT_PUMP = "hasHeatPump" DEFAULT_MIN_HUMIDITY = 15 DEFAULT_MAX_HUMIDITY = 50 HUMIDIFIER_MANUAL_MODE = "manual" +ECOBEE_AUX_HEAT_ONLY = "auxHeatOnly" + # Order matters, because for reverse mapping we don't want to map HEAT to AUX ECOBEE_HVAC_TO_HASS = collections.OrderedDict( @@ -161,7 +165,6 @@ SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema( SUPPORT_FLAGS = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.AUX_HEAT | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE ) @@ -308,7 +311,9 @@ class Thermostat(ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - def __init__(self, data, thermostat_index, thermostat): + def __init__( + self, data: EcobeeData, thermostat_index: int, thermostat: dict + ) -> None: """Initialize the thermostat.""" self.data = data self.thermostat_index = thermostat_index @@ -318,12 +323,9 @@ class Thermostat(ClimateEntity): self._last_active_hvac_mode = HVACMode.HEAT_COOL self._operation_list = [] - if ( - self.thermostat["settings"]["heatStages"] - or self.thermostat["settings"]["hasHeatPump"] - ): + if self.settings["heatStages"] or self.settings["hasHeatPump"]: self._operation_list.append(HVACMode.HEAT) - if self.thermostat["settings"]["coolStages"]: + if self.settings["coolStages"]: self._operation_list.append(HVACMode.COOL) if len(self._operation_list) == 2: self._operation_list.insert(0, HVACMode.HEAT_COOL) @@ -355,9 +357,12 @@ class Thermostat(ClimateEntity): @property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" + supported = SUPPORT_FLAGS if self.has_humidifier_control: - return SUPPORT_FLAGS | ClimateEntityFeature.TARGET_HUMIDITY - return SUPPORT_FLAGS + supported = supported | ClimateEntityFeature.TARGET_HUMIDITY + if self.has_aux_heat: + supported = supported | ClimateEntityFeature.AUX_HEAT + return supported @property def name(self): @@ -411,13 +416,23 @@ class Thermostat(ClimateEntity): return PRECISION_HALVES @property - def has_humidifier_control(self): + def settings(self) -> dict[str, Any]: + """Return the settings of the thermostat.""" + return self.thermostat["settings"] + + @property + def has_humidifier_control(self) -> bool: """Return true if humidifier connected to thermostat and set to manual/on mode.""" return ( - self.thermostat["settings"]["hasHumidifier"] - and self.thermostat["settings"]["humidifierMode"] == HUMIDIFIER_MANUAL_MODE + bool(self.settings.get("hasHumidifier")) + and self.settings.get("humidifierMode") == HUMIDIFIER_MANUAL_MODE ) + @property + def has_aux_heat(self) -> bool: + """Return true if the ecobee has a heat pump.""" + return bool(self.settings.get(HAS_HEAT_PUMP)) + @property def target_humidity(self) -> int | None: """Return the desired humidity set point.""" @@ -489,7 +504,7 @@ class Thermostat(ClimateEntity): @property def hvac_mode(self): """Return current operation.""" - return ECOBEE_HVAC_TO_HASS[self.thermostat["settings"]["hvacMode"]] + return ECOBEE_HVAC_TO_HASS[self.settings["hvacMode"]] @property def hvac_modes(self): @@ -541,23 +556,25 @@ class Thermostat(ClimateEntity): self.thermostat["program"]["currentClimateRef"] ], "equipment_running": status, - "fan_min_on_time": self.thermostat["settings"]["fanMinOnTime"], + "fan_min_on_time": self.settings["fanMinOnTime"], } @property - def is_aux_heat(self): + def is_aux_heat(self) -> bool: """Return true if aux heater.""" - return "auxHeat" in self.thermostat["equipmentStatus"] + return self.settings["hvacMode"] == ECOBEE_AUX_HEAT_ONLY - async def async_turn_aux_heat_on(self) -> None: + def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" - if not self.is_aux_heat: - _LOGGER.warning("# Changing aux heat is not supported") + _LOGGER.debug("Setting HVAC mode to auxHeatOnly to turn on aux heat") + self.data.ecobee.set_hvac_mode(self.thermostat_index, ECOBEE_AUX_HEAT_ONLY) + self.update_without_throttle = True - async def async_turn_aux_heat_off(self) -> None: + def turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" - if self.is_aux_heat: - _LOGGER.warning("# Changing aux heat is not supported") + _LOGGER.debug("Setting HVAC mode to last mode to disable aux heat") + self.set_hvac_mode(self._last_active_hvac_mode) + self.update_without_throttle = True def set_preset_mode(self, preset_mode: str) -> None: """Activate a preset.""" @@ -680,7 +697,7 @@ class Thermostat(ClimateEntity): heat_temp = temp cool_temp = temp else: - delta = self.thermostat["settings"]["heatCoolMinDelta"] / 10.0 + delta = self.settings["heatCoolMinDelta"] / 10.0 heat_temp = temp - delta cool_temp = temp + delta self.set_auto_temp_hold(heat_temp, cool_temp) @@ -739,7 +756,7 @@ class Thermostat(ClimateEntity): # "useEndTime2hour", "useEndTime4hour" # "nextPeriod", "askMe" # "indefinite" - device_preference = self.thermostat["settings"]["holdAction"] + device_preference = self.settings["holdAction"] # Currently supported pyecobee holdTypes: # dateTime, nextTransition, indefinite, holdHours hold_pref_map = { @@ -755,7 +772,7 @@ class Thermostat(ClimateEntity): # "useEndTime2hour", "useEndTime4hour" # "nextPeriod", "askMe" # "indefinite" - device_preference = self.thermostat["settings"]["holdAction"] + device_preference = self.settings["holdAction"] hold_hours_map = { "useEndTime2hour": 2, "useEndTime4hour": 4, diff --git a/homeassistant/components/ecobee/config_flow.py b/homeassistant/components/ecobee/config_flow.py index cce39a9ce9..0bd7306e54 100644 --- a/homeassistant/components/ecobee/config_flow.py +++ b/homeassistant/components/ecobee/config_flow.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_KEY from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.json import load_json +from homeassistant.util.json import load_json_object from .const import _LOGGER, CONF_REFRESH_TOKEN, DATA_ECOBEE_CONFIG, DOMAIN @@ -85,7 +85,7 @@ class EcobeeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """ try: legacy_config = await self.hass.async_add_executor_job( - load_json, self.hass.config.path(ECOBEE_CONFIG_FILENAME) + load_json_object, self.hass.config.path(ECOBEE_CONFIG_FILENAME) ) config = { ECOBEE_API_KEY: legacy_config[ECOBEE_API_KEY], 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/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json index 546202fa9c..9f0f668ee8 100644 --- a/homeassistant/components/ecowitt/manifest.json +++ b/homeassistant/components/ecowitt/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/ecowitt", "iot_class": "local_push", - "requirements": ["aioecowitt==2023.01.0"] + "requirements": ["aioecowitt==2023.5.0"] } diff --git a/homeassistant/components/eddystone_temperature/manifest.json b/homeassistant/components/eddystone_temperature/manifest.json index 075e8beb78..dba5d35ab1 100644 --- a/homeassistant/components/eddystone_temperature/manifest.json +++ b/homeassistant/components/eddystone_temperature/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/eddystone_temperature", "iot_class": "local_polling", "loggers": ["beacontools"], - "requirements": ["beacontools[scan]==1.2.3", "construct==2.10.56"] + "requirements": ["beacontools[scan]==2.1.0", "construct==2.10.56"] } diff --git a/homeassistant/components/edl21/__init__.py b/homeassistant/components/edl21/__init__.py index f1cd598474..2ece8517db 100644 --- a/homeassistant/components/edl21/__init__.py +++ b/homeassistant/components/edl21/__init__.py @@ -1 +1,18 @@ """The edl21 component.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up EDL21 integration from a config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/edl21/config_flow.py b/homeassistant/components/edl21/config_flow.py new file mode 100644 index 0000000000..b66a988958 --- /dev/null +++ b/homeassistant/components/edl21/config_flow.py @@ -0,0 +1,50 @@ +"""Config flow for EDL21 integration.""" +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_SERIAL_PORT): str, + } +) + + +class EDL21ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """EDL21 config flow.""" + + VERSION = 1 + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + + self._async_abort_entries_match( + {CONF_SERIAL_PORT: import_config[CONF_SERIAL_PORT]} + ) + return self.async_create_entry( + title=import_config[CONF_NAME] or DEFAULT_TITLE, + data=import_config, + ) + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the user setup step.""" + if user_input is not None: + self._async_abort_entries_match( + {CONF_SERIAL_PORT: user_input[CONF_SERIAL_PORT]} + ) + + return self.async_create_entry( + title=DEFAULT_TITLE, + data=user_input, + ) + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) + return self.async_show_form(step_id="user", data_schema=data_schema) diff --git a/homeassistant/components/edl21/const.py b/homeassistant/components/edl21/const.py new file mode 100644 index 0000000000..2bde0ff379 --- /dev/null +++ b/homeassistant/components/edl21/const.py @@ -0,0 +1,14 @@ +"""Constants for the EDL21 component.""" +import logging + +LOGGER = logging.getLogger(__package__) + +DOMAIN = "edl21" + +CONF_SERIAL_PORT = "serial_port" + +SIGNAL_EDL21_TELEGRAM = "edl21_telegram" + +DEFAULT_TITLE = "Smart Meter" + +DEFAULT_DEVICE_NAME = "Smart Meter" diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index dc7e861ce8..5fdda46345 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -2,8 +2,10 @@ "domain": "edl21", "name": "EDL21", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/edl21", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["sml"], - "requirements": ["pysml==0.0.8"] + "requirements": ["pysml==0.0.11"] } diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 497f6867df..e526f951f1 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -1,8 +1,9 @@ """Support for EDL21 Smart Meters.""" from __future__ import annotations +from collections.abc import Mapping from datetime import timedelta -import logging +from typing import Any from sml import SmlGetListResponse from sml.asyncio import SmlProtocol @@ -15,6 +16,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_NAME, DEGREE, @@ -25,21 +27,26 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utcnow -_LOGGER = logging.getLogger(__name__) +from .const import ( + CONF_SERIAL_PORT, + DEFAULT_DEVICE_NAME, + DOMAIN, + LOGGER, + SIGNAL_EDL21_TELEGRAM, +) -DOMAIN = "edl21" -CONF_SERIAL_PORT = "serial_port" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -SIGNAL_EDL21_TELEGRAM = "edl21_telegram" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -53,6 +60,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # A=1: Electricity # C=0: General purpose objects # D=0: Free ID-numbers for utilities + # E=0 Ownership ID + SensorEntityDescription( + key="1-0:0.0.0*255", + name="Ownership ID", + icon="mdi:flash", + entity_registry_enabled_default=False, + ), + # E=9: Electrity ID SensorEntityDescription( key="1-0:0.0.9*255", name="Electricity ID", icon="mdi:flash" ), @@ -269,9 +284,33 @@ async def async_setup_platform( config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up EDL21 sensors via configuration.yaml and show deprecation warning.""" + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the EDL21 sensor.""" - hass.data[DOMAIN] = EDL21(hass, config, async_add_entities) + hass.data[DOMAIN] = EDL21(hass, config_entry.data, async_add_entities) await hass.data[DOMAIN].connect() @@ -295,16 +334,21 @@ class EDL21: def __init__( self, hass: HomeAssistant, - config: ConfigType, + config: Mapping[str, Any], async_add_entities: AddEntitiesCallback, ) -> None: """Initialize an EDL21 object.""" self._registered_obis: set[tuple[str, str]] = set() self._hass = hass self._async_add_entities = async_add_entities - self._name = config[CONF_NAME] + self._name = config.get(CONF_NAME) self._proto = SmlProtocol(config[CONF_SERIAL_PORT]) self._proto.add_listener(self.event, ["SmlGetListResponse"]) + LOGGER.debug( + "Initialized EDL21 for %s on %s", + config.get(CONF_NAME), + config[CONF_SERIAL_PORT], + ) async def connect(self) -> None: """Connect to an EDL21 reader.""" @@ -313,6 +357,7 @@ class EDL21: def event(self, message_body) -> None: """Handle events from pysml.""" assert isinstance(message_body, SmlGetListResponse) + LOGGER.debug("Received sml message for %s: %s", self._name, message_body) electricity_id = None for telegram in message_body.get("valList", []): @@ -321,6 +366,7 @@ class EDL21: break if electricity_id is None: + LOGGER.debug("No electricity id found in sml message for %s", self._name) return electricity_id = electricity_id.replace(" ", "") @@ -336,18 +382,21 @@ class EDL21: else: entity_description = SENSORS.get(obis) if entity_description and entity_description.name: - name = entity_description.name - if self._name: - name = f"{self._name}: {name}" - + # self._name is only used for backwards YAML compatibility + # This needs to be cleaned up when YAML support is removed + device_name = self._name or DEFAULT_DEVICE_NAME new_entities.append( EDL21Entity( - electricity_id, obis, name, entity_description, telegram + electricity_id, + obis, + device_name, + entity_description, + telegram, ) ) self._registered_obis.add((electricity_id, obis)) elif obis not in self._OBIS_BLACKLIST: - _LOGGER.warning( + LOGGER.warning( "Unhandled sensor %s detected. Please report at %s", obis, "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+edl21%22", @@ -355,54 +404,29 @@ class EDL21: self._OBIS_BLACKLIST.add(obis) if new_entities: - self._hass.loop.create_task(self.add_entities(new_entities)) - - async def add_entities(self, new_entities: list[EDL21Entity]) -> None: - """Migrate old unique IDs, then add entities to hass.""" - registry = er.async_get(self._hass) - - for entity in new_entities: - old_entity_id = registry.async_get_entity_id( - "sensor", DOMAIN, entity.old_unique_id - ) - if old_entity_id is not None: - _LOGGER.debug( - "Migrating unique_id from [%s] to [%s]", - entity.old_unique_id, - entity.unique_id, - ) - if registry.async_get_entity_id("sensor", DOMAIN, entity.unique_id): - registry.async_remove(old_entity_id) - else: - registry.async_update_entity( - old_entity_id, new_unique_id=entity.unique_id - ) - - self._async_add_entities(new_entities, update_before_add=True) + self._async_add_entities(new_entities, update_before_add=True) class EDL21Entity(SensorEntity): """Entity reading values from EDL21 telegram.""" _attr_should_poll = False + _attr_has_entity_name = True - def __init__(self, electricity_id, obis, name, entity_description, telegram): + def __init__(self, electricity_id, obis, device_name, entity_description, telegram): """Initialize an EDL21Entity.""" self._electricity_id = electricity_id self._obis = obis - self._name = name - self._unique_id = f"{electricity_id}_{obis}" 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}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._electricity_id)}, + name=device_name, + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -434,37 +458,13 @@ class EDL21Entity(SensorEntity): if self._async_remove_dispatcher: self._async_remove_dispatcher() - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def old_unique_id(self) -> str: - """Return a less unique ID as used in the first version of edl21.""" - return self._obis - - @property - def name(self) -> str | None: - """Return a name.""" - return self._name - @property def native_value(self) -> str: """Return the value of the last received telegram.""" return self._telegram.get("value") @property - def extra_state_attributes(self): - """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): + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" if (unit := self._telegram.get("unit")) is None or unit == 0: return None diff --git a/homeassistant/components/edl21/strings.json b/homeassistant/components/edl21/strings.json new file mode 100644 index 0000000000..284e8229c5 --- /dev/null +++ b/homeassistant/components/edl21/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "step": { + "user": { + "title": "Add your EDL21 smart meter", + "data": { + "serial_port": "[%key:common::config_flow::data::usb_path%]" + } + } + } + }, + "issues": { + "deprecated_yaml": { + "title": "EDL21 YAML configuration is being removed", + "description": "Configuring EDL21 using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the EDL21 YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 0fb58319b4..1f544a7a97 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import EfergyEntity @@ -104,9 +104,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Efergy sensors.""" api: Efergy = hass.data[DOMAIN][entry.entry_id] 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/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index 58648123dc..e546318a4d 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -15,7 +15,10 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform as ep +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import EightSleepBaseEntity, EightSleepConfigEntryData @@ -68,7 +71,7 @@ SERVICE_EIGHT_SCHEMA = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: ep.AddEntitiesCallback + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the eight sleep sensors.""" config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] @@ -95,7 +98,7 @@ async def async_setup_entry( async_add_entities(all_sensors) - platform = ep.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( SERVICE_HEAT_SET, SERVICE_EIGHT_SCHEMA, diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index 9f68074bb2..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(), @@ -94,5 +94,5 @@ class ElgatoButtonEntity(ElgatoEntity, ButtonEntity): await self.entity_description.press_fn(self.coordinator.client) except ElgatoError as error: raise HomeAssistantError( - "An error occurred while identifying the Elgato Light" + "An error occurred while communicating with the Elgato Light" ) from error 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/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index c193f4894b..c20621ce60 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -188,7 +188,9 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: ) hass.async_create_background_task(_async_discovery(), "elkm1 setup discovery") - async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL) + async_track_time_interval( + hass, _async_discovery, DISCOVERY_INTERVAL, cancel_on_shutdown=True + ) if DOMAIN not in hass_config: return True diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 26fab34f0e..d7094a2e60 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/elkm1", "iot_class": "local_push", "loggers": ["elkm1_lib"], - "requirements": ["elkm1-lib==2.2.1"] + "requirements": ["elkm1-lib==2.2.2"] } 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/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index d5e677abcc..dc7159001d 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -123,6 +123,6 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): return self._paired_attr(self.entity_description.key) @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, int]: """Return the device specific state attributes.""" return {"channel": self.channel_number} 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/types.py b/homeassistant/components/energy/types.py index 9a599cb9a5..819ed6ac5a 100644 --- a/homeassistant/components/energy/types.py +++ b/homeassistant/components/energy/types.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from typing import TypedDict +from typing import Protocol, TypedDict from homeassistant.core import HomeAssistant @@ -18,8 +18,8 @@ GetSolarForecastType = Callable[ ] -class EnergyPlatform: - """This class represents the methods we expect on the energy platforms.""" +class EnergyPlatform(Protocol): + """Represents the methods we expect on the energy platforms.""" @staticmethod async def async_get_solar_forecast( diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index a2c3ad094d..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.""" @@ -603,7 +603,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: functools.partial( recorder.statistics.get_metadata, hass, - statistic_ids=list(wanted_statistics_metadata), + statistic_ids=set(wanted_statistics_metadata), ) ) ) diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 2075d00003..7830d3649f 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -13,6 +13,7 @@ from typing import Any, cast import voluptuous as vol from homeassistant.components import recorder, websocket_api +from homeassistant.components.recorder.statistics import StatisticsRow from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.integration_platform import ( @@ -261,8 +262,8 @@ async def ws_get_fossil_energy_consumption( connection.send_error(msg["id"], "invalid_end_time", "Invalid end_time") return - statistic_ids = list(msg["energy_statistic_ids"]) - statistic_ids.append(msg["co2_statistic_id"]) + statistic_ids = set(msg["energy_statistic_ids"]) + statistic_ids.add(msg["co2_statistic_id"]) # Fetch energy + CO2 statistics statistics = await recorder.get_instance(hass).async_add_executor_job( @@ -277,7 +278,7 @@ async def ws_get_fossil_energy_consumption( ) def _combine_sum_statistics( - stats: dict[str, list[dict[str, Any]]], statistic_ids: list[str] + stats: dict[str, list[StatisticsRow]], statistic_ids: list[str] ) -> dict[float, float]: """Combine multiple statistics, returns a dict indexed by start time.""" result: defaultdict[float, float] = defaultdict(float) @@ -313,11 +314,10 @@ async def ws_get_fossil_energy_consumption( if not stat_list: return result prev_stat: dict[str, Any] = stat_list[0] + fake_stat = {"start": stat_list[-1]["start"] + period.total_seconds()} # Loop over the hourly deltas + a fake entry to end the period - for statistic in chain( - stat_list, ({"start": stat_list[-1]["start"] + period.total_seconds()},) - ): + for statistic in chain(stat_list, (fake_stat,)): if not same_period(prev_stat["start"], statistic["start"]): start, _ = period_start_end(prev_stat["start"]) # The previous statistic was the last entry of the period @@ -338,10 +338,13 @@ async def ws_get_fossil_energy_consumption( statistics, msg["energy_statistic_ids"] ) energy_deltas = _calculate_deltas(merged_energy_statistics) - indexed_co2_statistics = { - period["start"]: period["mean"] - for period in statistics.get(msg["co2_statistic_id"], {}) - } + indexed_co2_statistics = cast( + dict[float, float], + { + period["start"]: period["mean"] + for period in statistics.get(msg["co2_statistic_id"], {}) + }, + ) # Calculate amount of fossil based energy, assume 100% fossil if missing fossil_energy = [ 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/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py index e18542241d..e7f9464794 100644 --- a/homeassistant/components/enocean/binary_sensor.py +++ b/homeassistant/components/enocean/binary_sensor.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_ID, CONF_NAME @@ -37,9 +38,9 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Binary Sensor platform for EnOcean.""" - dev_id = config.get(CONF_ID) - dev_name = config.get(CONF_NAME) - device_class = config.get(CONF_DEVICE_CLASS) + dev_id: list[int] = config[CONF_ID] + dev_name: str = config[CONF_NAME] + device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) add_entities([EnOceanBinarySensor(dev_id, dev_name, device_class)]) @@ -52,7 +53,12 @@ class EnOceanBinarySensor(EnOceanEntity, BinarySensorEntity): - F6-02-02 (Light and Blind Control - Application Style 1) """ - def __init__(self, dev_id, dev_name, device_class): + def __init__( + self, + dev_id: list[int], + dev_name: str, + device_class: BinarySensorDeviceClass | None, + ) -> None: """Initialize the EnOcean binary sensor.""" super().__init__(dev_id, dev_name) self._device_class = device_class diff --git a/homeassistant/components/enocean/device.py b/homeassistant/components/enocean/device.py index 0bd084742b..1c98b4dd23 100644 --- a/homeassistant/components/enocean/device.py +++ b/homeassistant/components/enocean/device.py @@ -11,7 +11,7 @@ from .const import SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE class EnOceanEntity(Entity): """Parent class for all entities associated with the EnOcean component.""" - def __init__(self, dev_id, dev_name="EnOcean device"): + def __init__(self, dev_id: list[int], dev_name: str) -> None: """Initialize the device.""" self.dev_id = dev_id self.dev_name = dev_name diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py index 479723bd05..e2a194af8b 100644 --- a/homeassistant/components/enocean/light.py +++ b/homeassistant/components/enocean/light.py @@ -41,9 +41,9 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the EnOcean light platform.""" - sender_id = config.get(CONF_SENDER_ID) - dev_name = config.get(CONF_NAME) - dev_id = config.get(CONF_ID) + sender_id: list[int] = config[CONF_SENDER_ID] + dev_name: str = config[CONF_NAME] + dev_id: list[int] = config[CONF_ID] add_entities([EnOceanLight(sender_id, dev_id, dev_name)]) @@ -54,7 +54,7 @@ class EnOceanLight(EnOceanEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - def __init__(self, sender_id, dev_id, dev_name): + def __init__(self, sender_id: list[int], dev_id: list[int], dev_name: str) -> None: """Initialize the EnOcean light source.""" super().__init__(dev_id, dev_name) self._on_state = False diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index ae2110b317..4fa3b25ed0 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -117,16 +117,16 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up an EnOcean sensor device.""" - dev_id = config[CONF_ID] - dev_name = config[CONF_NAME] - sensor_type = config[CONF_DEVICE_CLASS] + dev_id: list[int] = config[CONF_ID] + dev_name: str = config[CONF_NAME] + sensor_type: str = config[CONF_DEVICE_CLASS] entities: list[EnOceanSensor] = [] if sensor_type == SENSOR_TYPE_TEMPERATURE: - temp_min = config[CONF_MIN_TEMP] - temp_max = config[CONF_MAX_TEMP] - range_from = config[CONF_RANGE_FROM] - range_to = config[CONF_RANGE_TO] + temp_min: int = config[CONF_MIN_TEMP] + temp_max: int = config[CONF_MAX_TEMP] + range_from: int = config[CONF_RANGE_FROM] + range_to: int = config[CONF_RANGE_TO] entities = [ EnOceanTemperatureSensor( dev_id, @@ -155,7 +155,10 @@ class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): """Representation of an EnOcean sensor device such as a power meter.""" def __init__( - self, dev_id, dev_name, description: EnOceanSensorEntityDescription + self, + dev_id: list[int], + dev_name: str, + description: EnOceanSensorEntityDescription, ) -> None: """Initialize the EnOcean sensor device.""" super().__init__(dev_id, dev_name) @@ -217,14 +220,14 @@ class EnOceanTemperatureSensor(EnOceanSensor): def __init__( self, - dev_id, - dev_name, + dev_id: list[int], + dev_name: str, description: EnOceanSensorEntityDescription, *, - scale_min, - scale_max, - range_from, - range_to, + scale_min: int, + scale_max: int, + range_from: int, + range_to: int, ) -> None: """Initialize the EnOcean temperature sensor device.""" super().__init__(dev_id, dev_name, description) diff --git a/homeassistant/components/enocean/switch.py b/homeassistant/components/enocean/switch.py index 28727bfb76..c69821c837 100644 --- a/homeassistant/components/enocean/switch.py +++ b/homeassistant/components/enocean/switch.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.const import CONF_ID, CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -38,7 +37,7 @@ def _migrate_to_new_unique_id(hass: HomeAssistant, dev_id, channel) -> None: """Migrate old unique ids to new unique ids.""" old_unique_id = f"{combine_hex(dev_id)}" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_id = ent_reg.async_get_entity_id(Platform.SWITCH, DOMAIN, old_unique_id) if entity_id is not None: @@ -66,9 +65,9 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the EnOcean switch platform.""" - channel = config.get(CONF_CHANNEL) - dev_id = config.get(CONF_ID) - dev_name = config.get(CONF_NAME) + channel: int = config[CONF_CHANNEL] + dev_id: list[int] = config[CONF_ID] + dev_name: str = config[CONF_NAME] _migrate_to_new_unique_id(hass, dev_id, channel) async_add_entities([EnOceanSwitch(dev_id, dev_name, channel)]) @@ -77,7 +76,7 @@ async def async_setup_platform( class EnOceanSwitch(EnOceanEntity, SwitchEntity): """Representation of an EnOcean switch device.""" - def __init__(self, dev_id, dev_name, channel): + def __init__(self, dev_id: list[int], dev_name: str, channel: int) -> None: """Initialize the EnOcean switch device.""" super().__init__(dev_id, dev_name) self._light = None 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/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index 3e8b7bbe39..e109c25d34 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime, timedelta +from random import randint from enturclient import EnturPublicTransportData import voluptuous as vol @@ -22,7 +23,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle import homeassistant.util.dt as dt_util -API_CLIENT_NAME = "homeassistant-homeassistant" +API_CLIENT_NAME = "homeassistant-{}" CONF_STOP_IDS = "stop_ids" CONF_EXPAND_PLATFORMS = "expand_platforms" @@ -105,7 +106,7 @@ async def async_setup_platform( quays = [s for s in stop_ids if "Quay" in s] data = EnturPublicTransportData( - API_CLIENT_NAME, + API_CLIENT_NAME.format(str(randint(100000, 999999))), stops=stops, quays=quays, line_whitelist=line_whitelist, @@ -182,7 +183,7 @@ class EnturPublicTransportSensor(SensorEntity): return self._state @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" self._attributes[ATTR_STOP_ID] = self._stop return self._attributes diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 5ea67d3a07..6262a28302 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.28"] + "requirements": ["env_canada==0.5.34"] } diff --git a/homeassistant/components/esera_onewire/__init__.py b/homeassistant/components/esera_onewire/__init__.py new file mode 100644 index 0000000000..1adcf6cf63 --- /dev/null +++ b/homeassistant/components/esera_onewire/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: ESERA 1-Wire.""" diff --git a/homeassistant/components/esera_onewire/manifest.json b/homeassistant/components/esera_onewire/manifest.json new file mode 100644 index 0000000000..8d5e944c5c --- /dev/null +++ b/homeassistant/components/esera_onewire/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "esera_onewire", + "name": "ESERA 1-Wire", + "integration_type": "virtual", + "supported_by": "onewire" +} diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 59db885d45..a68dd562af 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -5,12 +5,11 @@ from collections.abc import Callable import functools import logging import math -from typing import Any, Generic, NamedTuple, TypeVar, cast, overload +from typing import Any, Generic, NamedTuple, TypeVar, cast from aioesphomeapi import ( APIClient, APIConnectionError, - APIIntEnum, APIVersion, DeviceInfo as EsphomeDeviceInfo, EntityCategory as EsphomeEntityCategory, @@ -23,6 +22,7 @@ from aioesphomeapi import ( RequiresEncryptionAPIError, UserService, UserServiceArgType, + VoiceAssistantEventType, ) from awesomeversion import AwesomeVersion import voluptuous as vol @@ -64,13 +64,15 @@ from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import RuntimeEntryData +from .enum_mapper import EsphomeEnumMapper +from .voice_assistant import VoiceAssistantUDPServer CONF_DEVICE_NAME = "device_name" CONF_NOISE_PSK = "noise_psk" _LOGGER = logging.getLogger(__name__) _R = TypeVar("_R") -STABLE_BLE_VERSION_STR = "2022.12.4" +STABLE_BLE_VERSION_STR = "2023.4.0" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", @@ -284,6 +286,49 @@ async def async_setup_entry( # noqa: C901 _send_home_assistant_state(entity_id, attribute, hass.states.get(entity_id)) ) + voice_assistant_udp_server: VoiceAssistantUDPServer | None = None + + def _handle_pipeline_event( + event_type: VoiceAssistantEventType, data: dict[str, str] | None + ) -> None: + cli.send_voice_assistant_event(event_type, data) + + def _handle_pipeline_finished() -> None: + nonlocal voice_assistant_udp_server + + entry_data.async_set_assist_pipeline_state(False) + + if voice_assistant_udp_server is not None: + voice_assistant_udp_server.close() + voice_assistant_udp_server = None + + async def _handle_pipeline_start() -> int | None: + """Start a voice assistant pipeline.""" + nonlocal voice_assistant_udp_server + + if voice_assistant_udp_server is not None: + return None + + voice_assistant_udp_server = VoiceAssistantUDPServer( + hass, entry_data, _handle_pipeline_event, _handle_pipeline_finished + ) + port = await voice_assistant_udp_server.start_server() + + hass.async_create_background_task( + voice_assistant_udp_server.run_pipeline(), + "esphome.voice_assistant_udp_server.run_pipeline", + ) + entry_data.async_set_assist_pipeline_state(True) + + return port + + async def _handle_pipeline_stop() -> None: + """Stop a voice assistant pipeline.""" + nonlocal voice_assistant_udp_server + + if voice_assistant_udp_server is not None: + voice_assistant_udp_server.stop() + async def on_connect() -> None: """Subscribe to states and list entities on successful API login.""" nonlocal device_id @@ -328,6 +373,14 @@ async def async_setup_entry( # noqa: C901 await cli.subscribe_service_calls(async_on_service_call) await cli.subscribe_home_assistant_states(async_on_state_subscription) + if device_info.voice_assistant_version: + entry_data.disconnect_callbacks.append( + await cli.subscribe_voice_assistant( + _handle_pipeline_start, + _handle_pipeline_stop, + ) + ) + hass.async_create_task(entry_data.async_save_to_store()) except APIConnectionError as err: _LOGGER.warning("Error getting initial data for %s: %s", host, err) @@ -345,7 +398,19 @@ async def async_setup_entry( # noqa: C901 disconnect_cb() entry_data.disconnect_callbacks = [] entry_data.available = False - entry_data.async_update_device_state(hass) + # Mark state as stale so that we will always dispatch + # the next state update of that type when the device reconnects + entry_data.stale_state = { + (type(entity_state), key) + for state_dict in entry_data.state.values() + for key, entity_state in state_dict.items() + } + if not hass.is_stopping: + # Avoid marking every esphome entity as unavailable on shutdown + # since it generates a lot of state changed events and database + # writes when we already know we're shutting down and the state + # will be cleared anyway. + entry_data.async_update_device_state(hass) async def on_connect_error(err: Exception) -> None: """Start reauth flow if appropriate connect error type.""" @@ -678,41 +743,6 @@ def esphome_state_property( return _wrapper -_EnumT = TypeVar("_EnumT", bound=APIIntEnum) -_ValT = TypeVar("_ValT") - - -class EsphomeEnumMapper(Generic[_EnumT, _ValT]): - """Helper class to convert between hass and esphome enum values.""" - - def __init__(self, mapping: dict[_EnumT, _ValT]) -> None: - """Construct a EsphomeEnumMapper.""" - # Add none mapping - augmented_mapping: dict[ - _EnumT | None, _ValT | None - ] = mapping # type: ignore[assignment] - augmented_mapping[None] = None - - self._mapping = augmented_mapping - self._inverse: dict[_ValT, _EnumT] = {v: k for k, v in mapping.items()} - - @overload - def from_esphome(self, value: _EnumT) -> _ValT: - ... - - @overload - def from_esphome(self, value: _EnumT | None) -> _ValT | None: - ... - - def from_esphome(self, value: _EnumT | None) -> _ValT | None: - """Convert from an esphome int representation to a hass string.""" - return self._mapping[value] - - def from_hass(self, value: _ValT) -> _EnumT: - """Convert from a hass string to a esphome int representation.""" - return self._inverse[value] - - ICON_SCHEMA = vol.Schema(cv.icon) @@ -760,7 +790,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self.async_on_remove( async_dispatcher_connect( self.hass, - f"esphome_{self._entry_id}_on_device_update", + self._entry_data.signal_device_updated, self._on_device_update, ) ) @@ -874,3 +904,40 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): if not self._static_info.entity_category: return None return ENTITY_CATEGORIES.from_esphome(self._static_info.entity_category) + + +class EsphomeAssistEntity(Entity): + """Define a base entity for Assist Pipeline entities.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, entry_data: RuntimeEntryData) -> None: + """Initialize the binary sensor.""" + self._entry_data: RuntimeEntryData = entry_data + self._attr_unique_id = ( + f"{self._device_info.mac_address}-{self.entity_description.key}" + ) + + @property + def _device_info(self) -> EsphomeDeviceInfo: + assert self._entry_data.device_info is not None + return self._entry_data.device_info + + @property + def device_info(self) -> DeviceInfo: + """Return device registry information for this entity.""" + return DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)} + ) + + @callback + def _update(self) -> None: + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register update callback.""" + await super().async_added_to_hass() + self.async_on_remove( + self._entry_data.async_subscribe_assist_pipeline_update(self._update) + ) diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 1a930435e6..77ec780acb 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -6,13 +6,15 @@ from aioesphomeapi import BinarySensorInfo, BinarySensorState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from . import EsphomeEntity, platform_async_setup_entry +from . import EsphomeAssistEntity, EsphomeEntity, platform_async_setup_entry +from .domain_data import DomainData async def async_setup_entry( @@ -29,6 +31,11 @@ async def async_setup_entry( state_type=BinarySensorState, ) + entry_data = DomainData.get(hass).get_entry_data(entry) + assert entry_data.device_info is not None + if entry_data.device_info.voice_assistant_version: + async_add_entities([EsphomeAssistInProgressBinarySensor(entry_data)]) + class EsphomeBinarySensor( EsphomeEntity[BinarySensorInfo, BinarySensorState], BinarySensorEntity @@ -59,3 +66,17 @@ class EsphomeBinarySensor( if self._static_info.is_status_binary_sensor: return True return super().available + + +class EsphomeAssistInProgressBinarySensor(EsphomeAssistEntity, BinarySensorEntity): + """A binary sensor implementation for ESPHome for use with assist_pipeline.""" + + entity_description = BinarySensorEntityDescription( + key="assist_in_progress", + translation_key="assist_in_progress", + ) + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self._entry_data.assist_pipeline_state diff --git a/homeassistant/components/esphome/bluetooth/__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 7eb38edbf4..914021b467 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -43,6 +43,8 @@ CCCD_NOTIFY_BYTES = b"\x01\x00" CCCD_INDICATE_BYTES = b"\x02\x00" MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE = 3 +MIN_BLUETOOTH_PROXY_HAS_PAIRING = 4 +MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE = 5 DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) @@ -222,7 +224,7 @@ class ESPHomeClient(BaseBleakClient): def _async_call_bleak_disconnected_callback(self) -> None: """Call the disconnected callback to inform the bleak consumer.""" if self._disconnected_callback: - self._disconnected_callback(self) + self._disconnected_callback() self._disconnected_callback = None @api_error_as_bleak_error @@ -234,6 +236,7 @@ class ESPHomeClient(BaseBleakClient): Keyword Args: timeout (float): Timeout for required ``BleakScanner.find_device_by_address`` call. Defaults to 10.0. + Returns: Boolean representing connection status. """ @@ -320,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 @@ -385,13 +397,33 @@ class ESPHomeClient(BaseBleakClient): @api_error_as_bleak_error async def pair(self, *args: Any, **kwargs: Any) -> bool: """Attempt to pair.""" - raise NotImplementedError("Pairing is not available in ESPHome.") + if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_PAIRING: + raise NotImplementedError( + "Pairing is not available in ESPHome with version {self._connection_version}." + ) + response = await self._client.bluetooth_device_pair(self._address_as_int) + if response.paired: + return True + _LOGGER.error( + "Pairing with %s failed due to error: %s", self.address, response.error + ) + return False @verify_connected @api_error_as_bleak_error async def unpair(self) -> bool: """Attempt to unpair.""" - raise NotImplementedError("Pairing is not available in ESPHome.") + if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_PAIRING: + raise NotImplementedError( + "Unpairing is not available in ESPHome with version {self._connection_version}." + ) + response = await self._client.bluetooth_device_unpair(self._address_as_int) + if response.success: + return True + _LOGGER.error( + "Unpairing with %s failed due to error: %s", self.address, response.error + ) + return False @api_error_as_bleak_error async def get_services( @@ -477,18 +509,38 @@ class ESPHomeClient(BaseBleakClient): self, char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID ) -> BleakGATTCharacteristic: """Resolve a characteristic specifier to a BleakGATTCharacteristic object.""" + if (services := self.services) is None: + raise BleakError("Services have not been resolved") if not isinstance(char_specifier, BleakGATTCharacteristic): - characteristic = self.services.get_characteristic(char_specifier) + characteristic = services.get_characteristic(char_specifier) else: characteristic = char_specifier if not characteristic: 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 @@ -504,6 +556,8 @@ class ESPHomeClient(BaseBleakClient): The characteristic to read from, specified by either integer handle, UUID or directly by the BleakGATTCharacteristic object representing it. + **kwargs: Unused + Returns: (bytearray) The read data. """ @@ -519,6 +573,8 @@ class ESPHomeClient(BaseBleakClient): Args: handle (int): The handle of the descriptor to read from. + **kwargs: Unused + Returns: (bytearray) The read data. """ @@ -583,6 +639,7 @@ class ESPHomeClient(BaseBleakClient): def callback(sender: int, data: bytearray): print(f"{sender}: {data}") client.start_notify(char_uuid, callback) + Args: characteristic (BleakGATTCharacteristic): The characteristic to activate notifications/indications on a 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 058e557b1a..e40df234d5 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -54,12 +54,10 @@ 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" async def async_setup_entry( @@ -109,6 +107,7 @@ _FAN_MODES: EsphomeEnumMapper[ClimateFanMode, str] = EsphomeEnumMapper( ClimateFanMode.MIDDLE: FAN_MIDDLE, ClimateFanMode.FOCUS: FAN_FOCUS, ClimateFanMode.DIFFUSE: FAN_DIFFUSE, + ClimateFanMode.QUIET: FAN_QUIET, } ) _SWING_MODES: EsphomeEnumMapper[ClimateSwingMode, str] = EsphomeEnumMapper( @@ -142,8 +141,12 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti def precision(self) -> float: """Return the precision of the climate device.""" precicions = [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] + if self._static_info.visual_current_temperature_step != 0: + step = self._static_info.visual_current_temperature_step + else: + step = self._static_info.visual_target_temperature_step for prec in precicions: - if self._static_info.visual_temperature_step >= prec: + if step >= prec: return prec # Fall back to highest precision, tenths return PRECISION_TENTHS @@ -184,7 +187,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti def target_temperature_step(self) -> float: """Return the supported step of target temperature.""" # Round to one digit because of floating point math - return round(self._static_info.visual_temperature_step, 1) + return round(self._static_info.visual_target_temperature_step, 1) @property def min_temp(self) -> float: 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/dashboard.py b/homeassistant/components/esphome/dashboard.py index 7439f8946f..a8332f8d04 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -10,7 +10,8 @@ from awesomeversion import AwesomeVersion from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -31,13 +32,13 @@ async def async_set_dashboard_info( """Set the dashboard info.""" url = f"http://{host}:{port}" - # Do nothing if we already have this data. - if ( - (cur_dashboard := hass.data.get(KEY_DASHBOARD)) - and cur_dashboard.addon_slug == addon_slug - and cur_dashboard.url == url - ): - return + if cur_dashboard := async_get_dashboard(hass): + if cur_dashboard.addon_slug == addon_slug and cur_dashboard.url == url: + # Do nothing if we already have this data. + return + # Clear and make way for new dashboard + await cur_dashboard.async_shutdown() + del hass.data[KEY_DASHBOARD] dashboard = ESPHomeDashboard(hass, addon_slug, url, async_get_clientsession(hass)) try: @@ -48,6 +49,11 @@ async def async_set_dashboard_info( hass.data[KEY_DASHBOARD] = dashboard + async def on_hass_stop(_: Event) -> None: + await dashboard.async_shutdown() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) + reloads = [ hass.config_entries.async_reload(entry.entry_id) for entry in hass.config_entries.async_entries(DOMAIN) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 0aed6ce43a..7ce195d68f 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -25,6 +25,7 @@ from aioesphomeapi import ( NumberInfo, SelectInfo, SensorInfo, + SensorState, SwitchInfo, TextSensorInfo, UserService, @@ -39,6 +40,7 @@ from homeassistant.helpers.storage import Store from .dashboard import async_get_dashboard +_SENTINEL = object() SAVE_DELAY = 120 _LOGGER = logging.getLogger(__name__) @@ -69,6 +71,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 @@ -94,6 +100,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: @@ -130,10 +140,15 @@ class RuntimeEntryData: ) self.ble_connections_free = free self.ble_connections_limit = limit - if free: - for fut in self._ble_connection_free_futures: + if not free: + return + for fut in self._ble_connection_free_futures: + # If wait_for_ble_connections_free gets cancelled, it will + # leave a future in the list. We need to check if it's done + # before setting the result. + if not fut.done(): fut.set_result(free) - self._ble_connection_free_futures.clear() + self._ble_connection_free_futures.clear() async def wait_for_ble_connections_free(self) -> int: """Wait until there are free BLE connections.""" @@ -143,6 +158,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 @@ -170,6 +203,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): @@ -198,14 +235,37 @@ class RuntimeEntryData: @callback def async_update_state(self, state: EntityState) -> None: """Distribute an update of state information to the target.""" - subscription_key = (type(state), state.key) - self.state[type(state)][state.key] = state + 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) + subscription_key = (state_type, key) + if ( + current_state == state + and subscription_key not in stale_state + and not ( + type(state) is SensorState # pylint: disable=unidiomatic-typecheck + and (platform_info := self.info.get(Platform.SENSOR)) + and (entity_info := platform_info.get(state.key)) + and (cast(SensorInfo, entity_info)).force_update + ) + ): + _LOGGER.debug( + "%s: ignoring duplicate update with key %s: %s", + self.name, + key, + state, + ) + return _LOGGER.debug( "%s: dispatching update with key %s: %s", self.name, - subscription_key, + key, state, ) + stale_state.discard(subscription_key) + current_state_by_type[key] = state 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 dbb089acba..4905708046 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.3.1", "esphome-dashboard-api==1.2.3"], + "requirements": [ + "aioesphomeapi==13.7.5", + "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..81350c2c65 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -46,10 +46,25 @@ }, "flow_title": "{name}" }, + "entity": { + "binary_sensor": { + "assist_in_progress": { + "name": "[%key:component::assist_pipeline::entity::binary_sensor::assist_in_progress::name%]" + } + }, + "select": { + "pipeline": { + "name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + } + } + }, "issues": { "ble_firmware_outdated": { "title": "Update {name} with ESPHome {version} or later", - "description": "To improve Bluetooth reliability and performance, we highly recommend updating {name} with ESPHome {version} or later. When updating the device to ESPHome {version}, it is recommended to use a serial cable instead of an over-the-air update to take advantage of the new partition scheme." + "description": "To improve Bluetooth reliability and performance, we highly recommend updating {name} with ESPHome {version} or later. When updating the device from ESPHome earlier than 2022.12.0, it is recommended to use a serial cable instead of an over-the-air update to take advantage of the new partition scheme." }, "api_password_deprecated": { "title": "API Password deprecated on {name}", diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index a0d7b03133..618e31024b 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -13,7 +13,7 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo @@ -33,34 +33,36 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up ESPHome update based on a config entry.""" - dashboard = async_get_dashboard(hass) - - if dashboard is None: + if (dashboard := async_get_dashboard(hass)) is None: return - entry_data = DomainData.get(hass).get_entry_data(entry) - unsub = None + unsubs: list[CALLBACK_TYPE] = [] - async def setup_update_entity() -> None: + @callback + def _async_setup_update_entity() -> None: """Set up the update entity.""" - nonlocal unsub - + nonlocal unsubs + assert dashboard is not None # Keep listening until device is available - if not entry_data.available: + if not entry_data.available or not dashboard.last_update_success: return - if unsub is not None: - unsub() # type: ignore[unreachable] + for unsub in unsubs: + unsub() + unsubs.clear() - assert dashboard is not None async_add_entities([ESPHomeUpdateEntity(entry_data, dashboard)]) - if entry_data.available: - await setup_update_entity() + if entry_data.available and dashboard.last_update_success: + _async_setup_update_entity() return - signal = f"esphome_{entry_data.entry_id}_on_device_update" - unsub = async_dispatcher_connect(hass, signal, setup_update_entity) + unsubs = [ + async_dispatcher_connect( + hass, entry_data.signal_device_updated, _async_setup_update_entity + ), + dashboard.async_add_listener(_async_setup_update_entity), + ] class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): @@ -87,7 +89,11 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): # If the device has deep sleep, we can't assume we can install updates # as the ESP will not be connectable (by design). - if coordinator.supports_update and not self._device_info.has_deep_sleep: + if ( + coordinator.last_update_success + and coordinator.supports_update + and not self._device_info.has_deep_sleep + ): self._attr_supported_features = UpdateEntityFeature.INSTALL @property diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py new file mode 100644 index 0000000000..aaa2dc80a7 --- /dev/null +++ b/homeassistant/components/esphome/voice_assistant.py @@ -0,0 +1,253 @@ +"""ESPHome voice assistant support.""" +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterable, Callable +import logging +import socket +from typing import cast + +from aioesphomeapi import VoiceAssistantEventType +import async_timeout + +from homeassistant.components import stt, tts +from homeassistant.components.assist_pipeline import ( + PipelineEvent, + PipelineEventType, + async_pipeline_from_audio_stream, + select as pipeline_select, +) +from homeassistant.components.media_player import async_process_play_media_url +from homeassistant.core import Context, HomeAssistant, callback + +from .const import DOMAIN +from .entry_data import RuntimeEntryData +from .enum_mapper import EsphomeEnumMapper + +_LOGGER = logging.getLogger(__name__) + +UDP_PORT = 0 # Set to 0 to let the OS pick a free random port +UDP_MAX_PACKET_SIZE = 1024 + +_VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[ + VoiceAssistantEventType, PipelineEventType +] = EsphomeEnumMapper( + { + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: PipelineEventType.ERROR, + VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: PipelineEventType.RUN_START, + VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: PipelineEventType.RUN_END, + VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: PipelineEventType.STT_START, + VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: PipelineEventType.STT_END, + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START: PipelineEventType.INTENT_START, + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: PipelineEventType.INTENT_END, + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: PipelineEventType.TTS_START, + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: PipelineEventType.TTS_END, + } +) + + +class VoiceAssistantUDPServer(asyncio.DatagramProtocol): + """Receive UDP packets and forward them to the voice assistant.""" + + started = False + queue: asyncio.Queue[bytes] | None = None + transport: asyncio.DatagramTransport | None = None + remote_addr: tuple[str, int] | None = None + + def __init__( + self, + hass: HomeAssistant, + entry_data: RuntimeEntryData, + handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None], + handle_finished: Callable[[], None], + ) -> None: + """Initialize UDP receiver.""" + self.context = Context() + self.hass = hass + + assert entry_data.device_info is not None + self.device_info = entry_data.device_info + + self.queue = asyncio.Queue() + self.handle_event = handle_event + self.handle_finished = handle_finished + self._tts_done = asyncio.Event() + + async def start_server(self) -> int: + """Start accepting connections.""" + + def accept_connection() -> VoiceAssistantUDPServer: + """Accept connection.""" + if self.started: + raise RuntimeError("Can only start once") + if self.queue is None: + raise RuntimeError("No longer accepting connections") + + self.started = True + return self + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + + sock.bind(("", UDP_PORT)) + + await asyncio.get_running_loop().create_datagram_endpoint( + accept_connection, sock=sock + ) + + return cast(int, sock.getsockname()[1]) + + @callback + def connection_made(self, transport: asyncio.BaseTransport) -> None: + """Store transport for later use.""" + self.transport = cast(asyncio.DatagramTransport, transport) + + @callback + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + """Handle incoming UDP packet.""" + if not self.started: + return + if self.remote_addr is None: + self.remote_addr = addr + if self.queue is not None: + self.queue.put_nowait(data) + + def error_received(self, exc: Exception) -> None: + """Handle when a send or receive operation raises an OSError. + + (Other than BlockingIOError or InterruptedError.) + """ + _LOGGER.error("ESPHome Voice Assistant UDP server error received: %s", exc) + self.handle_finished() + + @callback + def stop(self) -> None: + """Stop the receiver.""" + if self.queue is not None: + self.queue.put_nowait(b"") + self.started = False + + def close(self) -> None: + """Close the receiver.""" + if self.queue is not None: + self.queue = None + if self.transport is not None: + self.transport.close() + + async def _iterate_packets(self) -> AsyncIterable[bytes]: + """Iterate over incoming packets.""" + if self.queue is None: + raise RuntimeError("Already stopped") + + while data := await self.queue.get(): + yield data + + def _event_callback(self, event: PipelineEvent) -> None: + """Handle pipeline events.""" + + try: + event_type = _VOICE_ASSISTANT_EVENT_TYPES.from_hass(event.type) + except KeyError: + _LOGGER.warning("Received unknown pipeline event type: %s", event.type) + return + + data_to_send = None + if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: + assert event.data is not None + data_to_send = {"text": event.data["stt_output"]["text"]} + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: + assert event.data is not None + data_to_send = {"text": event.data["tts_input"]} + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: + assert event.data is not None + path = event.data["tts_output"]["url"] + url = async_process_play_media_url(self.hass, path) + data_to_send = {"url": url} + + if self.device_info.voice_assistant_version >= 2: + media_id = event.data["tts_output"]["media_id"] + self.hass.async_create_background_task( + self._send_tts(media_id), "esphome_voice_assistant_tts" + ) + else: + self._tts_done.set() + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: + assert event.data is not None + data_to_send = { + "code": event.data["code"], + "message": event.data["message"], + } + self.handle_finished() + + self.handle_event(event_type, data_to_send) + + async def run_pipeline( + self, + pipeline_timeout: float = 30.0, + ) -> None: + """Run the Voice Assistant pipeline.""" + try: + tts_audio_output = ( + "raw" if self.device_info.voice_assistant_version >= 2 else "mp3" + ) + async with async_timeout.timeout(pipeline_timeout): + await async_pipeline_from_audio_stream( + self.hass, + context=self.context, + event_callback=self._event_callback, + stt_metadata=stt.SpeechMetadata( + language="", # set in async_pipeline_from_audio_stream + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=self._iterate_packets(), + pipeline_id=pipeline_select.get_chosen_pipeline( + self.hass, DOMAIN, self.device_info.mac_address + ), + tts_audio_output=tts_audio_output, + ) + + # Block until TTS is done sending + await self._tts_done.wait() + + _LOGGER.debug("Pipeline finished") + except asyncio.TimeoutError: + _LOGGER.warning("Pipeline timeout") + finally: + self.handle_finished() + + async def _send_tts(self, media_id: str) -> None: + """Send TTS audio to device via UDP.""" + try: + if self.transport is None: + return + + _extension, audio_bytes = await tts.async_get_media_source_audio( + self.hass, + media_id, + ) + + _LOGGER.debug("Sending %d bytes of audio", len(audio_bytes)) + + bytes_per_sample = stt.AudioBitRates.BITRATE_16 // 8 + sample_offset = 0 + samples_left = len(audio_bytes) // bytes_per_sample + + while samples_left > 0: + bytes_offset = sample_offset * bytes_per_sample + chunk: bytes = audio_bytes[bytes_offset : bytes_offset + 1024] + samples_in_chunk = len(chunk) // bytes_per_sample + samples_left -= samples_in_chunk + + self.transport.sendto(chunk, self.remote_addr) + await asyncio.sleep( + samples_in_chunk / stt.AudioSampleRates.SAMPLERATE_16000 * 0.99 + ) + + sample_offset += samples_in_chunk + + finally: + self._tts_done.set() diff --git a/homeassistant/components/eufy/manifest.json b/homeassistant/components/eufy/manifest.json index 5232fadc42..ccf15144f9 100644 --- a/homeassistant/components/eufy/manifest.json +++ b/homeassistant/components/eufy/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/eufy", "iot_class": "local_polling", "loggers": ["lakeside"], - "requirements": ["lakeside==0.12"] + "requirements": ["lakeside==0.13"] } diff --git a/homeassistant/components/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 65b5df100d..0456e7ade9 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -17,10 +17,10 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - config_validation as cv, - discovery_flow, - entity_platform, +from homeassistant.helpers import config_validation as cv, discovery_flow +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, ) from .const import ( @@ -34,7 +34,6 @@ from .const import ( DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_RTSP_PORT, DIR_DOWN, DIR_LEFT, DIR_RIGHT, @@ -53,9 +52,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up EZVIZ cameras based on a config entry.""" @@ -72,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, ) @@ -125,14 +115,14 @@ async def async_setup_entry( camera_username, camera_password, camera_rtsp_stream, - local_rtsp_port, + value["local_rtsp_port"], ffmpeg_arguments, ) ) async_add_entities(camera_entities) - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( SERVICE_PTZ, 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/fan/device_condition.py b/homeassistant/components/fan/device_condition.py index 7e27ea29f9..d4bd5f2e41 100644 --- a/homeassistant/components/fan/device_condition.py +++ b/homeassistant/components/fan/device_condition.py @@ -14,7 +14,11 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -34,11 +38,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Fan devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index fdd95a822d..b16d6da6df 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -16,10 +16,41 @@ "turn_off": "Turn off {entity_name}" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "name": "[%key:component::fan::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "direction": { + "name": "Direction", + "state": { + "forward": "Forward", + "reverse": "Reverse" + } + }, + "oscillating": { + "name": "Oscillating", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "percentage": { + "name": "Speed" + }, + "percentage_step": { + "name": "Speed step" + }, + "preset_modes": { + "name": "Available preset modes" + }, + "preset_mode": { + "name": "Preset mode" + } + } } } } diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 50404bb96d..6be0e3c219 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -93,7 +93,12 @@ class FeedManager: def _init_regular_updates(self, hass: HomeAssistant) -> None: """Schedule regular updates at the top of the clock.""" - track_time_interval(hass, lambda now: self._update(), self._scan_interval) + track_time_interval( + hass, + lambda now: self._update(), + self._scan_interval, + cancel_on_shutdown=True, + ) @property def last_update_successful(self) -> bool: 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/cover.py b/homeassistant/components/fibaro/cover.py index e19c5c32e8..c73c45d254 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -94,9 +94,9 @@ class FibaroCover(FibaroDevice, CoverEntity): """Return if the cover is closed.""" if self._is_open_close_only(): state = self.fibaro_device.state - if not state.has_value or state.str_value.lower() == "unknown": + if not state.has_value or state.str_value().lower() == "unknown": return None - return state.str_value.lower() == "closed" + return state.str_value().lower() == "closed" if self.current_cover_position is None: return None diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index 6522d3b06e..4b3721eed1 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.8"] + "requirements": ["pyfibaro==0.7.1"] } diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 4a0b4c11ca..3238fe9110 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -2,7 +2,7 @@ from __future__ import annotations import os -from typing import TextIO +from typing import Any, TextIO import voluptuous as vol @@ -48,7 +48,7 @@ class FileNotificationService(BaseNotificationService): self.filename = filename self.add_timestamp = add_timestamp - def send_message(self, message="", **kwargs) -> None: + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a file.""" file: TextIO if not self.hass.config.config_dir: 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..9b1e2250a2 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -80,9 +80,7 @@ DEFAULT_FILTER_TIME_CONSTANT = 10 NAME_TEMPLATE = "{} filter" ICON = "mdi:chart-line-variant" -FILTER_SCHEMA = vol.Schema( - {vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int)} -) +FILTER_SCHEMA = vol.Schema({vol.Optional(CONF_FILTER_PRECISION): vol.Coerce(int)}) FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend( { @@ -238,11 +236,18 @@ class SensorFilter(SensorEntity): self.async_write_ha_state() return - if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): - self._state = new_state.state + if new_state.state == STATE_UNKNOWN: + self._state = None self.async_write_ha_state() return + if new_state.state == STATE_UNAVAILABLE: + self._attr_available = False + self.async_write_ha_state() + return + + self._attr_available = True + temp_state = _State(new_state.last_updated, new_state.state) try: @@ -383,9 +388,9 @@ class FilterState: except ValueError: self.state = state.state - def set_precision(self, precision: int) -> None: + def set_precision(self, precision: int | None) -> None: """Set precision of Number based states.""" - if isinstance(self.state, Number): + if precision is not None and isinstance(self.state, Number): value = round(float(self.state), precision) self.state = int(value) if precision == 0 else value @@ -417,8 +422,8 @@ class Filter: self, name: str, window_size: int | timedelta, - precision: int, entity: str, + precision: int | None, ) -> None: """Initialize common attributes. @@ -467,6 +472,7 @@ class Filter: filtered = self._filter_state(fstate) filtered.set_precision(self.filter_precision) + if self._store_raw: self.states.append(copy(FilterState(new_state))) else: @@ -485,8 +491,9 @@ class RangeFilter(Filter, SensorEntity): def __init__( self, + *, entity: str, - precision: int, + precision: int | None = None, lower_bound: float | None = None, upper_bound: float | None = None, ) -> None: @@ -495,7 +502,9 @@ class RangeFilter(Filter, SensorEntity): :param upper_bound: band upper bound :param lower_bound: band lower bound """ - super().__init__(FILTER_NAME_RANGE, DEFAULT_WINDOW_SIZE, precision, entity) + super().__init__( + FILTER_NAME_RANGE, DEFAULT_WINDOW_SIZE, precision=precision, entity=entity + ) self._lower_bound = lower_bound self._upper_bound = upper_bound self._stats_internal: Counter = Counter() @@ -539,13 +548,20 @@ class OutlierFilter(Filter, SensorEntity): """ def __init__( - self, window_size: int, precision: int, entity: str, radius: float + self, + *, + window_size: int, + entity: str, + radius: float, + precision: int | None = None, ) -> None: """Initialize Filter. :param radius: band radius """ - super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity) + super().__init__( + FILTER_NAME_OUTLIER, window_size, precision=precision, entity=entity + ) self._radius = radius self._stats_internal: Counter = Counter() self._store_raw = True @@ -579,10 +595,17 @@ class LowPassFilter(Filter, SensorEntity): """BASIC Low Pass Filter.""" def __init__( - self, window_size: int, precision: int, entity: str, time_constant: int + self, + *, + window_size: int, + entity: str, + time_constant: int, + precision: int = DEFAULT_PRECISION, ) -> None: """Initialize Filter.""" - super().__init__(FILTER_NAME_LOWPASS, window_size, precision, entity) + super().__init__( + FILTER_NAME_LOWPASS, window_size, precision=precision, entity=entity + ) self._time_constant = time_constant def _filter_state(self, new_state: FilterState) -> FilterState: @@ -610,16 +633,19 @@ class TimeSMAFilter(Filter, SensorEntity): def __init__( self, + *, window_size: timedelta, - precision: int, entity: str, type: str, # pylint: disable=redefined-builtin + precision: int = DEFAULT_PRECISION, ) -> None: """Initialize Filter. :param type: type of algorithm used to connect discrete values """ - super().__init__(FILTER_NAME_TIME_SMA, window_size, precision, entity) + super().__init__( + FILTER_NAME_TIME_SMA, window_size, precision=precision, entity=entity + ) self._time_window = window_size self.last_leak: FilterState | None = None self.queue = deque[FilterState]() @@ -660,9 +686,13 @@ class ThrottleFilter(Filter, SensorEntity): One sample per window. """ - def __init__(self, window_size: int, precision: int, entity: str) -> None: + def __init__( + self, *, window_size: int, entity: str, precision: None = None + ) -> None: """Initialize Filter.""" - super().__init__(FILTER_NAME_THROTTLE, window_size, precision, entity) + super().__init__( + FILTER_NAME_THROTTLE, window_size, precision=precision, entity=entity + ) self._only_numbers = False def _filter_state(self, new_state: FilterState) -> FilterState: @@ -683,9 +713,13 @@ class TimeThrottleFilter(Filter, SensorEntity): One sample per time period. """ - def __init__(self, window_size: timedelta, precision: int, entity: str) -> None: + def __init__( + self, *, window_size: timedelta, entity: str, precision: int | None = None + ) -> None: """Initialize Filter.""" - super().__init__(FILTER_NAME_TIME_THROTTLE, window_size, precision, entity) + super().__init__( + FILTER_NAME_TIME_THROTTLE, window_size, precision=precision, entity=entity + ) self._time_window = window_size self._last_emitted_at: datetime | None = None self._only_numbers = False diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 6ef0467f7b..479e59d9cd 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -272,7 +272,7 @@ class FinTsHoldingsAccount(SensorEntity): self._attr_native_value = sum(h.total_value for h in self._holdings) @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Additional attributes of the sensor. Lists each holding of the account with the current value. diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index 8a80ac610f..d746e63ca5 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -220,7 +220,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Weight", unit_type="weight", icon="mdi:human", - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.WEIGHT, ), FitbitSensorEntityDescription( diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index d703699a43..c53c01c84a 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -27,7 +27,7 @@ from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.json import save_json from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util.json import load_json +from homeassistant.util.json import load_json_object from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( @@ -85,7 +85,7 @@ def request_app_setup( """Handle configuration updates.""" config_path = hass.config.path(FITBIT_CONFIG_FILE) if os.path.isfile(config_path): - config_file = load_json(config_path) + config_file = load_json_object(config_path) if config_file == DEFAULT_CONFIG: error_msg = ( f"You didn't correctly modify {FITBIT_CONFIG_FILE}, please try" @@ -161,7 +161,7 @@ def setup_platform( """Set up the Fitbit sensor.""" config_path = hass.config.path(FITBIT_CONFIG_FILE) if os.path.isfile(config_path): - config_file: ConfigType = cast(ConfigType, load_json(config_path)) + config_file = load_json_object(config_path) if config_file == DEFAULT_CONFIG: request_app_setup( hass, config, add_entities, config_path, discovery_info=None @@ -175,13 +175,10 @@ def setup_platform( if "fitbit" in _CONFIGURING: configurator.request_done(hass, _CONFIGURING.pop("fitbit")) - access_token: str | None = config_file.get(ATTR_ACCESS_TOKEN) - refresh_token: str | None = config_file.get(ATTR_REFRESH_TOKEN) - expires_at: int | None = config_file.get(ATTR_LAST_SAVED_AT) if ( - access_token is not None - and refresh_token is not None - and expires_at is not None + (access_token := config_file.get(ATTR_ACCESS_TOKEN)) is not None + and (refresh_token := config_file.get(ATTR_REFRESH_TOKEN)) is not None + and (expires_at := config_file.get(ATTR_LAST_SAVED_AT)) is not None ): authd_client = Fitbit( config_file.get(CONF_CLIENT_ID), @@ -192,7 +189,7 @@ def setup_platform( refresh_cb=lambda x: None, ) - if int(time.time()) - expires_at > 3600: + if int(time.time()) - cast(int, expires_at) > 3600: authd_client.client.refresh_token() user_profile = authd_client.user_profile_get()["user"] diff --git a/homeassistant/components/fivem/sensor.py b/homeassistant/components/fivem/sensor.py index 31e23565a6..9afe589016 100644 --- a/homeassistant/components/fivem/sensor.py +++ b/homeassistant/components/fivem/sensor.py @@ -1,11 +1,11 @@ """The FiveM sensor platform.""" from dataclasses import dataclass -from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import FiveMEntity, FiveMEntityDescription from .const import ( @@ -73,6 +73,6 @@ class FiveMSensorEntity(FiveMEntity, SensorEntity): entity_description: FiveMSensorEntityDescription @property - def native_value(self) -> Any: + def native_value(self) -> StateType: """Return the state of the sensor.""" return self.coordinator.data[self.entity_description.key] 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/flipr/manifest.json b/homeassistant/components/flipr/manifest.json index e7b9c8bf81..73a0b3edb2 100644 --- a/homeassistant/components/flipr/manifest.json +++ b/homeassistant/components/flipr/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/flipr", "iot_class": "cloud_polling", "loggers": ["flipr_api"], - "requirements": ["flipr-api==1.4.4"] + "requirements": ["flipr-api==1.5.0"] } 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/switch.py b/homeassistant/components/flux/switch.py index d8a5819259..f71ccc87f0 100644 --- a/homeassistant/components/flux/switch.py +++ b/homeassistant/components/flux/switch.py @@ -226,6 +226,12 @@ class FluxSwitch(SwitchEntity, RestoreEntity): if last_state and last_state.state == STATE_ON: await self.async_turn_on() + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self.unsub_tracker: + self.unsub_tracker() + return await super().async_will_remove_from_hass() + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on flux.""" if self.is_on: diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 7d7ef2d42b..94f50caa1a 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -87,14 +87,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, STARTUP_SCAN_TIMEOUT ) + @callback + def _async_start_background_discovery(*_: Any) -> None: + """Run discovery in the background.""" + hass.async_create_background_task(_async_discovery(), "flux_led-discovery") + async def _async_discovery(*_: Any) -> None: async_trigger_discovery( hass, await async_discover_devices(hass, DISCOVER_SCAN_TIMEOUT) ) async_trigger_discovery(hass, domain_data[FLUX_LED_DISCOVERY]) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_discovery) - async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, _async_start_background_discovery + ) + async_track_time_interval( + hass, + _async_start_background_discovery, + DISCOVERY_INTERVAL, + cancel_on_shutdown=True, + ) return True @@ -200,7 +212,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await device.async_set_time() await _async_sync_time() # set at startup - entry.async_on_unload(async_track_time_change(hass, _async_sync_time, 2, 40, 30)) + entry.async_on_unload(async_track_time_change(hass, _async_sync_time, 3, 40, 30)) # There must not be any awaits between here and the return # to avoid a race condition where the add_update_listener is not 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/flux_led/sensor.py b/homeassistant/components/flux_led/sensor.py index 664d18f385..3cff6d017f 100644 --- a/homeassistant/components/flux_led/sensor.py +++ b/homeassistant/components/flux_led/sensor.py @@ -36,7 +36,7 @@ class FluxPairedRemotes(FluxEntity, SensorEntity): """Representation of a Magic Home paired remotes sensor.""" _attr_icon = "mdi:remote" - _attr_entity_category = EntityCategory.CONFIG + _attr_entity_category = EntityCategory.DIAGNOSTIC @property def native_value(self) -> int: diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index 31a199ab88..96decd0b8c 100644 --- a/homeassistant/components/folder_watcher/manifest.json +++ b/homeassistant/components/folder_watcher/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["watchdog"], "quality_scale": "internal", - "requirements": ["watchdog==2.2.1"] + "requirements": ["watchdog==2.3.1"] } 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 d42c72b65d..e1f1ece055 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -1,4 +1,4 @@ -"""This library brings support for forked_daapd to Home Assistant.""" +"""Support forked_daapd media player.""" from __future__ import annotations import asyncio @@ -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/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py index 65f63829c0..95a418ae40 100644 --- a/homeassistant/components/fortios/device_tracker.py +++ b/homeassistant/components/fortios/device_tracker.py @@ -67,7 +67,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> FortiOSDeviceScanner class FortiOSDeviceScanner(DeviceScanner): - """This class queries a FortiOS unit for connected devices.""" + """Class which queries a FortiOS unit for connected devices.""" def __init__(self, fgt) -> None: """Initialize the scanner.""" diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index fe11b05688..ae28fd8d11 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -1,4 +1,4 @@ -"""This component provides basic support for Foscam IP cameras.""" +"""Component providing basic support for Foscam IP cameras.""" from __future__ import annotations import asyncio 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 fd9252aaa1..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 @@ -77,7 +77,6 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Check permissions await fbx.system.get_config() await fbx.lan.get_hosts_list() - await self.hass.async_block_till_done() # Close connection await fbx.close() @@ -103,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..f10a9e047f --- /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._device_name = node["label"].strip() + self._attr_name = self._device_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 35760e7cbf..ad7da1703b 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -3,9 +3,10 @@ "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"], - "requirements": ["freebox-api==1.0.1"], + "requirements": ["freebox-api==1.1.0"], "zeroconf": ["_fbx-api._tcp.local."] } 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/freedns/__init__.py b/homeassistant/components/freedns/__init__.py index a5c507c385..e6ac11889b 100644 --- a/homeassistant/components/freedns/__init__.py +++ b/homeassistant/components/freedns/__init__.py @@ -1,6 +1,6 @@ """Integrate with FreeDNS Dynamic DNS service at freedns.afraid.org.""" import asyncio -from datetime import timedelta +from datetime import datetime, timedelta import logging import aiohttp @@ -53,11 +53,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if result is False: return False - async def update_domain_callback(now): + async def update_domain_callback(now: datetime) -> None: """Update the FreeDNS entry.""" await _update_freedns(hass, session, url, auth_token) - async_track_time_interval(hass, update_domain_callback, update_interval) + async_track_time_interval( + hass, update_domain_callback, update_interval, cancel_on_shutdown=True + ) return True diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index d355906ec6..6d371a82c9 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,24 +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, - ), - FritzBinarySensorEntityDescription( - # Deprecated, scheduled to be removed in 2022.7 (#70096) - entity_registry_enabled_default=False, - key="firmware_update", - name="Firmware Update", - device_class=BinarySensorDeviceClass.UPDATE, - entity_category=EntityCategory.DIAGNOSTIC, - is_suitable=lambda info: True, + value_fn=lambda status, _: bool(status.is_linked), ), ) @@ -71,32 +71,19 @@ 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 == "firmware_update": - self._attr_is_on = self._avm_wrapper.update_available - self._attr_extra_state_attributes = { - "installed_version": self._avm_wrapper.current_firmware, - "latest_available_version": self._avm_wrapper.latest_firmware, - } - 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["entity_states"].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..26b336208f 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -19,6 +19,7 @@ from fritzconnection.core.exceptions import ( from fritzconnection.lib.fritzhosts import FritzHosts from fritzconnection.lib.fritzstatus import FritzStatus from fritzconnection.lib.fritzwlan import DEFAULT_PASSWORD_LENGTH, FritzGuestWLAN +import xmltodict from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, @@ -35,7 +36,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 +138,16 @@ class HostInfo(TypedDict): status: bool -class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): +class UpdateCoordinatorDataType(TypedDict): + """Update coordinator data type.""" + + call_deflections: dict[int, dict] + entity_states: dict[str, StateType | bool] + + +class FritzBoxTools( + update_coordinator.DataUpdateCoordinator[UpdateCoordinatorDataType] +): """FritzBoxTools class.""" def __init__( @@ -170,11 +181,15 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): self.password = password self.port = port self.username = username + self.has_call_deflections: bool = False self._model: str | None = None self._current_firmware: str | None = None self._latest_firmware: str | None = None 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,13 +252,49 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): ) self.device_is_router = self.fritz_status.has_wan_enabled - async def _async_update_data(self) -> None: + self.has_call_deflections = "X_AVM-DE_OnTel1" in self.connection.services + + def register_entity_updates( + self, key: str, update_fn: Callable[[FritzStatus, StateType], Any] + ) -> Callable[[], None]: + """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) -> UpdateCoordinatorDataType: """Update FritzboxTools data.""" + entity_data: UpdateCoordinatorDataType = { + "call_deflections": {}, + "entity_states": {}, + } try: await self.async_scan_devices() + for key, update_fn in self._entity_update_functions.items(): + _LOGGER.debug("update entity %s", key) + entity_data["entity_states"][ + key + ] = await self.hass.async_add_executor_job( + update_fn, self.fritz_status, self.data["entity_states"].get(key) + ) + if self.has_call_deflections: + entity_data[ + "call_deflections" + ] = await self.async_update_call_deflections() except FRITZ_EXCEPTIONS as ex: raise update_coordinator.UpdateFailed(ex) from ex + _LOGGER.debug("enity_data: %s", entity_data) + return entity_data + @property def unique_id(self) -> str: """Return unique id.""" @@ -324,6 +375,23 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): """Retrieve latest device information from the FRITZ!Box.""" return await self.hass.async_add_executor_job(self._update_device_info) + async def async_update_call_deflections( + self, + ) -> dict[int, dict[str, Any]]: + """Call GetDeflections action from X_AVM-DE_OnTel service.""" + raw_data = await self.hass.async_add_executor_job( + partial(self.connection.call_action, "X_AVM-DE_OnTel1", "GetDeflections") + ) + if not raw_data: + return {} + + xml_data = xmltodict.parse(raw_data["NewDeflectionList"]) + if xml_data.get("List") and (items := xml_data["List"].get("Item")) is not None: + if not isinstance(items, list): + items = [items] + return {int(item["DeflectionId"]): item for item in items} + return {} + async def _async_get_wan_access(self, ip_address: str) -> bool | None: """Get WAN access rule for given IP address.""" try: @@ -742,18 +810,6 @@ class AvmWrapper(FritzBoxTools): "WLANConfiguration", str(index), "GetInfo" ) - async def async_get_ontel_num_deflections(self) -> dict[str, Any]: - """Call GetNumberOfDeflections action from X_AVM-DE_OnTel service.""" - - return await self._async_service_call( - "X_AVM-DE_OnTel", "1", "GetNumberOfDeflections" - ) - - async def async_get_ontel_deflections(self) -> dict[str, Any]: - """Call GetDeflections action from X_AVM-DE_OnTel service.""" - - return await self._async_service_call("X_AVM-DE_OnTel", "1", "GetDeflections") - async def async_set_wlan_configuration( self, index: int, turn_on: bool ) -> dict[str, Any]: @@ -981,6 +1037,53 @@ 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[AvmWrapper]): + """Fritz host coordinator entity base class.""" + + 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/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index 212710a638..e32ee15279 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -68,7 +68,7 @@ def _async_add_entities( class FritzBoxTracker(FritzDeviceBase, ScannerEntity): - """This class queries a FRITZ!Box device.""" + """Class which queries a FRITZ!Box device.""" def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None: """Initialize a FRITZ!Box device.""" 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..d6b78c1cfc 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["entity_states"].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/fritz/switch.py b/homeassistant/components/fritz/switch.py index a26a0b2313..5b8c404853 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -4,10 +4,8 @@ from __future__ import annotations import logging from typing import Any -import xmltodict - from homeassistant.components.network import async_get_source_ip -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback @@ -15,6 +13,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify from .common import ( @@ -47,31 +46,15 @@ async def _async_deflection_entities_list( _LOGGER.debug("Setting up %s switches", SWITCH_TYPE_DEFLECTION) - deflections_response = await avm_wrapper.async_get_ontel_num_deflections() - if not deflections_response: + if ( + call_deflections := avm_wrapper.data.get("call_deflections") + ) is None or not isinstance(call_deflections, dict): _LOGGER.debug("The FRITZ!Box has no %s options", SWITCH_TYPE_DEFLECTION) return [] - _LOGGER.debug( - "Specific %s response: GetNumberOfDeflections=%s", - SWITCH_TYPE_DEFLECTION, - deflections_response, - ) - - if deflections_response["NewNumberOfDeflections"] == 0: - _LOGGER.debug("The FRITZ!Box has no %s options", SWITCH_TYPE_DEFLECTION) - return [] - - if not (deflection_list := await avm_wrapper.async_get_ontel_deflections()): - return [] - - items = xmltodict.parse(deflection_list["NewDeflectionList"])["List"]["Item"] - if not isinstance(items, list): - items = [items] - return [ - FritzBoxDeflectionSwitch(avm_wrapper, device_friendly_name, dict_of_deflection) - for dict_of_deflection in items + FritzBoxDeflectionSwitch(avm_wrapper, device_friendly_name, cd_id) + for cd_id in call_deflections ] @@ -273,6 +256,60 @@ async def async_setup_entry( ) +class FritzBoxBaseCoordinatorSwitch(CoordinatorEntity[AvmWrapper], SwitchEntity): + """Fritz switch coordinator base class.""" + + entity_description: SwitchEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + avm_wrapper: AvmWrapper, + device_name: str, + description: SwitchEntityDescription, + ) -> None: + """Init device info class.""" + super().__init__(avm_wrapper) + self.entity_description = description + self._device_name = device_name + self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return DeviceInfo( + configuration_url=f"http://{self.coordinator.host}", + connections={(CONNECTION_NETWORK_MAC, self.coordinator.mac)}, + identifiers={(DOMAIN, self.coordinator.unique_id)}, + manufacturer="AVM", + model=self.coordinator.model, + name=self._device_name, + sw_version=self.coordinator.current_firmware, + ) + + @property + def data(self) -> dict[str, Any]: + """Return entity data from coordinator data.""" + raise NotImplementedError() + + @property + def available(self) -> bool: + """Return availability based on data availability.""" + return super().available and bool(self.data) + + async def _async_handle_turn_on_off(self, turn_on: bool) -> None: + """Handle switch state change request.""" + raise NotImplementedError() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on switch.""" + await self._async_handle_turn_on_off(turn_on=True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off switch.""" + await self._async_handle_turn_on_off(turn_on=False) + + class FritzBoxBaseSwitch(FritzBoxBaseEntity): """Fritz switch base class.""" @@ -417,69 +454,51 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): return bool(resp is not None) -class FritzBoxDeflectionSwitch(FritzBoxBaseSwitch, SwitchEntity): +class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch): """Defines a FRITZ!Box Tools PortForward switch.""" + _attr_entity_category = EntityCategory.CONFIG + def __init__( self, avm_wrapper: AvmWrapper, device_friendly_name: str, - dict_of_deflection: Any, + deflection_id: int, ) -> None: """Init Fritxbox Deflection class.""" - self._avm_wrapper = avm_wrapper - - self.dict_of_deflection = dict_of_deflection - self._attributes = {} - self.id = int(self.dict_of_deflection["DeflectionId"]) - self._attr_entity_category = EntityCategory.CONFIG - - switch_info = SwitchInfo( - description=f"Call deflection {self.id}", - friendly_name=device_friendly_name, + self.deflection_id = deflection_id + description = SwitchEntityDescription( + key=f"call_deflection_{self.deflection_id}", + name=f"Call deflection {self.deflection_id}", icon="mdi:phone-forward", - type=SWITCH_TYPE_DEFLECTION, - callback_update=self._async_fetch_update, - callback_switch=self._async_switch_on_off_executor, ) - super().__init__(self._avm_wrapper, device_friendly_name, switch_info) + super().__init__(avm_wrapper, device_friendly_name, description) - async def _async_fetch_update(self) -> None: - """Fetch updates.""" + @property + def data(self) -> dict[str, Any]: + """Return call deflection data.""" + return self.coordinator.data["call_deflections"].get(self.deflection_id, {}) - resp = await self._avm_wrapper.async_get_ontel_deflections() - if not resp: - self._is_available = False - return + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return device attributes.""" + return { + "type": self.data["Type"], + "number": self.data["Number"], + "deflection_to_number": self.data["DeflectionToNumber"], + "mode": self.data["Mode"][1:], + "outgoing": self.data["Outgoing"], + "phonebook_id": self.data["PhonebookID"], + } - self.dict_of_deflection = xmltodict.parse(resp["NewDeflectionList"])["List"][ - "Item" - ] - if isinstance(self.dict_of_deflection, list): - self.dict_of_deflection = self.dict_of_deflection[self.id] + @property + def is_on(self) -> bool | None: + """Switch status.""" + return self.data.get("Enable") == "1" - _LOGGER.debug( - "Specific %s response: NewDeflectionList=%s", - SWITCH_TYPE_DEFLECTION, - self.dict_of_deflection, - ) - - self._attr_is_on = self.dict_of_deflection["Enable"] == "1" - self._is_available = True - - self._attributes["type"] = self.dict_of_deflection["Type"] - self._attributes["number"] = self.dict_of_deflection["Number"] - self._attributes["deflection_to_number"] = self.dict_of_deflection[ - "DeflectionToNumber" - ] - # Return mode sample: "eImmediately" - self._attributes["mode"] = self.dict_of_deflection["Mode"][1:] - self._attributes["outgoing"] = self.dict_of_deflection["Outgoing"] - self._attributes["phonebook_id"] = self.dict_of_deflection["PhonebookID"] - - async def _async_switch_on_off_executor(self, turn_on: bool) -> None: + async def _async_handle_turn_on_off(self, turn_on: bool) -> None: """Handle deflection switch.""" - await self._avm_wrapper.async_set_deflection_enable(self.id, turn_on) + await self.coordinator.async_set_deflection_enable(self.deflection_id, turn_on) class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity): diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index fc65ed9645..bd246dd914 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from abc import ABC, abstractmethod -from xml.etree.ElementTree import ParseError from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError from pyfritzhome.devicetypes.fritzhomeentitybase import FritzhomeEntityBase @@ -44,14 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_CONNECTIONS: fritz, } - try: - await hass.async_add_executor_job(fritz.update_templates) - except ParseError: - LOGGER.debug("Disable smarthome templates") - has_templates = False - else: - LOGGER.debug("Enable smarthome templates") - has_templates = True + has_templates = await hass.async_add_executor_job(fritz.has_templates) + LOGGER.debug("enable smarthome templates: %s", has_templates) coordinator = FritzboxDataUpdateCoordinator(hass, entry, has_templates) @@ -120,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/light.py b/homeassistant/components/fritzbox/light.py index 24431f78ac..f83dd45459 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -72,8 +72,10 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): """Initialize the FritzboxLight entity.""" super().__init__(coordinator, ain, None) - self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) - self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) + if supported_color_temps: + # only available for color bulbs + self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) + self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup @@ -125,7 +127,11 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): @property def supported_color_modes(self) -> set[ColorMode]: """Flag supported color modes.""" - return SUPPORTED_COLOR_MODES + if self.data.has_color: + return SUPPORTED_COLOR_MODES + if self.data.has_level: + return {ColorMode.BRIGHTNESS} + return {ColorMode.ONOFF} async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index e604f1d37b..29df2f51a3 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], - "requirements": ["pyfritzhome==0.6.7"], + "requirements": ["pyfritzhome==0.6.8"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index ea63ab983c..3c3275e0ff 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -8,7 +8,6 @@ from typing import TypedDict from pyfritzhome import FritzhomeDevice -@dataclass class ClimateExtraAttributes(TypedDict, total=False): """TypedDict for climates extra attributes.""" diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 4d045c2c98..7922224e19 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -74,17 +74,6 @@ def suitable_temperature(device: FritzhomeDevice) -> bool: return device.has_temperature_sensor and not device.has_thermostat -def value_electric_current(device: FritzhomeDevice) -> float: - """Return native value for electric current sensor.""" - if ( - isinstance(device.power, int) - and isinstance(device.voltage, int) - and device.voltage > 0 - ): - return round(device.power / device.voltage, 3) - return 0.0 - - def value_nextchange_preset(device: FritzhomeDevice) -> str: """Return native value for next scheduled preset sensor.""" if device.nextchange_temperature == device.eco_temperature: @@ -102,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, @@ -112,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, @@ -121,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, @@ -130,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, @@ -139,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, @@ -148,16 +137,16 @@ 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, suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] - native_value=value_electric_current, + native_value=lambda device: round((device.current or 0.0) / 1000, 3), ), 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, @@ -167,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/base.py b/homeassistant/components/fritzbox_callmonitor/base.py index 386e60ba19..df19bca7b1 100644 --- a/homeassistant/components/fritzbox_callmonitor/base.py +++ b/homeassistant/components/fritzbox_callmonitor/base.py @@ -19,7 +19,7 @@ MIN_TIME_PHONEBOOK_UPDATE = timedelta(hours=6) class FritzBoxPhonebook: - """This connects to a FritzBox router and downloads its phone book.""" + """Connects to a FritzBox router and downloads its phone book.""" fph: FritzPhonebook phonebook_dict: dict[str, list[str]] 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 af58a15363..4e1e0a74fe 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==20230202.0"] + "requirements": ["home-assistant-frontend==20230503.3"] } diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py index ddd74ca8ef..62f2623d05 100644 --- a/homeassistant/components/frontier_silicon/__init__.py +++ b/homeassistant/components/frontier_silicon/__init__.py @@ -1 +1,45 @@ -"""The frontier_silicon component.""" +"""The Frontier Silicon integration.""" +from __future__ import annotations + +import logging + +from afsapi import AFSAPI, ConnectionError as FSConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_PIN, CONF_WEBFSAPI_URL, DOMAIN + +PLATFORMS = [Platform.MEDIA_PLAYER] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Frontier Silicon from a config entry.""" + + webfsapi_url = entry.data[CONF_WEBFSAPI_URL] + pin = entry.data[CONF_PIN] + + afsapi = AFSAPI(webfsapi_url, pin) + + try: + await afsapi.get_power() + except FSConnectionError as exception: + raise ConfigEntryNotReady from exception + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = afsapi + + 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/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py new file mode 100644 index 0000000000..5179b02bbc --- /dev/null +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -0,0 +1,279 @@ +"""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, + NotImplementedException, +) +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, + SSDP_ATTR_SPEAKER_NAME, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } +) + +STEP_DEVICE_CONFIG_DATA_SCHEMA = vol.Schema( + { + vol.Required( + CONF_PIN, + default=DEFAULT_PIN, + ): str, + } +) + + +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.""" + + device_url = f"http://{import_info[CONF_HOST]}:{import_info[CONF_PORT]}/device" + try: + webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) + except FSConnectionError: + return self.async_abort(reason="cannot_connect") + except Exception as exception: # pylint: disable=broad-except + _LOGGER.exception(exception) + return self.async_abort(reason="unknown") + + afsapi = AFSAPI(webfsapi_url, import_info[CONF_PIN]) + try: + unique_id = await afsapi.get_radio_id() + except NotImplementedException: + unique_id = None # Not all radios have this call implemented + except FSConnectionError: + return self.async_abort(reason="cannot_connect") + except InvalidPinException: + return self.async_abort(reason="invalid_auth") + except Exception as exception: # pylint: disable=broad-except + _LOGGER.exception(exception) + return self.async_abort(reason="unknown") + + await self.async_set_unique_id(unique_id, raise_on_progress=False) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=import_info[CONF_NAME] or "Radio", + data={ + CONF_WEBFSAPI_URL: webfsapi_url, + CONF_PIN: import_info[CONF_PIN], + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step of manual configuration.""" + errors = {} + + if user_input: + device_url = ( + f"http://{user_input[CONF_HOST]}:{user_input[CONF_PORT]}/device" + ) + try: + self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) + except FSConnectionError: + errors["base"] = "cannot_connect" + except Exception as exception: # pylint: disable=broad-except + _LOGGER.exception(exception) + errors["base"] = "unknown" + else: + return await self._async_step_device_config_if_needed() + + data_schema = self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ) + return self.async_show_form( + 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 to login with default pin + afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) + try: + await afsapi.get_friendly_name() + except InvalidPinException: + return self.async_abort(reason="invalid_auth") + + try: + unique_id = await afsapi.get_radio_id() + except NotImplementedException: + unique_id = None + + 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. + + We try to use this default PIN, and only if this fails ask for it via `async_step_device_config` + """ + + try: + # try to login with default pin + afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) + + 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": self._name} + + try: + unique_id = await afsapi.get_radio_id() + except NotImplementedException: + unique_id = None + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + 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: + """Handle device configuration step. + + We ask for the PIN in this step. + """ + + if user_input is None: + return self.async_show_form( + step_id="device_config", data_schema=STEP_DEVICE_CONFIG_DATA_SCHEMA + ) + + errors = {} + + try: + afsapi = AFSAPI(self._webfsapi_url, user_input[CONF_PIN]) + + self._name = await afsapi.get_friendly_name() + + except FSConnectionError: + errors["base"] = "cannot_connect" + except InvalidPinException: + errors["base"] = "invalid_auth" + except Exception as exception: # pylint: disable=broad-except + _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") + + try: + unique_id = await afsapi.get_radio_id() + except NotImplementedException: + unique_id = None + await self.async_set_unique_id(unique_id, raise_on_progress=False) + self._abort_if_unique_id_configured() + 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 + ) + return self.async_show_form( + step_id="device_config", + 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 9ee17c0320..34201fe8f4 100644 --- a/homeassistant/components/frontier_silicon/const.py +++ b/homeassistant/components/frontier_silicon/const.py @@ -1,6 +1,12 @@ """Constants for the Frontier Silicon Media Player integration.""" 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 322c1b90b2..9cc928e6f8 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -2,7 +2,9 @@ "domain": "frontier_silicon", "name": "Frontier Silicon", "codeowners": ["@wlcrs"], + "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 0e3eb16848..54c17429b5 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -21,15 +21,17 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .browse_media import browse_node, browse_top_level -from .const import DEFAULT_PIN, DEFAULT_PORT, DOMAIN, MEDIA_CONTENT_ID_PRESET +from .const import CONF_PIN, DEFAULT_PIN, DEFAULT_PORT, DOMAIN, MEDIA_CONTENT_ID_PRESET _LOGGER = logging.getLogger(__name__) @@ -49,36 +51,43 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Frontier Silicon platform.""" - if discovery_info is not None: - webfsapi_url = await AFSAPI.get_webfsapi_endpoint( - discovery_info["ssdp_description"] - ) - afsapi = AFSAPI(webfsapi_url, DEFAULT_PIN) + """Set up the Frontier Silicon platform. - name = await afsapi.get_friendly_name() - async_add_entities( - [AFSAPIDevice(name, afsapi)], - True, - ) - return + YAML is deprecated, and imported automatically. + """ - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - password = config.get(CONF_PASSWORD) - name = config.get(CONF_NAME) + ir.async_create_issue( + hass, + DOMAIN, + "remove_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="removed_yaml", + ) - try: - webfsapi_url = await AFSAPI.get_webfsapi_endpoint( - f"http://{host}:{port}/device" - ) - except FSConnectionError: - _LOGGER.error( - "Could not add the FSAPI device at %s:%s -> %s", host, port, password - ) - return - afsapi = AFSAPI(webfsapi_url, password) - async_add_entities([AFSAPIDevice(name, afsapi)], True) + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_NAME: config.get(CONF_NAME), + CONF_HOST: config.get(CONF_HOST), + CONF_PORT: config.get(CONF_PORT, DEFAULT_PORT), + CONF_PIN: config.get(CONF_PASSWORD, DEFAULT_PIN), + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Frontier Silicon entity.""" + + afsapi: AFSAPI = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities([AFSAPIDevice(config_entry.title, afsapi)], True) class AFSAPIDevice(MediaPlayerEntity): @@ -305,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 new file mode 100644 index 0000000000..193ca7123f --- /dev/null +++ b/homeassistant/components/frontier_silicon/strings.json @@ -0,0 +1,40 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "device_config": { + "title": "Device Configuration", + "description": "The pin can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + } + }, + "confirm": { + "description": "Do you want to set up {name}?" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "issues": { + "removed_yaml": { + "title": "The Frontier Silicon YAML configuration has been removed", + "description": "Configuring Frontier Silicon using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py index ae6cf083ed..8c73d47dd7 100644 --- a/homeassistant/components/fully_kiosk/media_player.py +++ b/homeassistant/components/fully_kiosk/media_player.py @@ -8,6 +8,7 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaPlayerEntity, MediaPlayerState, + MediaType, async_process_play_media_url, ) from homeassistant.config_entries import ConfigEntry @@ -42,7 +43,7 @@ class FullyMediaPlayer(FullyKioskEntity, MediaPlayerEntity): self._attr_state = MediaPlayerState.IDLE async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" if media_source.is_media_source_id(media_id): @@ -71,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/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py index cf7dd62dec..60009eb6ae 100644 --- a/homeassistant/components/fully_kiosk/sensor.py +++ b/homeassistant/components/fully_kiosk/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import DOMAIN from .coordinator import FullyKioskDataUpdateCoordinator @@ -30,7 +30,7 @@ def round_storage(value: int) -> float: class FullySensorEntityDescription(SensorEntityDescription): """Fully Kiosk Browser sensor description.""" - state_fn: Callable | None = None + state_fn: Callable[[int], float] | None = None SENSORS: tuple[FullySensorEntityDescription, ...] = ( @@ -130,7 +130,7 @@ class FullySensor(FullyKioskEntity, SensorEntity): super().__init__(coordinator) @property - def native_value(self) -> Any: + def native_value(self) -> StateType: """Return the state of the sensor.""" if (value := self.coordinator.data.get(self.entity_description.key)) is None: return None @@ -138,4 +138,4 @@ class FullySensor(FullyKioskEntity, SensorEntity): if self.entity_description.state_fn is not None: return self.entity_description.state_fn(value) - return value + return value # type: ignore[no-any-return] diff --git a/homeassistant/components/garages_amsterdam/manifest.json b/homeassistant/components/garages_amsterdam/manifest.json index 9dd043a715..e2f068b961 100644 --- a/homeassistant/components/garages_amsterdam/manifest.json +++ b/homeassistant/components/garages_amsterdam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", "iot_class": "cloud_polling", - "requirements": ["odp-amsterdam==5.0.1"] + "requirements": ["odp-amsterdam==5.1.0"] } diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py index 06ab1aa083..1d3dabc464 100644 --- a/homeassistant/components/gdacs/geo_location.py +++ b/homeassistant/components/gdacs/geo_location.py @@ -77,7 +77,7 @@ async def async_setup_entry( class GdacsEvent(GeolocationEvent): - """This represents an external event with GDACS feed data.""" + """Represents an external event with GDACS feed data.""" _attr_should_poll = False _attr_source = SOURCE diff --git a/homeassistant/components/gdacs/manifest.json b/homeassistant/components/gdacs/manifest.json index 4db9d2fc89..86904e3e9b 100644 --- a/homeassistant/components/gdacs/manifest.json +++ b/homeassistant/components/gdacs/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/gdacs", "integration_type": "service", "iot_class": "cloud_polling", + "loggers": ["aio_georss_gdacs", "aio_georss_client"], "quality_scale": "platinum", "requirements": ["aio_georss_gdacs==0.8"] } diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 531eb05dcf..6563e26368 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -39,7 +39,7 @@ async def async_setup_entry( class GdacsSensor(SensorEntity): - """This is a status sensor for the GDACS integration.""" + """Status sensor for the GDACS integration.""" _attr_should_poll = False 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 8ed2711d7c..a6e76330f2 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 @@ -170,11 +172,24 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): ) if self._keep_alive: - async_track_time_interval(self.hass, self._async_operate, self._keep_alive) + self.async_on_remove( + async_track_time_interval( + self.hass, self._async_operate, self._keep_alive + ) + ) 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) @@ -205,6 +220,12 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): await _async_startup(None) # init the sensor + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._remove_stale_tracking: + self._remove_stale_tracking() + return await super().async_will_remove_from_hass() + @property def available(self): """Return True if entity is available.""" @@ -259,7 +280,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 +289,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 +298,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 +339,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.""" @@ -436,7 +457,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): data = {ATTR_ENTITY_ID: self._switch_entity_id} await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data) - async def async_set_mode(self, mode: str): + async def async_set_mode(self, mode: str) -> None: """Set new mode. This method must be run in the event loop and returns a coroutine. @@ -460,4 +481,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/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index 3f8fb0c680..c2b32582ce 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -41,7 +41,7 @@ async def async_setup_platform( [ GeniusClimateZone(broker, z) for z in broker.client.zone_objs - if z.data["type"] in GH_ZONES + if z.data.get("type") in GH_ZONES ] ) @@ -79,10 +79,10 @@ class GeniusClimateZone(GeniusHeatingZone, ClimateEntity): def hvac_action(self) -> str | None: """Return the current running hvac operation if supported.""" if "_state" in self._zone.data: # only for v3 API + if self._zone.data["output"] == 1: + return HVACAction.HEATING if not self._zone.data["_state"].get("bIsActive"): return HVACAction.OFF - if self._zone.data["_state"].get("bOutRequestHeat"): - return HVACAction.HEATING return HVACAction.IDLE return None diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index cf29d0ea80..79ba418d50 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -42,7 +42,7 @@ async def async_setup_platform( [ GeniusSwitch(broker, z) for z in broker.client.zone_objs - if z.data["type"] == GH_ON_OFF_ZONE + if z.data.get("type") == GH_ON_OFF_ZONE ] ) diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py index ea8b1a4396..f8cf7288e5 100644 --- a/homeassistant/components/geniushub/water_heater.py +++ b/homeassistant/components/geniushub/water_heater.py @@ -48,7 +48,7 @@ async def async_setup_platform( [ GeniusWaterHeater(broker, z) for z in broker.client.zone_objs - if z.data["type"] in GH_HEATERS + if z.data.get("type") in GH_HEATERS ] ) diff --git a/homeassistant/components/geo_json_events/__init__.py b/homeassistant/components/geo_json_events/__init__.py index 0bc612b6e8..64b589f4f9 100644 --- a/homeassistant/components/geo_json_events/__init__.py +++ b/homeassistant/components/geo_json_events/__init__.py @@ -1 +1,57 @@ -"""The geo_json_events component.""" +"""The GeoJSON events component.""" +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import ( + async_entries_for_config_entry, + async_get, +) + +from .const import DOMAIN, PLATFORMS +from .manager import GeoJsonFeedEntityManager + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up the GeoJSON events component as config entry.""" + feeds = hass.data.setdefault(DOMAIN, {}) + # Create feed entity manager for all platforms. + manager = GeoJsonFeedEntityManager(hass, config_entry) + feeds[config_entry.entry_id] = manager + _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) + await remove_orphaned_entities(hass, config_entry.entry_id) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + await manager.async_init() + return True + + +async def remove_orphaned_entities(hass: HomeAssistant, entry_id: str) -> None: + """Remove orphaned geo_location entities. + + This is needed because when fetching data from the external feed this integration is + determining which entities need to be added, updated or removed by comparing the + current with the previous data. After a restart of Home Assistant the integration + has no previous data to compare against, and thus all entities managed by this + integration are removed after startup. + """ + entity_registry = async_get(hass) + orphaned_entries = async_entries_for_config_entry(entity_registry, entry_id) + if orphaned_entries is not None: + for entry in orphaned_entries: + if entry.domain == Platform.GEO_LOCATION: + _LOGGER.debug("Removing orphaned entry %s", entry.entity_id) + entity_registry.async_remove(entry.entity_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload the GeoJSON events config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + manager: GeoJsonFeedEntityManager = hass.data[DOMAIN].pop(entry.entry_id) + await manager.async_stop() + return unload_ok diff --git a/homeassistant/components/geo_json_events/config_flow.py b/homeassistant/components/geo_json_events/config_flow.py new file mode 100644 index 0000000000..cf58e8b57c --- /dev/null +++ b/homeassistant/components/geo_json_events/config_flow.py @@ -0,0 +1,105 @@ +"""Config flow to configure the GeoJSON events integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_URL, + UnitOfLength, +) +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv, selector +from homeassistant.util.unit_conversion import DistanceConverter + +from .const import DEFAULT_RADIUS_IN_KM, DEFAULT_RADIUS_IN_M, DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): cv.string, + vol.Required(CONF_LOCATION): selector.LocationSelector( + selector.LocationSelectorConfig(radius=True, icon="") + ), + } +) + +_LOGGER = logging.getLogger(__name__) + + +class GeoJsonEventsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a GeoJSON events config flow.""" + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + url: str = import_config[CONF_URL] + latitude: float = import_config.get(CONF_LATITUDE, self.hass.config.latitude) + longitude: float = import_config.get(CONF_LONGITUDE, self.hass.config.longitude) + self._async_abort_entries_match( + { + CONF_URL: url, + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + } + ) + return self.async_create_entry( + title=f"{url} ({latitude}, {longitude})", + data={ + CONF_URL: url, + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_RADIUS: import_config.get(CONF_RADIUS, DEFAULT_RADIUS_IN_KM), + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the start of the config flow.""" + if not user_input: + suggested_values: Mapping[str, Any] = { + CONF_LOCATION: { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + CONF_RADIUS: DEFAULT_RADIUS_IN_M, + } + } + data_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, suggested_values + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + ) + + url: str = user_input[CONF_URL] + location: dict[str, Any] = user_input[CONF_LOCATION] + latitude: float = location[CONF_LATITUDE] + longitude: float = location[CONF_LONGITUDE] + self._async_abort_entries_match( + { + CONF_URL: url, + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + } + ) + return self.async_create_entry( + title=f"{url} ({latitude}, {longitude})", + data={ + CONF_URL: url, + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_RADIUS: DistanceConverter.convert( + location[CONF_RADIUS], + UnitOfLength.METERS, + UnitOfLength.KILOMETERS, + ), + }, + ) diff --git a/homeassistant/components/geo_json_events/const.py b/homeassistant/components/geo_json_events/const.py new file mode 100644 index 0000000000..15f8b0a5b8 --- /dev/null +++ b/homeassistant/components/geo_json_events/const.py @@ -0,0 +1,20 @@ +"""Define constants for the GeoJSON events integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "geo_json_events" + +PLATFORMS: Final = [Platform.GEO_LOCATION] + +ATTR_EXTERNAL_ID: Final = "external_id" +DEFAULT_RADIUS_IN_KM: Final = 20.0 +DEFAULT_RADIUS_IN_M: Final = 20000.0 +DEFAULT_UPDATE_INTERVAL: Final = timedelta(seconds=300) +SOURCE: Final = "geo_json_events" + +SIGNAL_DELETE_ENTITY: Final = "geo_json_events_delete_{}" +SIGNAL_UPDATE_ENTITY: Final = "geo_json_events_update_{}" diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index 166da1184c..def8f77994 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -2,45 +2,41 @@ from __future__ import annotations from collections.abc import Callable -from datetime import datetime, timedelta import logging from typing import Any -from aio_geojson_generic_client import GenericFeedManager from aio_geojson_generic_client.feed_entry import GenericFeedEntry import voluptuous as vol from homeassistant.components.geo_location import PLATFORM_SCHEMA, GeolocationEvent +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, - CONF_SCAN_INTERVAL, CONF_URL, - EVENT_HOMEASSISTANT_START, UnitOfLength, ) -from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import aiohttp_client +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import GeoJsonFeedEntityManager +from .const import ( + ATTR_EXTERNAL_ID, + DEFAULT_RADIUS_IN_KM, + DOMAIN, + SIGNAL_DELETE_ENTITY, + SIGNAL_UPDATE_ENTITY, + SOURCE, +) + _LOGGER = logging.getLogger(__name__) -ATTR_EXTERNAL_ID = "external_id" - -DEFAULT_RADIUS_IN_KM = 20.0 - -SCAN_INTERVAL = timedelta(minutes=5) - -SOURCE = "geo_json_events" - +# Deprecated. PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_URL): cv.string, @@ -51,6 +47,31 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the GeoJSON Events platform.""" + manager: GeoJsonFeedEntityManager = hass.data[DOMAIN][entry.entry_id] + + @callback + def async_add_geolocation( + feed_manager: GeoJsonFeedEntityManager, + external_id: str, + ) -> None: + """Add geolocation entity from feed.""" + new_entity = GeoJsonLocationEvent(feed_manager, external_id) + _LOGGER.debug("Adding geolocation %s", new_entity) + async_add_entities([new_entity], True) + + manager.listeners.append( + async_dispatcher_connect(hass, manager.signal_new_entity, async_add_geolocation) + ) + # Do not wait for update here so that the setup can be completed and because an + # update will fetch data from the feed via HTTP and then process that data. + entry.async_create_task(hass, manager.async_update()) + _LOGGER.debug("Geolocation setup done") + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -58,100 +79,38 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the GeoJSON Events platform.""" - url: str = config[CONF_URL] - scan_interval: timedelta = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) - coordinates: tuple[float, float] = ( - config.get(CONF_LATITUDE, hass.config.latitude), - config.get(CONF_LONGITUDE, hass.config.longitude), + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", ) - radius_in_km: float = config[CONF_RADIUS] - # Initialize the entity manager. - manager = GeoJsonFeedEntityManager( - hass, async_add_entities, scan_interval, coordinates, url, radius_in_km - ) - await manager.async_init() - - async def start_feed_manager(event: Event) -> None: - """Start feed manager.""" - await manager.async_update() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) - - -class GeoJsonFeedEntityManager: - """Feed Entity Manager for GeoJSON feeds.""" - - def __init__( - self, - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - scan_interval: timedelta, - coordinates: tuple[float, float], - url: str, - radius_in_km: float, - ) -> None: - """Initialize the GeoJSON Feed Manager.""" - - self._hass = hass - websession = aiohttp_client.async_get_clientsession(hass) - self._feed_manager = GenericFeedManager( - websession, - self._generate_entity, - self._update_entity, - self._remove_entity, - coordinates, - url, - filter_radius=radius_in_km, + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config ) - self._async_add_entities = async_add_entities - self._scan_interval = scan_interval - - async def async_init(self) -> None: - """Schedule initial and regular updates based on configured time interval.""" - - async def update(event_time: datetime) -> None: - """Update.""" - await self.async_update() - - # Trigger updates at regular intervals. - async_track_time_interval(self._hass, update, self._scan_interval) - _LOGGER.debug("Feed entity manager initialized") - - async def async_update(self) -> None: - """Refresh data.""" - await self._feed_manager.update() - _LOGGER.debug("Feed entity manager updated") - - def get_entry(self, external_id: str) -> GenericFeedEntry | None: - """Get feed entry by external id.""" - return self._feed_manager.feed_entries.get(external_id) - - async def _generate_entity(self, external_id: str) -> None: - """Generate new entity.""" - new_entity = GeoJsonLocationEvent(self, external_id) - # Add new entities to HA. - self._async_add_entities([new_entity], True) - - async def _update_entity(self, external_id: str) -> None: - """Update entity.""" - async_dispatcher_send(self._hass, f"geo_json_events_update_{external_id}") - - async def _remove_entity(self, external_id: str) -> None: - """Remove entity.""" - async_dispatcher_send(self._hass, f"geo_json_events_delete_{external_id}") + ) class GeoJsonLocationEvent(GeolocationEvent): - """This represents an external event with GeoJSON data.""" + """Represents an external event with GeoJSON data.""" _attr_should_poll = False _attr_source = SOURCE _attr_unit_of_measurement = UnitOfLength.KILOMETERS - def __init__(self, feed_manager: GenericFeedManager, external_id: str) -> None: + def __init__( + self, + feed_manager: GeoJsonFeedEntityManager, + external_id: str, + ) -> None: """Initialize entity with data from feed entry.""" self._feed_manager = feed_manager self._external_id = external_id + self._attr_unique_id = f"{feed_manager.entry_id}_{external_id}" self._remove_signal_delete: Callable[[], None] self._remove_signal_update: Callable[[], None] @@ -159,12 +118,12 @@ class GeoJsonLocationEvent(GeolocationEvent): """Call when entity is added to hass.""" self._remove_signal_delete = async_dispatcher_connect( self.hass, - f"geo_json_events_delete_{self._external_id}", + SIGNAL_DELETE_ENTITY.format(self._external_id), self._delete_callback, ) self._remove_signal_update = async_dispatcher_connect( self.hass, - f"geo_json_events_update_{self._external_id}", + SIGNAL_UPDATE_ENTITY.format(self._external_id), self._update_callback, ) diff --git a/homeassistant/components/geo_json_events/manager.py b/homeassistant/components/geo_json_events/manager.py new file mode 100644 index 0000000000..93f74831ec --- /dev/null +++ b/homeassistant/components/geo_json_events/manager.py @@ -0,0 +1,105 @@ +"""Entity manager for generic GeoJSON events.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +import logging + +from aio_geojson_generic_client import GenericFeedManager +from aio_geojson_generic_client.feed_entry import GenericFeedEntry + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +from .const import ( + DEFAULT_UPDATE_INTERVAL, + DOMAIN, + SIGNAL_DELETE_ENTITY, + SIGNAL_UPDATE_ENTITY, +) + +_LOGGER = logging.getLogger(__name__) + + +class GeoJsonFeedEntityManager: + """Feed Entity Manager for GeoJSON feeds.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + ) -> None: + """Initialize the GeoJSON Feed Manager.""" + self._hass: HomeAssistant = hass + self.entry_id: str = config_entry.entry_id + websession = aiohttp_client.async_get_clientsession(hass) + self._feed_manager: GenericFeedManager = GenericFeedManager( + websession, + self._generate_entity, + self._update_entity, + self._remove_entity, + ( + config_entry.data[CONF_LATITUDE], + config_entry.data[CONF_LONGITUDE], + ), + config_entry.data[CONF_URL], + filter_radius=config_entry.data[CONF_RADIUS], + ) + self._track_time_remove_callback: Callable[[], None] | None = None + self.listeners: list[Callable[[], None]] = [] + self.signal_new_entity: str = ( + f"{DOMAIN}_new_geolocation_{config_entry.entry_id}" + ) + + async def async_init(self) -> None: + """Schedule initial and regular updates based on configured time interval.""" + + async def update(event_time: datetime) -> None: + """Update.""" + await self.async_update() + + # Trigger updates at regular intervals. + self._track_time_remove_callback = async_track_time_interval( + self._hass, update, DEFAULT_UPDATE_INTERVAL + ) + + _LOGGER.debug("Feed entity manager initialized") + + async def async_update(self) -> None: + """Refresh data.""" + await self._feed_manager.update() + _LOGGER.debug("Feed entity manager updated") + + async def async_stop(self) -> None: + """Stop this feed entity manager from refreshing.""" + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + self.listeners = [] + if self._track_time_remove_callback: + self._track_time_remove_callback() + _LOGGER.debug("Feed entity manager stopped") + + def get_entry(self, external_id: str) -> GenericFeedEntry | None: + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + async def _generate_entity(self, external_id: str) -> None: + """Generate new entity.""" + async_dispatcher_send( + self._hass, + self.signal_new_entity, + self, + external_id, + ) + + async def _update_entity(self, external_id: str) -> None: + """Update entity.""" + async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + + async def _remove_entity(self, external_id: str) -> None: + """Remove entity.""" + async_dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) diff --git a/homeassistant/components/geo_json_events/manifest.json b/homeassistant/components/geo_json_events/manifest.json index 8e38144941..b02339eb20 100644 --- a/homeassistant/components/geo_json_events/manifest.json +++ b/homeassistant/components/geo_json_events/manifest.json @@ -2,6 +2,7 @@ "domain": "geo_json_events", "name": "GeoJSON", "codeowners": ["@exxamalte"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/geo_json_events", "integration_type": "service", "iot_class": "cloud_polling", diff --git a/homeassistant/components/geo_json_events/strings.json b/homeassistant/components/geo_json_events/strings.json new file mode 100644 index 0000000000..e50369d6e7 --- /dev/null +++ b/homeassistant/components/geo_json_events/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "Fill in your GeoJSON feed details.", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "location": "[%key:common::config_flow::data::location%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The GeoJSON feed YAML configuration is being removed", + "description": "Configuring a GeoJSON feed using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the GeoJSON feed YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/geo_location/strings.json b/homeassistant/components/geo_location/strings.json new file mode 100644 index 0000000000..4678790b52 --- /dev/null +++ b/homeassistant/components/geo_location/strings.json @@ -0,0 +1,19 @@ +{ + "title": "Geolocation", + "entity_component": { + "_": { + "name": "[%key:component::geo_location::title%]", + "state_attributes": { + "latitude": { + "name": "Latitude" + }, + "longitude": { + "name": "Longitude" + }, + "source": { + "name": "Source" + } + } + } + } +} diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index cc47883d05..892116121a 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -3,7 +3,7 @@ from homeassistant.components.device_tracker import SourceType, TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -34,7 +34,7 @@ async def async_setup_entry( ] = async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) # Restore previously loaded devices - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_ids = { identifier[1] for device in dev_reg.devices.values() diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py index 411a037546..6fa84f590f 100644 --- a/homeassistant/components/geonetnz_quakes/geo_location.py +++ b/homeassistant/components/geonetnz_quakes/geo_location.py @@ -65,7 +65,7 @@ async def async_setup_entry( class GeonetnzQuakesEvent(GeolocationEvent): - """This represents an external event with GeoNet NZ Quakes feed data.""" + """Represents an external event with GeoNet NZ Quakes feed data.""" _attr_icon = "mdi:pulse" _attr_should_poll = False diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index 9183aead16..8fb2ff8535 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -40,7 +40,7 @@ async def async_setup_entry( class GeonetnzQuakesSensor(SensorEntity): - """This is a status sensor for the GeoNet NZ Quakes integration.""" + """Status sensor for the GeoNet NZ Quakes integration.""" _attr_should_poll = False diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index 25e02f4430..33a879eeb2 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -54,7 +54,7 @@ async def async_setup_entry( class GeonetnzVolcanoSensor(SensorEntity): - """This represents an external event with GeoNet NZ Volcano feed data.""" + """Represents an external event with GeoNet NZ Volcano feed data.""" _attr_should_poll = False diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 1ade1a83cc..213fabc911 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -2,12 +2,13 @@ from __future__ import annotations import logging -from typing import Any, cast from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout -from gios import ApiError, Gios, InvalidSensorsData, NoStationError +from gios import Gios +from gios.exceptions import GiosError +from gios.model import GiosSensors from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry @@ -73,7 +74,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class GiosDataUpdateCoordinator(DataUpdateCoordinator): +class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): """Define an object to hold GIOS data.""" def __init__( @@ -84,15 +85,10 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator): super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> GiosSensors: """Update data via library.""" try: async with timeout(API_TIMEOUT): - return cast(dict[str, Any], await self.gios.async_update()) - except ( - ApiError, - NoStationError, - ClientConnectorError, - InvalidSensorsData, - ) as error: + return await self.gios.async_update() + except (GiosError, ClientConnectorError) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index 0fa5052e12..a1b4abd2dc 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -6,7 +6,7 @@ from typing import Any from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout -from gios import ApiError, Gios, InvalidSensorsData, NoStationError +from gios import ApiError, Gios, InvalidSensorsDataError, NoStationError import voluptuous as vol from homeassistant import config_entries @@ -50,7 +50,7 @@ class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except NoStationError: errors[CONF_STATION_ID] = "wrong_station_id" - except InvalidSensorsData: + except InvalidSensorsDataError: errors[CONF_STATION_ID] = "invalid_sensors_data" return self.async_show_form( 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/manifest.json b/homeassistant/components/gios/manifest.json index 6b3051a4bd..41954645f5 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], "quality_scale": "platinum", - "requirements": ["gios==2.3.0"] + "requirements": ["gios==3.1.0"] } diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index cabbb671ae..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,65 +44,135 @@ _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"], + translation_key="aqi", ), 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", ), ) @@ -136,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) @@ -170,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 18db42b69c..5387c043fc 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -22,5 +22,96 @@ "info": { "can_reach_server": "Reach GIO\u015a server" } + }, + "entity": { + "sensor": { + "aqi": { + "name": "[%key:component::sensor::entity_component::aqi::name%]", + "state": { + "very_bad": "Very bad", + "bad": "Bad", + "sufficient": "Sufficient", + "moderate": "Moderate", + "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 e0eaf3bb38..8b836fba3e 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -15,13 +15,12 @@ from homeassistant.const import ( CONF_NAME, PERCENTAGE, REVOLUTIONS_PER_MINUTE, - STATE_UNAVAILABLE, Platform, UnitOfInformation, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -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( @@ -257,7 +256,7 @@ async def async_setup_entry( hass: HomeAssistant, old_unique_id: str, new_key: str ) -> None: """Migrate unique IDs to the new format.""" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) if entity_id := ent_reg.async_get_entity_id( Platform.SENSOR, DOMAIN, old_unique_id @@ -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/__init__.py b/homeassistant/components/goodwe/__init__.py index a888752f50..b5872ed3de 100644 --- a/homeassistant/components/goodwe/__init__.py +++ b/homeassistant/components/goodwe/__init__.py @@ -1,14 +1,12 @@ """The Goodwe inverter component.""" -import logging -from goodwe import InverterError, RequestFailedException, connect +from goodwe import InverterError, connect from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_MODEL_FAMILY, @@ -17,16 +15,13 @@ from .const import ( KEY_DEVICE_INFO, KEY_INVERTER, PLATFORMS, - SCAN_INTERVAL, ) - -_LOGGER = logging.getLogger(__name__) +from .coordinator import GoodweUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Goodwe components from a config entry.""" hass.data.setdefault(DOMAIN, {}) - name = entry.title host = entry.data[CONF_HOST] model_family = entry.data[CONF_MODEL_FAMILY] @@ -49,39 +44,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: sw_version=f"{inverter.firmware} / {inverter.arm_firmware}", ) - async def async_update_data(): - """Fetch data from the inverter.""" - try: - return await inverter.read_runtime_data() - except RequestFailedException as ex: - # UDP communication with inverter is by definition unreliable. - # It is rather normal in many environments to fail to receive - # proper response in usual time, so we intentionally ignore isolated - # failures and report problem with availability only after - # consecutive streak of 3 of failed requests. - if ex.consecutive_failures_count < 3: - _LOGGER.debug( - "No response received (streak of %d)", ex.consecutive_failures_count - ) - # return empty dictionary, sensors will keep their previous values - return {} - # Inverter does not respond anymore (e.g. it went to sleep mode) - _LOGGER.debug( - "Inverter not responding (streak of %d)", ex.consecutive_failures_count - ) - raise UpdateFailed(ex) from ex - except InverterError as ex: - raise UpdateFailed(ex) from ex - # Create update coordinator - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=name, - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=SCAN_INTERVAL, - ) + coordinator = GoodweUpdateCoordinator(hass, entry, inverter) # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/goodwe/coordinator.py b/homeassistant/components/goodwe/coordinator.py new file mode 100644 index 0000000000..0ae064e0e9 --- /dev/null +++ b/homeassistant/components/goodwe/coordinator.py @@ -0,0 +1,80 @@ +"""Update coordinator for Goodwe.""" +from __future__ import annotations + +import logging +from typing import Any + +from goodwe import Inverter, InverterError, RequestFailedException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class GoodweUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Gather data for the energy device.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + inverter: Inverter, + ) -> None: + """Initialize update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=entry.title, + update_interval=SCAN_INTERVAL, + update_method=self._async_update_data, + ) + self.inverter: Inverter = inverter + self._last_data: dict[str, Any] = {} + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the inverter.""" + try: + self._last_data = self.data if self.data else {} + return await self.inverter.read_runtime_data() + except RequestFailedException as ex: + # UDP communication with inverter is by definition unreliable. + # It is rather normal in many environments to fail to receive + # proper response in usual time, so we intentionally ignore isolated + # failures and report problem with availability only after + # consecutive streak of 3 of failed requests. + if ex.consecutive_failures_count < 3: + _LOGGER.debug( + "No response received (streak of %d)", ex.consecutive_failures_count + ) + # return last known data + return self._last_data + # Inverter does not respond anymore (e.g. it went to sleep mode) + _LOGGER.debug( + "Inverter not responding (streak of %d)", ex.consecutive_failures_count + ) + raise UpdateFailed(ex) from ex + except InverterError as ex: + raise UpdateFailed(ex) from ex + + def sensor_value(self, sensor: str) -> Any: + """Answer current (or last known) value of the sensor.""" + val = self.data.get(sensor) + return val if val is not None else self._last_data.get(sensor) + + def total_sensor_value(self, sensor: str) -> Any: + """Answer current value of the 'total' (never 0) sensor.""" + val = self.data.get(sensor) + return val if val else self._last_data.get(sensor) + + def reset_sensor(self, sensor: str) -> None: + """Reset sensor value to 0. + + Intended for "daily" cumulative sensors (e.g. PV energy produced today), + which should be explicitly reset to 0 at midnight if inverter is suspended. + """ + self._last_data[sensor] = 0 + self.data[sensor] = 0 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 2fbf56624c..d76d620283 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import date, datetime, timedelta from decimal import Decimal import logging -from typing import Any, cast +from typing import Any from goodwe import Inverter, Sensor, SensorKind @@ -19,26 +19,27 @@ 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 from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity import homeassistant.util.dt as dt_util from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE_INFO, KEY_INVERTER +from .coordinator import GoodweUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -78,10 +79,12 @@ _ICONS: dict[SensorKind, str] = { class GoodweSensorEntityDescription(SensorEntityDescription): """Class describing Goodwe sensor entities.""" - value: Callable[[Any, Any], Any] = lambda prev, val: val + value: Callable[ + [GoodweUpdateCoordinator, str], Any + ] = lambda coordinator, sensor: coordinator.sensor_value(sensor) available: Callable[ - [CoordinatorEntity], bool - ] = lambda entity: entity.coordinator.last_update_success + [GoodweUpdateCoordinator], bool + ] = lambda coordinator: coordinator.last_update_success _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { @@ -108,8 +111,22 @@ _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda prev, val: val if val else prev, - available=lambda entity: entity.coordinator.data is not None, + 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", @@ -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, @@ -159,12 +183,14 @@ async def async_setup_entry( async_add_entities(entities) -class InverterSensor(CoordinatorEntity, SensorEntity): +class InverterSensor(CoordinatorEntity[GoodweUpdateCoordinator], SensorEntity): """Entity representing individual inverter sensor.""" + entity_description: GoodweSensorEntityDescription + def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: GoodweUpdateCoordinator, device_info: DeviceInfo, inverter: Inverter, sensor: Sensor, @@ -190,18 +216,12 @@ class InverterSensor(CoordinatorEntity, SensorEntity): if sensor.id_ == BATTERY_SOC: self._attr_device_class = SensorDeviceClass.BATTERY self._sensor = sensor - self._previous_value = None self._stop_reset: Callable[[], None] | None = None @property def native_value(self) -> StateType | date | datetime | Decimal: """Return the value reported by the sensor.""" - value = cast(GoodweSensorEntityDescription, self.entity_description).value( - self._previous_value, - self.coordinator.data.get(self._sensor.id_, self._previous_value), - ) - self._previous_value = value - return value + return self.entity_description.value(self.coordinator, self._sensor.id_) @property def available(self) -> bool: @@ -212,16 +232,18 @@ class InverterSensor(CoordinatorEntity, SensorEntity): as available even when the (non-battery) pv inverter is off-line during night and most of the sensors are actually unavailable. """ - return cast(GoodweSensorEntityDescription, self.entity_description).available( - self - ) + return self.entity_description.available(self.coordinator) @callback def async_reset(self, now): - """Reset the value back to 0 at midnight.""" + """Reset the value back to 0 at midnight. + + Some sensors values like daily produced energy are kept available, + even when the inverter is in sleep mode and no longer responds to request. + In contrast to "total" sensors, these "daily" sensors need to be reset to 0 on midnight. + """ if not self.coordinator.last_update_success: - self._previous_value = 0 - self.coordinator.data[self._sensor.id_] = 0 + self.coordinator.reset_sensor(self._sensor.id) self.async_write_ha_state() _LOGGER.debug("Goodwe reset %s to 0", self.name) next_midnight = dt_util.start_of_local_day( diff --git a/homeassistant/components/google/__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/const.py b/homeassistant/components/google_assistant/const.py index 20c4ab60e8..bf511f8eae 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -161,6 +161,7 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = { (cover.DOMAIN, cover.CoverDeviceClass.GARAGE): TYPE_GARAGE, (cover.DOMAIN, cover.CoverDeviceClass.GATE): TYPE_GARAGE, (cover.DOMAIN, cover.CoverDeviceClass.SHUTTER): TYPE_SHUTTER, + (cover.DOMAIN, cover.CoverDeviceClass.WINDOW): TYPE_WINDOW, ( humidifier.DOMAIN, humidifier.HumidifierDeviceClass.DEHUMIDIFIER, diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 196fa580ea..e194242df9 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -22,7 +22,12 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import Context, HomeAssistant, State, callback -from homeassistant.helpers import area_registry, device_registry, entity_registry, start +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + start, +) from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url from homeassistant.helpers.storage import Store @@ -52,15 +57,11 @@ LOCAL_SDK_MIN_VERSION = AwesomeVersion("2.1.5") @callback def _get_registry_entries( hass: HomeAssistant, entity_id: str -) -> tuple[ - entity_registry.RegistryEntry | None, - device_registry.DeviceEntry | None, - area_registry.AreaEntry | None, -]: +) -> tuple[er.RegistryEntry | None, dr.DeviceEntry | None, ar.AreaEntry | None,]: """Get registry entries.""" - ent_reg = entity_registry.async_get(hass) - dev_reg = device_registry.async_get(hass) - area_reg = area_registry.async_get(hass) + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + area_reg = ar.async_get(hass) if (entity_entry := ent_reg.async_get(entity_id)) and entity_entry.device_id: device_entry = dev_reg.devices.get(entity_entry.device_id) diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 737b54c8b1..109ea61dba 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -139,7 +139,9 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig unsub = async_track_state_change(hass, MATCH_ALL, async_entity_state_listener) - unsub = async_call_later(hass, INITIAL_REPORT_DELAY, initial_report) + unsub = async_call_later( + hass, INITIAL_REPORT_DELAY, HassJob(initial_report, cancel_on_shutdown=True) + ) @callback def unsub_all(): diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index af203906b8..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, @@ -832,7 +831,7 @@ class TemperatureControlTrait(_Trait): "temperatureUnitForUX": _google_temp_unit( self.hass.config.units.temperature_unit ), - "queryOnlyTemperatureSetting": True, + "queryOnlyTemperatureControl": True, "temperatureRange": { "minThresholdCelsius": -100, "maxThresholdCelsius": 100, @@ -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_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 0de580ef7b..89da343381 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -40,11 +40,14 @@ SUPPORTED_LANGUAGES = [ "en-US", "es-ES", "es-US", + "eu-ES", "fi-FI", "fil-PH", "fr-CA", "fr-FR", + "gl-ES", "gu-IN", + "he-IL", "hi-IN", "hu-HU", "id-ID", @@ -54,7 +57,9 @@ SUPPORTED_LANGUAGES = [ "kn-IN", "ko-KR", "lv-LV", + "lt-LT", "ml-IN", + "mr-IN", "ms-MY", "nb-NO", "nl-BE", diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py new file mode 100644 index 0000000000..3d0fac6342 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -0,0 +1,157 @@ +"""The Google Generative AI Conversation integration.""" +from __future__ import annotations + +from functools import partial +import logging +from typing import Literal + +from google.api_core.exceptions import ClientError +import google.generativeai as palm +from google.generativeai.types.discuss_types import ChatResponse + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, TemplateError +from homeassistant.helpers import intent, template +from homeassistant.util import ulid + +from .const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + CONF_TEMPERATURE, + CONF_TOP_K, + CONF_TOP_P, + DEFAULT_CHAT_MODEL, + DEFAULT_PROMPT, + DEFAULT_TEMPERATURE, + DEFAULT_TOP_K, + DEFAULT_TOP_P, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Google Generative AI Conversation from a config entry.""" + palm.configure(api_key=entry.data[CONF_API_KEY]) + + try: + await hass.async_add_executor_job( + partial( + palm.get_model, entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) + ) + ) + except ClientError as err: + if err.reason == "API_KEY_INVALID": + _LOGGER.error("Invalid API key: %s", err) + return False + raise ConfigEntryNotReady(err) from err + + conversation.async_set_agent(hass, entry, GoogleGenerativeAIAgent(hass, entry)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload GoogleGenerativeAI.""" + palm.configure(api_key=None) + conversation.async_unset_agent(hass, entry) + return True + + +class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): + """Google Generative AI conversation agent.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the agent.""" + self.hass = hass + self.entry = entry + self.history: dict[str, list[dict]] = {} + + @property + def attribution(self): + """Return the attribution.""" + return { + "name": "Powered by Google Generative AI", + "url": "https://developers.generativeai.google/", + } + + @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_CHAT_MODEL, DEFAULT_CHAT_MODEL) + temperature = self.entry.options.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) + top_p = self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P) + top_k = self.entry.options.get(CONF_TOP_K, DEFAULT_TOP_K) + + if user_input.conversation_id in self.history: + conversation_id = user_input.conversation_id + messages = self.history[conversation_id] + else: + conversation_id = ulid.ulid() + messages = [] + + try: + prompt = self._async_generate_prompt(raw_prompt) + except TemplateError as err: + _LOGGER.error("Error rendering prompt: %s", err) + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + f"Sorry, I had a problem with my template: {err}", + ) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) + + messages.append({"author": "0", "content": user_input.text}) + + _LOGGER.debug("Prompt for %s: %s", model, messages) + + try: + chat_response: ChatResponse = await palm.chat_async( + model=model, + context=prompt, + messages=messages, + temperature=temperature, + top_p=top_p, + top_k=top_k, + ) + except ClientError as err: + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + f"Sorry, I had a problem talking to Google Generative AI: {err}", + ) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) + + _LOGGER.debug("Response %s", chat_response) + # For some queries the response is empty. In that case don't update history to avoid + # "google.generativeai.types.discuss_types.AuthorError: Authors are not strictly alternating" + if chat_response.last: + self.history[conversation_id] = chat_response.messages + + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_speech(chat_response.last) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) + + def _async_generate_prompt(self, raw_prompt: str) -> str: + """Generate a prompt for the user.""" + return template.Template(raw_prompt, self.hass).async_render( + { + "ha_name": self.hass.config.location_name, + }, + parse_result=False, + ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py new file mode 100644 index 0000000000..94639177a4 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -0,0 +1,165 @@ +"""Config flow for Google Generative AI Conversation integration.""" +from __future__ import annotations + +from functools import partial +import logging +import types +from types import MappingProxyType +from typing import Any + +from google.api_core.exceptions import ClientError +import google.generativeai as palm +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + TemplateSelector, +) + +from .const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + CONF_TEMPERATURE, + CONF_TOP_K, + CONF_TOP_P, + DEFAULT_CHAT_MODEL, + DEFAULT_PROMPT, + DEFAULT_TEMPERATURE, + DEFAULT_TOP_K, + DEFAULT_TOP_P, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } +) + +DEFAULT_OPTIONS = types.MappingProxyType( + { + CONF_PROMPT: DEFAULT_PROMPT, + CONF_CHAT_MODEL: DEFAULT_CHAT_MODEL, + CONF_TEMPERATURE: DEFAULT_TEMPERATURE, + CONF_TOP_P: DEFAULT_TOP_P, + CONF_TOP_K: DEFAULT_TOP_K, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + palm.configure(api_key=data[CONF_API_KEY]) + await hass.async_add_executor_job(partial(palm.list_models)) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Google Generative AI Conversation.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + await validate_input(self.hass, user_input) + except ClientError as err: + if err.reason == "API_KEY_INVALID": + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="Google Generative AI Conversation", data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + @staticmethod + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return OptionsFlow(config_entry) + + +class OptionsFlow(config_entries.OptionsFlow): + """Google Generative AI config flow options handler.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry( + title="Google Generative AI Conversation", data=user_input + ) + schema = google_generative_ai_config_option_schema(self.config_entry.options) + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(schema), + ) + + +def google_generative_ai_config_option_schema( + options: MappingProxyType[str, Any] +) -> dict: + """Return a schema for Google Generative AI completion options.""" + if not options: + options = DEFAULT_OPTIONS + return { + vol.Optional( + CONF_PROMPT, + description={"suggested_value": options[CONF_PROMPT]}, + default=DEFAULT_PROMPT, + ): TemplateSelector(), + vol.Optional( + CONF_CHAT_MODEL, + description={ + "suggested_value": options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) + }, + default=DEFAULT_CHAT_MODEL, + ): str, + vol.Optional( + CONF_TEMPERATURE, + description={"suggested_value": options[CONF_TEMPERATURE]}, + default=DEFAULT_TEMPERATURE, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + 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_TOP_K, + description={"suggested_value": options[CONF_TOP_K]}, + default=DEFAULT_TOP_K, + ): int, + } diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py new file mode 100644 index 0000000000..9664552e43 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -0,0 +1,33 @@ +"""Constants for the Google Generative AI Conversation integration.""" + +DOMAIN = "google_generative_ai_conversation" +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() %} + {%- set area_info = namespace(printed=false) %} + {%- 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) }}: + {%- 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 %} + {%- endif %} + {%- endfor %} +{%- endfor %} + +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. +""" +CONF_CHAT_MODEL = "chat_model" +DEFAULT_CHAT_MODEL = "models/chat-bison-001" +CONF_TEMPERATURE = "temperature" +DEFAULT_TEMPERATURE = 0.25 +CONF_TOP_P = "top_p" +DEFAULT_TOP_P = 0.95 +CONF_TOP_K = "top_k" +DEFAULT_TOP_K = 40 diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json new file mode 100644 index 0000000000..52de921553 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "google_generative_ai_conversation", + "name": "Google Generative AI Conversation", + "codeowners": ["@tronikos"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", + "integration_type": "service", + "iot_class": "cloud_polling", + "requirements": ["google-generativeai==0.1.0rc2"] +} diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json new file mode 100644 index 0000000000..2df5398222 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "prompt": "Prompt Template", + "model": "Model", + "temperature": "Temperature", + "top_p": "Top P", + "top_k": "Top K" + } + } + } + } +} 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_mail/sensor.py b/homeassistant/components/google_mail/sensor.py index c30ea1c0a6..8023b9222a 100644 --- a/homeassistant/components/google_mail/sensor.py +++ b/homeassistant/components/google_mail/sensor.py @@ -43,10 +43,10 @@ class GoogleMailSensor(GoogleMailEntity, SensorEntity): """Get the vacation data.""" service = await self.auth.get_resource() settings: HttpRequest = service.users().settings().getVacation(userId="me") - data = await self.hass.async_add_executor_job(settings.execute) + data: dict = await self.hass.async_add_executor_job(settings.execute) - if data["enableAutoReply"]: - value = datetime.fromtimestamp(int(data["endTime"]) / 1000, tz=timezone.utc) + if data["enableAutoReply"] and (end := data.get("endTime")): + value = datetime.fromtimestamp(int(end) / 1000, tz=timezone.utc) else: value = None self._attr_native_value = value diff --git a/homeassistant/components/google_maps/manifest.json b/homeassistant/components/google_maps/manifest.json index 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/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index a452d32e54..317f2619be 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -8,7 +8,7 @@ from homeassistant.const import ( ATTR_LONGITUDE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -44,7 +44,7 @@ async def async_setup_entry( ] = async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) # Restore previously loaded devices - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_ids = { identifier[1] for device in dev_reg.devices.values() 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/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index 815e3b76f0..112b111bdc 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -1,4 +1,4 @@ -"""This platform allows several binary sensor to be grouped into one binary sensor.""" +"""Platform allowing several binary sensor to be grouped into one binary sensor.""" from __future__ import annotations import voluptuous as vol 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/cover.py b/homeassistant/components/group/cover.py index 2ecfbeaca4..38928302eb 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -1,4 +1,4 @@ -"""This platform allows several cover to be grouped into one cover.""" +"""Platform allowing several cover to be grouped into one cover.""" from __future__ import annotations from typing import Any diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 682890dddd..0c4c59d245 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -1,4 +1,4 @@ -"""This platform allows several fans to be grouped into one fan.""" +"""Platform allowing several fans to be grouped into one fan.""" from __future__ import annotations from functools import reduce diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 6315e79d61..33d240a9a4 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -1,4 +1,4 @@ -"""This platform allows several lights to be grouped into one light.""" +"""Platform allowing several lights to be grouped into one light.""" from __future__ import annotations from collections import Counter diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index 9c39e14552..07d08c7851 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -1,4 +1,4 @@ -"""This platform allows several locks to be grouped into one lock.""" +"""Platform allowing several locks to be grouped into one lock.""" from __future__ import annotations import logging diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index a349a62800..15be22ddfb 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -1,6 +1,7 @@ -"""This platform allows several media players to be grouped into one media player.""" +"""Platform allowing several media players to be grouped into one media player.""" from __future__ import annotations +from collections.abc import Mapping from contextlib import suppress from typing import Any @@ -20,6 +21,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -207,7 +209,7 @@ class MediaPlayerGroup(MediaPlayerEntity): return self._name @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the state attributes for the media group.""" return {ATTR_ENTITY_ID: self._entities} @@ -298,7 +300,7 @@ class MediaPlayerGroup(MediaPlayerEntity): ) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" data = { 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 6c379832ce..4c6e8dccc1 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -1,4 +1,4 @@ -"""This platform allows several sensors to be grouped into one sensor to provide numeric combinations.""" +"""Platform allowing several sensors to be grouped into one sensor to provide numeric combinations.""" from __future__ import annotations from collections.abc import Callable @@ -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 75a2423d93..9f50545468 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -155,18 +155,26 @@ } } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]", - "home": "[%key:component::device_tracker::state::_::home%]", - "not_home": "[%key:component::device_tracker::state::_::not_home%]", - "open": "[%key:common::state::open%]", - "closed": "[%key:common::state::closed%]", - "locked": "[%key:common::state::locked%]", - "unlocked": "[%key:common::state::unlocked%]", - "ok": "[%key:component::binary_sensor::state::problem::off%]", - "problem": "[%key:component::binary_sensor::state::problem::on%]" + "name": "[%key:component::group::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "home": "[%key:component::device_tracker::entity_component::_::state::home%]", + "not_home": "[%key:component::device_tracker::entity_component::_::state::not_home%]", + "open": "[%key:common::state::open%]", + "closed": "[%key:common::state::closed%]", + "locked": "[%key:common::state::locked%]", + "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/group/switch.py b/homeassistant/components/group/switch.py index 8b60e1f140..4b6b959ba1 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -1,4 +1,4 @@ -"""This platform allows several switches to be grouped into one switch.""" +"""Platform allowing several switches to be grouped into one switch.""" from __future__ import annotations import logging diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py index cb6e6cee72..cb221d4941 100644 --- a/homeassistant/components/gstreamer/media_player.py +++ b/homeassistant/components/gstreamer/media_player.py @@ -100,7 +100,7 @@ class GstreamerDevice(MediaPlayerEntity): self._player.volume = volume async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play media.""" # Handle media_source @@ -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 3a79d8d88a..9fac4d0192 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -342,12 +342,14 @@ def get_next_departure( origin_stop_time.departure_time LIMIT :limit """ - result = schedule.engine.execute( + result = schedule.engine.connect().execute( text(sql_query), - origin_station_id=start_station_id, - end_station_id=end_station_id, - today=now_date, - limit=limit, + { + "origin_station_id": start_station_id, + "end_station_id": end_station_id, + "today": now_date, + "limit": limit, + }, ) # Create lookup timetable for today and possibly tomorrow, taking into @@ -357,7 +359,8 @@ def get_next_departure( yesterday_start = today_start = tomorrow_start = None yesterday_last = today_last = "" - for row in result: + for row_cursor in result: + row = row_cursor._asdict() if row["yesterday"] == 1 and yesterday_date >= row["start_date"]: extras = {"day": "yesterday", "first": None, "last": False} if yesterday_start is None: @@ -565,7 +568,7 @@ class GTFSDepartureSensor(SensorEntity): return self._available @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return self._attributes @@ -607,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 @@ -645,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() @@ -800,7 +808,10 @@ class GTFSDepartureSensor(SensorEntity): @staticmethod def dict_for_table(resource: Any) -> dict: """Return a dictionary for the SQLAlchemy resource given.""" - return {col: getattr(resource, col) for col in resource.__table__.columns} + _dict = {} + for column in resource.__table__.columns: + _dict[column.name] = str(getattr(resource, column.name)) + return _dict def append_keys(self, resource: dict, prefix: str | None = None) -> None: """Properly format key val pairs to append to attributes.""" diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index 010f65cd11..ff41c6e493 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -12,7 +12,7 @@ from aioguardian.errors import GuardianError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -41,7 +41,7 @@ def async_finish_entity_domain_replacements( entity_replacement_strategies: Iterable[EntityDomainReplacementStrategy], ) -> None: """Remove old entities and create a repairs issue with info on their replacement.""" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) for strategy in entity_replacement_strategies: try: [registry_entry] = [ diff --git a/homeassistant/components/hardkernel/hardware.py b/homeassistant/components/hardkernel/hardware.py index cd83f684ea..3d4a87b040 100644 --- a/homeassistant/components/hardkernel/hardware.py +++ b/homeassistant/components/hardkernel/hardware.py @@ -9,10 +9,11 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN BOARD_NAMES = { - "odroid-c2": "Hardkernel Odroid-C2", - "odroid-c4": "Hardkernel Odroid-C4", - "odroid-n2": "Home Assistant Blue / Hardkernel Odroid-N2", - "odroid-xu4": "Hardkernel Odroid-XU4", + "odroid-c2": "Hardkernel ODROID-C2", + "odroid-c4": "Hardkernel ODROID-C4", + "odroid-m1": "Hardkernel ODROID-M1", + "odroid-n2": "Home Assistant Blue / Hardkernel ODROID-N2/N2+", + "odroid-xu4": "Hardkernel ODROID-XU4", } 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/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index 259ea66031..d861068629 100644 --- a/homeassistant/components/harmony/__init__.py +++ b/homeassistant/components/harmony/__init__.py @@ -5,7 +5,7 @@ from homeassistant.components.remote import ATTR_ACTIVITY, ATTR_DELAY_SECS from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( @@ -60,7 +60,7 @@ async def _migrate_old_unique_ids( names_to_ids = {activity["label"]: activity["id"] for activity in data.activities} @callback - def _async_migrator(entity_entry: entity_registry.RegistryEntry): + def _async_migrator(entity_entry: er.RegistryEntry): # Old format for switches was {remote_unique_id}-{activity_name} # New format is activity_{activity_id} parts = entity_entry.unique_id.split("-", 1) @@ -78,7 +78,7 @@ async def _migrate_old_unique_ids( return None - await entity_registry.async_migrate_entries(hass, entry_id, _async_migrator) + await er.async_migrate_entries(hass, entry_id, _async_migrator) @callback diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index 2603ee613a..c6a6327046 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -13,7 +13,7 @@ "documentation": "https://www.home-assistant.io/integrations/harmony", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], - "requirements": ["aioharmony==0.2.9"], + "requirements": ["aioharmony==0.2.10"], "ssdp": [ { "manufacturer": "Logitech", diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 4f5d8e9d31..c8f4b69d42 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, @@ -69,8 +70,10 @@ from .const import ( ATTR_VERSION, DATA_KEY_ADDONS, DATA_KEY_CORE, + DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, + DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, SupervisorEntityModel, ) @@ -82,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, @@ -96,7 +102,7 @@ from .handler import ( # noqa: F401 ) from .http import HassIOView from .ingress import async_setup_ingress_view -from .repairs import SupervisorRepairs +from .issues import SupervisorIssues from .websocket_api import async_load_websocket_api _LOGGER = logging.getLogger(__name__) @@ -115,15 +121,16 @@ CONFIG_SCHEMA = vol.Schema( DATA_CORE_INFO = "hassio_core_info" +DATA_CORE_STATS = "hassio_core_stats" DATA_HOST_INFO = "hassio_host_info" DATA_STORE = "hassio_store" DATA_INFO = "hassio_info" DATA_OS_INFO = "hassio_os_info" DATA_SUPERVISOR_INFO = "hassio_supervisor_info" +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_REPAIRS = "supervisor_repairs" HASSIO_UPDATE_INTERVAL = timedelta(minutes=5) ADDONS_COORDINATOR = "hassio_addons_coordinator" @@ -229,6 +236,7 @@ MAP_SERVICE_API = { HARDWARE_INTEGRATIONS = { "odroid-c2": "hardkernel", "odroid-c4": "hardkernel", + "odroid-m1": "hardkernel", "odroid-n2": "hardkernel", "odroid-xu4": "hardkernel", "rpi2": "raspberry_pi", @@ -240,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: @@ -300,6 +313,26 @@ def get_addons_stats(hass): return hass.data.get(DATA_ADDONS_STATS) +@callback +@bind_hass +def get_core_stats(hass): + """Return core stats. + + Async friendly. + """ + return hass.data.get(DATA_CORE_STATS) + + +@callback +@bind_hass +def get_supervisor_stats(hass): + """Return supervisor stats. + + Async friendly. + """ + return hass.data.get(DATA_SUPERVISOR_STATS) + + @callback @bind_hass def get_addons_changelogs(hass): @@ -463,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: @@ -487,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.""" @@ -555,13 +590,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: await async_setup_addon_panel(hass, hassio) # Setup hardware integration for the detected board type - async def _async_setup_hardware_integration(hass): + async def _async_setup_hardware_integration(_: datetime | None = None) -> None: """Set up hardaware integration for the detected board type.""" if (os_info := get_os_info(hass)) is None: # os info not yet fetched from supervisor, retry later async_track_point_in_utc_time( hass, - _async_setup_hardware_integration, + async_setup_hardware_integration_job, utcnow() + HASSIO_UPDATE_INTERVAL, ) return @@ -575,15 +610,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) ) - await _async_setup_hardware_integration(hass) + async_setup_hardware_integration_job = HassJob( + _async_setup_hardware_integration, cancel_on_shutdown=True + ) + + await _async_setup_hardware_integration() hass.async_create_task( hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) ) - # Start listening for problems with supervisor and making repairs - hass.data[DATA_SUPERVISOR_REPAIRS] = repairs = SupervisorRepairs(hass, hassio) - await repairs.setup() + # Start listening for problems with supervisor and making issues + hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio) + await issues.setup() return True @@ -645,6 +684,22 @@ def async_register_os_in_dev_reg( dev_reg.async_get_or_create(config_entry_id=entry_id, **params) +@callback +def async_register_host_in_dev_reg( + entry_id: str, + dev_reg: dr.DeviceRegistry, +) -> None: + """Register host in the device registry.""" + params = DeviceInfo( + identifiers={(DOMAIN, "host")}, + manufacturer="Home Assistant", + model=SupervisorEntityModel.HOST, + name="Home Assistant Host", + entry_type=dr.DeviceEntryType.SERVICE, + ) + dev_reg.async_get_or_create(config_entry_id=entry_id, **params) + + @callback def async_register_core_in_dev_reg( entry_id: str, @@ -746,8 +801,15 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): if self.is_hass_os: new_data[DATA_KEY_OS] = get_os_info(self.hass) - new_data[DATA_KEY_CORE] = get_core_info(self.hass) - new_data[DATA_KEY_SUPERVISOR] = supervisor_info + new_data[DATA_KEY_CORE] = { + **(get_core_info(self.hass) or {}), + **get_core_stats(self.hass), + } + new_data[DATA_KEY_SUPERVISOR] = { + **supervisor_info, + **get_supervisor_stats(self.hass), + } + new_data[DATA_KEY_HOST] = get_host_info(self.hass) or {} # If this is the initial refresh, register all addons and return the dict if not self.data: @@ -760,6 +822,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): async_register_supervisor_in_dev_reg( self.entry_id, self.dev_reg, new_data[DATA_KEY_SUPERVISOR] ) + async_register_host_in_dev_reg(self.entry_id, self.dev_reg) if self.is_hass_os: async_register_os_in_dev_reg( self.entry_id, self.dev_reg, new_data[DATA_KEY_OS] @@ -804,32 +867,38 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): ( self.hass.data[DATA_INFO], self.hass.data[DATA_CORE_INFO], + self.hass.data[DATA_CORE_STATS], self.hass.data[DATA_SUPERVISOR_INFO], + self.hass.data[DATA_SUPERVISOR_STATS], self.hass.data[DATA_OS_INFO], ) = await asyncio.gather( self.hassio.get_info(), self.hassio.get_core_info(), + self.hassio.get_core_stats(), self.hassio.get_supervisor_info(), + self.hassio.get_supervisor_stats(), self.hassio.get_os_info(), ) - 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/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index d469b66b9e..b2cf0040be 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -30,7 +30,11 @@ async def async_setup_addon_panel(hass: HomeAssistant, hassio): for addon, data in panels.items(): if not data[ATTR_ENABLE]: continue - jobs.append(_register_panel(hass, addon, data)) + jobs.append( + asyncio.create_task( + _register_panel(hass, addon, data), name=f"register panel {addon}" + ) + ) if jobs: await asyncio.wait(jobs) 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 64ef7a718a..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" @@ -36,6 +38,7 @@ X_AUTH_TOKEN = "X-Supervisor-Token" X_INGRESS_PATH = "X-Ingress-Path" X_HASS_USER_ID = "X-Hass-User-ID" X_HASS_IS_ADMIN = "X-Hass-Is-Admin" +X_HASS_SOURCE = "X-Hass-Source" WS_TYPE = "type" WS_ID = "id" @@ -48,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" @@ -67,6 +72,10 @@ DATA_KEY_ADDONS = "addons" 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): @@ -76,3 +85,4 @@ class SupervisorEntityModel(str, Enum): OS = "Home Assistant Operating System" CORE = "Home Assistant Core" SUPERVIOSR = "Home Assistant Supervisor" + HOST = "Home Assistant Host" diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 6d936c6ce2..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) + await self.hass.config_entries.async_remove(entry.entry_id) diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index dfa89ae911..3a6a5a9f7c 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -11,6 +11,7 @@ from .const import ( ATTR_SLUG, DATA_KEY_ADDONS, DATA_KEY_CORE, + DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, ) @@ -71,6 +72,32 @@ class HassioOSEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): ) +class HassioHostEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): + """Base Entity for Hass.io host.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: HassioDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"home_assistant_host_{entity_description.key}" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "host")}) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available + and DATA_KEY_HOST in self.coordinator.data + and self.entity_description.key in self.coordinator.data[DATA_KEY_HOST] + ) + + class HassioSupervisorEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): """Base Entity for Supervisor.""" diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 0d923075bf..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 @@ -17,7 +18,7 @@ from homeassistant.const import SERVER_PORT from homeassistant.core import HomeAssistant from homeassistant.loader import bind_hass -from .const import ATTR_DISCOVERY, DOMAIN +from .const import ATTR_DISCOVERY, DOMAIN, X_HASS_SOURCE _LOGGER = logging.getLogger(__name__) @@ -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.""" @@ -319,6 +363,14 @@ class HassIO: """ return self.send_command(f"/addons/{addon}/info", method="get") + @api_data + def get_core_stats(self): + """Return stats for the core. + + This method returns a coroutine. + """ + return self.send_command("/core/stats", method="get") + @api_data def get_addon_stats(self, addon): """Return stats for an Add-on. @@ -327,6 +379,14 @@ class HassIO: """ return self.send_command(f"/addons/{addon}/stats", method="get") + @api_data + def get_supervisor_stats(self): + """Return stats for the supervisor. + + This method returns a coroutine. + """ + return self.send_command("/supervisor/stats", method="get") + def get_addon_changelog(self, addon): """Return changelog for an Add-on. @@ -400,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.""" @@ -438,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, @@ -445,6 +523,8 @@ class HassIO: payload=None, timeout=10, return_text=False, + *, + source="core.handler", ): """Send API command to Hass.io. @@ -458,7 +538,8 @@ class HassIO: headers={ aiohttp.hdrs.AUTHORIZATION: ( f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}" - ) + ), + X_HASS_SOURCE: source, }, timeout=aiohttp.ClientTimeout(total=timeout), ) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 2b7145bdca..2480353c2d 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -6,6 +6,7 @@ from http import HTTPStatus import logging import os import re +from urllib.parse import quote, unquote import aiohttp from aiohttp import web @@ -19,38 +20,70 @@ from aiohttp.hdrs import ( TRANSFER_ENCODING, ) from aiohttp.web_exceptions import HTTPBadGateway -from multidict import istr -from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView +from homeassistant.components.http import ( + KEY_AUTHENTICATED, + KEY_HASS_USER, + HomeAssistantView, +) from homeassistant.components.onboarding import async_is_onboarded from homeassistant.core import HomeAssistant -from .const import X_HASS_IS_ADMIN, X_HASS_USER_ID +from .const import X_HASS_SOURCE _LOGGER = logging.getLogger(__name__) MAX_UPLOAD_SIZE = 1024 * 1024 * 1024 -# pylint: disable=implicit-str-concat NO_TIMEOUT = re.compile( r"^(?:" - r"|homeassistant/update" - r"|hassos/update" - r"|hassos/update/cli" - r"|supervisor/update" - r"|addons/[^/]+/(?:update|install|rebuild)" r"|backups/.+/full" r"|backups/.+/partial" r"|backups/[^/]+/(?:upload|download)" r")$" ) -NO_AUTH_ONBOARDING = re.compile(r"^(?:" r"|supervisor/logs" r"|backups/[^/]+/.+" r")$") +# fmt: off +# Onboarding can upload backups and restore it +PATHS_NOT_ONBOARDED = re.compile( + r"^(?:" + r"|backups/[a-f0-9]{8}(/info|/new/upload|/download|/restore/full|/restore/partial)?" + r"|backups/new/upload" + r")$" +) -NO_AUTH = re.compile(r"^(?:" r"|app/.*" r"|[store\/]*addons/[^/]+/(logo|icon)" r")$") +# Authenticated users manage backups + download logs, changelog and documentation +PATHS_ADMIN = re.compile( + r"^(?:" + r"|backups/[a-f0-9]{8}(/info|/download|/restore/full|/restore/partial)?" + r"|backups/new/upload" + r"|audio/logs" + r"|cli/logs" + r"|core/logs" + r"|dns/logs" + r"|host/logs" + r"|multicast/logs" + r"|observer/logs" + r"|supervisor/logs" + r"|addons/[^/]+/(changelog|documentation|logs)" + r")$" +) -NO_STORE = re.compile(r"^(?:" r"|app/entrypoint.js" r")$") +# Unauthenticated requests come in for Supervisor panel + add-on images +PATHS_NO_AUTH = re.compile( + r"^(?:" + r"|app/.*" + r"|(store/)?addons/[^/]+/(logo|icon)" + r")$" +) + +NO_STORE = re.compile( + r"^(?:" + r"|app/entrypoint.js" + r")$" +) # pylint: enable=implicit-str-concat +# fmt: on class HassIOView(HomeAssistantView): @@ -65,38 +98,66 @@ class HassIOView(HomeAssistantView): self._host = host self._websession = websession - async def _handle( - self, request: web.Request, path: str - ) -> web.Response | web.StreamResponse: - """Route data to Hass.io.""" - hass = request.app["hass"] - if _need_auth(hass, path) and not request[KEY_AUTHENTICATED]: - return web.Response(status=HTTPStatus.UNAUTHORIZED) - - return await self._command_proxy(path, request) - - delete = _handle - get = _handle - post = _handle - - async def _command_proxy( - self, path: str, request: web.Request - ) -> web.StreamResponse: + async def _handle(self, request: web.Request, path: str) -> web.StreamResponse: """Return a client request with proxy origin for Hass.io supervisor. - This method is a coroutine. + Use cases: + - Onboarding allows restoring backups + - Load Supervisor panel and add-on logo unauthenticated + - User upload/restore backups """ - headers = _init_header(request) - if path == "backups/new/upload": - # We need to reuse the full content type that includes the boundary - headers[ - CONTENT_TYPE - ] = request._stored_content_type # pylint: disable=protected-access + # No bullshit + if path != unquote(path): + return web.Response(status=HTTPStatus.BAD_REQUEST) + + hass: HomeAssistant = request.app["hass"] + is_admin = request[KEY_AUTHENTICATED] and request[KEY_HASS_USER].is_admin + authorized = is_admin + + if is_admin: + allowed_paths = PATHS_ADMIN + + elif not async_is_onboarded(hass): + allowed_paths = PATHS_NOT_ONBOARDED + + # During onboarding we need the user to manage backups + authorized = True + + else: + # Either unauthenticated or not an admin + allowed_paths = PATHS_NO_AUTH + + no_auth_path = PATHS_NO_AUTH.match(path) + headers = { + X_HASS_SOURCE: "core.http", + } + + if no_auth_path: + if request.method != "GET": + return web.Response(status=HTTPStatus.METHOD_NOT_ALLOWED) + + else: + if not allowed_paths.match(path): + return web.Response(status=HTTPStatus.UNAUTHORIZED) + + if authorized: + headers[ + AUTHORIZATION + ] = f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}" + + if request.method == "POST": + headers[CONTENT_TYPE] = request.content_type + # _stored_content_type is only computed once `content_type` is accessed + if path == "backups/new/upload": + # We need to reuse the full content type that includes the boundary + headers[ + CONTENT_TYPE + ] = request._stored_content_type # pylint: disable=protected-access try: client = await self._websession.request( method=request.method, - url=f"http://{self._host}/{path}", + url=f"http://{self._host}/{quote(path)}", params=request.query, data=request.content, headers=headers, @@ -123,20 +184,8 @@ class HassIOView(HomeAssistantView): raise HTTPBadGateway() - -def _init_header(request: web.Request) -> dict[istr, str]: - """Create initial header.""" - headers = { - AUTHORIZATION: f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}", - CONTENT_TYPE: request.content_type, - } - - # Add user data - if request.get("hass_user") is not None: - headers[istr(X_HASS_USER_ID)] = request["hass_user"].id - headers[istr(X_HASS_IS_ADMIN)] = str(int(request["hass_user"].is_admin)) - - return headers + get = _handle + post = _handle def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]: @@ -164,12 +213,3 @@ def _get_timeout(path: str) -> ClientTimeout: if NO_TIMEOUT.match(path): return ClientTimeout(connect=10, total=None) return ClientTimeout(connect=10, total=300) - - -def _need_auth(hass: HomeAssistant, path: str) -> bool: - """Return if a path need authentication.""" - if not async_is_onboarded(hass) and NO_AUTH_ONBOARDING.match(path): - return False - if NO_AUTH.match(path): - return False - return True diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index dceff75bca..fc92e9309a 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -3,20 +3,22 @@ from __future__ import annotations import asyncio from collections.abc import Iterable +from functools import lru_cache from ipaddress import ip_address import logging -import os +from urllib.parse import quote import aiohttp from aiohttp import ClientTimeout, hdrs, web from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest from multidict import CIMultiDict +from yarl import URL from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import X_AUTH_TOKEN, X_INGRESS_PATH +from .const import X_HASS_SOURCE, X_INGRESS_PATH _LOGGER = logging.getLogger(__name__) @@ -42,9 +44,19 @@ class HassIOIngress(HomeAssistantView): self._host = host self._websession = websession + @lru_cache def _create_url(self, token: str, path: str) -> str: """Create URL to service.""" - return f"http://{self._host}/ingress/{token}/{path}" + base_path = f"/ingress/{token}/" + url = f"http://{self._host}{base_path}{quote(path)}" + + try: + if not URL(url).path.startswith(base_path): + raise HTTPBadRequest() + except ValueError as err: + raise HTTPBadRequest() from err + + return url async def _handle( self, request: web.Request, token: str, path: str @@ -107,8 +119,8 @@ class HassIOIngress(HomeAssistantView): # Proxy requests await asyncio.wait( [ - _websocket_forward(ws_server, ws_client), - _websocket_forward(ws_client, ws_server), + asyncio.create_task(_websocket_forward(ws_server, ws_client)), + asyncio.create_task(_websocket_forward(ws_client, ws_server)), ], return_when=asyncio.FIRST_COMPLETED, ) @@ -185,10 +197,8 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st continue headers[name] = value - # Inject token / cleanup later on Supervisor - headers[X_AUTH_TOKEN] = os.environ.get("SUPERVISOR_TOKEN", "") - # Ingress information + headers[X_HASS_SOURCE] = "core.ingress" headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}" # Set X-Forwarded-For diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py new file mode 100644 index 0000000000..ac6af7f348 --- /dev/null +++ b/homeassistant/components/hassio/issues.py @@ -0,0 +1,354 @@ +"""Supervisor events monitor.""" +from __future__ import annotations + +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 +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) + +from .const import ( + ATTR_DATA, + ATTR_HEALTHY, + ATTR_ISSUES, + ATTR_SUGGESTIONS, + ATTR_SUPPORTED, + ATTR_UNHEALTHY, + ATTR_UNHEALTHY_REASONS, + ATTR_UNSUPPORTED, + ATTR_UNSUPPORTED_REASONS, + ATTR_UPDATE_KEY, + 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, 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", + "content_trust", + "dbus", + "dns_server", + "docker_configuration", + "docker_version", + "cgroup_version", + "job_conditions", + "lxc", + "network_manager", + "os", + "os_agent", + "restart_policy", + "software", + "source_mods", + "supervisor_version", + "systemd", + "systemd_journal", + "systemd_resolved", +} +# Some unsupported reasons also mark the system as unhealthy. If the unsupported reason +# provides no additional information beyond the unhealthy one then skip that repair. +UNSUPPORTED_SKIP_REPAIR = {"privileged"} +UNHEALTHY_REASONS = { + "docker", + "supervisor", + "setup", + "privileged", + "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.""" + + def __init__(self, hass: HomeAssistant, client: HassIO) -> None: + """Initialize supervisor issues.""" + self._hass = hass + 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]: + """Get unhealthy reasons. Returns empty set if system is healthy.""" + return self._unhealthy_reasons + + @unhealthy_reasons.setter + def unhealthy_reasons(self, reasons: set[str]) -> None: + """Set unhealthy reasons. Create or delete repairs as necessary.""" + for unhealthy in reasons - self.unhealthy_reasons: + if unhealthy in UNHEALTHY_REASONS: + translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}" + translation_placeholders = None + else: + translation_key = ISSUE_KEY_UNHEALTHY + translation_placeholders = {PLACEHOLDER_KEY_REASON: unhealthy} + + async_create_issue( + self._hass, + DOMAIN, + f"{ISSUE_ID_UNHEALTHY}_{unhealthy}", + is_fixable=False, + learn_more_url=f"{INFO_URL_UNHEALTHY}/{unhealthy}", + severity=IssueSeverity.CRITICAL, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + + for fixed in self.unhealthy_reasons - reasons: + async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNHEALTHY}_{fixed}") + + self._unhealthy_reasons = reasons + + @property + def unsupported_reasons(self) -> set[str]: + """Get unsupported reasons. Returns empty set if system is supported.""" + return self._unsupported_reasons + + @unsupported_reasons.setter + def unsupported_reasons(self, reasons: set[str]) -> None: + """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"{ISSUE_KEY_UNSUPPORTED}_{unsupported}" + translation_placeholders = None + else: + translation_key = ISSUE_KEY_UNSUPPORTED + translation_placeholders = {PLACEHOLDER_KEY_REASON: unsupported} + + async_create_issue( + self._hass, + DOMAIN, + f"{ISSUE_ID_UNSUPPORTED}_{unsupported}", + is_fixable=False, + learn_more_url=f"{INFO_URL_UNSUPPORTED}/{unsupported}", + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + + for fixed in self.unsupported_reasons - (reasons - UNSUPPORTED_SKIP_REPAIR): + async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNSUPPORTED}_{fixed}") + + 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() + + async_dispatcher_connect( + self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues + ) + + async def update(self) -> None: + """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.""" + if ATTR_WS_EVENT not in event: + return + + if ( + event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE + and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR + ): + self._hass.async_create_task(self.update()) + + elif event[ATTR_WS_EVENT] == EVENT_HEALTH_CHANGED: + self.unhealthy_reasons = ( + set() + if event[ATTR_DATA][ATTR_HEALTHY] + else set(event[ATTR_DATA][ATTR_UNHEALTHY_REASONS]) + ) + + elif event[ATTR_WS_EVENT] == EVENT_SUPPORTED_CHANGED: + self.unsupported_reasons = ( + set() + 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/manifest.json b/homeassistant/components/hassio/manifest.json index bbc50fe7a5..70fc024c00 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -1,7 +1,6 @@ { "domain": "hassio", "name": "Home Assistant Supervisor", - "after_dependencies": ["panel_custom"], "codeowners": ["@home-assistant/supervisor"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/hassio", diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 21120d8d52..50a9b087a7 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -1,185 +1,122 @@ -"""Supervisor events monitor.""" -from __future__ import annotations +"""Repairs implementation for supervisor integration.""" +from collections.abc import Callable +from types import MethodType from typing import Any -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) +import voluptuous as vol -from .const import ( - ATTR_DATA, - ATTR_HEALTHY, - ATTR_SUPPORTED, - ATTR_UNHEALTHY, - ATTR_UNHEALTHY_REASONS, - ATTR_UNSUPPORTED, - ATTR_UNSUPPORTED_REASONS, - ATTR_UPDATE_KEY, - ATTR_WS_EVENT, - DOMAIN, - EVENT_HEALTH_CHANGED, - EVENT_SUPERVISOR_EVENT, - EVENT_SUPERVISOR_UPDATE, - EVENT_SUPPORTED_CHANGED, - UPDATE_KEY_SUPERVISOR, -) -from .handler import HassIO +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult -ISSUE_ID_UNHEALTHY = "unhealthy_system" -ISSUE_ID_UNSUPPORTED = "unsupported_system" +from .const import DATA_KEY_SUPERVISOR_ISSUES, PLACEHOLDER_KEY_REFERENCE +from .handler import HassioAPIError, async_apply_suggestion +from .issues import Issue, Suggestion, SupervisorIssues -INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy" -INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported" - -UNSUPPORTED_REASONS = { - "apparmor", - "connectivity_check", - "content_trust", - "dbus", - "dns_server", - "docker_configuration", - "docker_version", - "cgroup_version", - "job_conditions", - "lxc", - "network_manager", - "os", - "os_agent", - "restart_policy", - "software", - "source_mods", - "supervisor_version", - "systemd", - "systemd_journal", - "systemd_resolved", -} -# Some unsupported reasons also mark the system as unhealthy. If the unsupported reason -# provides no additional information beyond the unhealthy one then skip that repair. -UNSUPPORTED_SKIP_REPAIR = {"privileged"} -UNHEALTHY_REASONS = { - "docker", - "supervisor", - "setup", - "privileged", - "untrusted", -} +SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"} -class SupervisorRepairs: - """Create repairs from supervisor events.""" +class SupervisorIssueRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" - def __init__(self, hass: HomeAssistant, client: HassIO) -> None: - """Initialize supervisor repairs.""" - self._hass = hass - self._client = client - self._unsupported_reasons: set[str] = set() - self._unhealthy_reasons: set[str] = set() + _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 unhealthy_reasons(self) -> set[str]: - """Get unhealthy reasons. Returns empty set if system is healthy.""" - return self._unhealthy_reasons + 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) - @unhealthy_reasons.setter - def unhealthy_reasons(self, reasons: set[str]) -> None: - """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_placeholders = None - else: - translation_key = "unhealthy" - translation_placeholders = {"reason": unhealthy} - - async_create_issue( - self._hass, - DOMAIN, - f"{ISSUE_ID_UNHEALTHY}_{unhealthy}", - is_fixable=False, - learn_more_url=f"{INFO_URL_UNHEALTHY}/{unhealthy}", - severity=IssueSeverity.CRITICAL, - translation_key=translation_key, - translation_placeholders=translation_placeholders, - ) - - for fixed in self.unhealthy_reasons - reasons: - async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNHEALTHY}_{fixed}") - - self._unhealthy_reasons = reasons + return self._issue @property - def unsupported_reasons(self) -> set[str]: - """Get unsupported reasons. Returns empty set if system is supported.""" - return self._unsupported_reasons - - @unsupported_reasons.setter - def unsupported_reasons(self, reasons: set[str]) -> None: - """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_placeholders = None - else: - translation_key = "unsupported" - translation_placeholders = {"reason": unsupported} - - async_create_issue( - self._hass, - DOMAIN, - f"{ISSUE_ID_UNSUPPORTED}_{unsupported}", - is_fixable=False, - learn_more_url=f"{INFO_URL_UNSUPPORTED}/{unsupported}", - severity=IssueSeverity.WARNING, - translation_key=translation_key, - translation_placeholders=translation_placeholders, - ) - - for fixed in self.unsupported_reasons - (reasons - UNSUPPORTED_SKIP_REPAIR): - async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNSUPPORTED}_{fixed}") - - self._unsupported_reasons = reasons - - async def setup(self) -> None: - """Create supervisor events listener.""" - await self.update() - - async_dispatcher_connect( - self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_repairs + 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 ) - async def update(self) -> None: - """Update repairs 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]) + 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, + ) - @callback - def _supervisor_events_to_repairs(self, event: dict[str, Any]) -> None: - """Create repairs from supervisor events.""" - if ATTR_WS_EVENT not in event: - return + 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={}) - if ( - event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE - and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR - ): - self._hass.async_create_task(self.update()) - - elif event[ATTR_WS_EVENT] == EVENT_HEALTH_CHANGED: - self.unhealthy_reasons = ( - set() - if event[ATTR_DATA][ATTR_HEALTHY] - else set(event[ATTR_DATA][ATTR_UNHEALTHY_REASONS]) + # 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), ) - elif event[ATTR_WS_EVENT] == EVENT_SUPPORTED_CHANGED: - self.unsupported_reasons = ( - set() - if event[ATTR_DATA][ATTR_SUPPORTED] - else set(event[ATTR_DATA][ATTR_UNSUPPORTED_REASONS]) + 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 31e728a973..b49433961e 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -2,12 +2,13 @@ from __future__ import annotations from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -18,28 +19,37 @@ from .const import ( ATTR_VERSION, ATTR_VERSION_LATEST, DATA_KEY_ADDONS, + DATA_KEY_CORE, + DATA_KEY_HOST, DATA_KEY_OS, + DATA_KEY_SUPERVISOR, +) +from .entity import ( + HassioAddonEntity, + HassioCoreEntity, + HassioHostEntity, + HassioOSEntity, + HassioSupervisorEntity, ) -from .entity import HassioAddonEntity, HassioOSEntity 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", ), ) -ADDON_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + ( +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, @@ -47,14 +57,56 @@ ADDON_ENTITY_DESCRIPTIONS = COMMON_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, ), ) +ADDON_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + STATS_ENTITY_DESCRIPTIONS +CORE_ENTITY_DESCRIPTIONS = STATS_ENTITY_DESCRIPTIONS OS_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS +SUPERVISOR_ENTITY_DESCRIPTIONS = STATS_ENTITY_DESCRIPTIONS + +HOST_ENTITY_DESCRIPTIONS = ( + SensorEntityDescription( + entity_registry_enabled_default=False, + key="agent_version", + translation_key="agent_version", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key="apparmor_version", + translation_key="apparmor_version", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key="disk_total", + translation_key="disk_total", + native_unit_of_measurement=UnitOfInformation.GIGABYTES, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key="disk_used", + translation_key="disk_used", + native_unit_of_measurement=UnitOfInformation.GIGABYTES, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key="disk_free", + translation_key="disk_free", + native_unit_of_measurement=UnitOfInformation.GIGABYTES, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) async def async_setup_entry( @@ -65,7 +117,9 @@ async def async_setup_entry( """Sensor set up for Hass.io config entry.""" coordinator = hass.data[ADDONS_COORDINATOR] - entities: list[HassioOSSensor | HassioAddonSensor] = [] + entities: list[ + HassioOSSensor | HassioAddonSensor | CoreSensor | SupervisorSensor | HostSensor + ] = [] for addon in coordinator.data[DATA_KEY_ADDONS].values(): for entity_description in ADDON_ENTITY_DESCRIPTIONS: @@ -77,6 +131,30 @@ async def async_setup_entry( ) ) + for entity_description in CORE_ENTITY_DESCRIPTIONS: + entities.append( + CoreSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + ) + + for entity_description in SUPERVISOR_ENTITY_DESCRIPTIONS: + entities.append( + SupervisorSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + ) + + for entity_description in HOST_ENTITY_DESCRIPTIONS: + entities.append( + HostSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + ) + if coordinator.is_hass_os: for entity_description in OS_ENTITY_DESCRIPTIONS: entities.append( @@ -107,3 +185,30 @@ class HassioOSSensor(HassioOSEntity, SensorEntity): def native_value(self) -> str: """Return native value of entity.""" return self.coordinator.data[DATA_KEY_OS][self.entity_description.key] + + +class CoreSensor(HassioCoreEntity, SensorEntity): + """Sensor to track a core attribute.""" + + @property + def native_value(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_CORE][self.entity_description.key] + + +class SupervisorSensor(HassioSupervisorEntity, SensorEntity): + """Sensor to track a supervisor attribute.""" + + @property + def native_value(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_SUPERVISOR][self.entity_description.key] + + +class HostSensor(HassioHostEntity, SensorEntity): + """Sensor to track a host attribute.""" + + @property + def native_value(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_HOST][self.entity_description.key] 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/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 3670d5ca1f..8a9a145f2d 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -116,6 +116,7 @@ async def websocket_supervisor_api( method=msg[ATTR_METHOD], timeout=msg.get(ATTR_TIMEOUT, 10), payload=msg.get(ATTR_DATA, {}), + source="core.websocket_api", ) if result.get(ATTR_RESULT) == "error": diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 5ad8393726..459f03edfb 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -31,7 +31,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.helpers import discovery, event import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -208,16 +208,18 @@ def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: # noqa: C901 def _adapter_watchdog(now=None): _LOGGER.debug("Reached _adapter_watchdog") - event.call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog) + event.call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog_job) if not adapter.initialized: _LOGGER.info("Adapter not initialized; Trying to restart") hass.bus.fire(EVENT_HDMI_CEC_UNAVAILABLE) adapter.init() + _adapter_watchdog_job = HassJob(_adapter_watchdog, cancel_on_shutdown=True) + @callback def _async_initialized_callback(*_: Any): """Add watchdog on initialization.""" - return event.async_call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog) + return event.async_call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog_job) hdmi_network.set_initialized_callback(_async_initialized_callback) diff --git a/homeassistant/components/hdmi_cec/media_player.py b/homeassistant/components/hdmi_cec/media_player.py index 25019ec693..df7df830fd 100644 --- a/homeassistant/components/hdmi_cec/media_player.py +++ b/homeassistant/components/hdmi_cec/media_player.py @@ -30,6 +30,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -105,7 +106,9 @@ class CecPlayerEntity(CecEntity, MediaPlayerEntity): self.send_keypress(KEY_STOP) self._attr_state = MediaPlayerState.IDLE - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Not supported.""" raise NotImplementedError() diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 4184e9f82b..9ad33caf07 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -195,7 +195,7 @@ class HeosMediaPlayer(MediaPlayerEntity): @log_command_error("play media") async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" if media_source.is_media_source_id(media_id): @@ -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 05d6205835..f5b97a7fb1 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -3,8 +3,6 @@ from __future__ import annotations from datetime import datetime as dt, timedelta from http import HTTPStatus -import logging -import time from typing import cast from aiohttp import web @@ -12,39 +10,28 @@ import voluptuous as vol from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView -from homeassistant.components.recorder import ( - DOMAIN as RECORDER_DOMAIN, - get_instance, - history, -) -from homeassistant.components.recorder.filters import ( - Filters, - extract_include_exclude_filter_conf, - merge_include_exclude_filters, - sqlalchemy_filter_from_include_exclude_conf, -) +from homeassistant.components.recorder import get_instance, history from homeassistant.components.recorder.util import session_scope -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE +from homeassistant.core import HomeAssistant, valid_entity_id import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entityfilter import ( - INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, - convert_include_exclude_filter, -) +from homeassistant.helpers.entityfilter import INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from . import websocket_api from .const import DOMAIN from .helpers import entities_may_have_state_changes_after -from .models import HistoryConfig - -_LOGGER = logging.getLogger(__name__) CONF_ORDER = "use_include_order" +_ONE_DAY = timedelta(days=1) + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( + cv.deprecated(CONF_INCLUDE), + cv.deprecated(CONF_EXCLUDE), cv.deprecated(CONF_ORDER), INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend( {vol.Optional(CONF_ORDER, default=False): cv.boolean} @@ -57,23 +44,7 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the history hooks.""" - conf = config.get(DOMAIN, {}) - recorder_conf = config.get(RECORDER_DOMAIN, {}) - history_conf = config.get(DOMAIN, {}) - recorder_filter = extract_include_exclude_filter_conf(recorder_conf) - logbook_filter = extract_include_exclude_filter_conf(history_conf) - merged_filter = merge_include_exclude_filters(recorder_filter, logbook_filter) - - possible_merged_entities_filter = convert_include_exclude_filter(merged_filter) - - sqlalchemy_filter = None - entity_filter = None - if not possible_merged_entities_filter.empty_filter: - sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(conf) - entity_filter = possible_merged_entities_filter - - hass.data[DOMAIN] = HistoryConfig(sqlalchemy_filter, entity_filter) - hass.http.register_view(HistoryPeriodView(sqlalchemy_filter)) + hass.http.register_view(HistoryPeriodView()) frontend.async_register_built_in_panel(hass, "history", "history", "hass:chart-box") websocket_api.async_setup(hass) return True @@ -86,50 +57,54 @@ class HistoryPeriodView(HomeAssistantView): name = "api:history:view-period" extra_urls = ["/api/history/period/{datetime}"] - def __init__(self, filters: Filters | None) -> None: - """Initialize the history period view.""" - self.filters = filters - async def get( self, request: web.Request, datetime: str | None = None ) -> web.Response: """Return history over a period of time.""" datetime_ = None + query = request.query + if datetime and (datetime_ := dt_util.parse_datetime(datetime)) is None: return self.json_message("Invalid datetime", HTTPStatus.BAD_REQUEST) - now = dt_util.utcnow() + if not (entity_ids_str := query.get("filter_entity_id")) or not ( + entity_ids := entity_ids_str.strip().lower().split(",") + ): + return self.json_message( + "filter_entity_id is missing", HTTPStatus.BAD_REQUEST + ) - one_day = timedelta(days=1) + hass = request.app["hass"] + + for entity_id in entity_ids: + if not hass.states.get(entity_id) and not valid_entity_id(entity_id): + return self.json_message( + "Invalid filter_entity_id", HTTPStatus.BAD_REQUEST + ) + + now = dt_util.utcnow() if datetime_: start_time = dt_util.as_utc(datetime_) else: - start_time = now - one_day + start_time = now - _ONE_DAY if start_time > now: return self.json([]) - if end_time_str := request.query.get("end_time"): + if end_time_str := query.get("end_time"): if end_time := dt_util.parse_datetime(end_time_str): end_time = dt_util.as_utc(end_time) else: return self.json_message("Invalid end_time", HTTPStatus.BAD_REQUEST) else: - end_time = start_time + one_day - entity_ids_str = request.query.get("filter_entity_id") - entity_ids = None - if entity_ids_str: - entity_ids = entity_ids_str.lower().split(",") - include_start_time_state = "skip_initial_state" not in request.query - significant_changes_only = ( - request.query.get("significant_changes_only", "1") != "0" - ) + end_time = start_time + _ONE_DAY + + include_start_time_state = "skip_initial_state" not in query + significant_changes_only = query.get("significant_changes_only", "1") != "0" minimal_response = "minimal_response" in request.query no_attributes = "no_attributes" in request.query - hass = request.app["hass"] - if ( not include_start_time_state and entity_ids @@ -159,33 +134,27 @@ class HistoryPeriodView(HomeAssistantView): hass: HomeAssistant, start_time: dt, end_time: dt, - entity_ids: list[str] | None, + entity_ids: list[str], include_start_time_state: bool, significant_changes_only: bool, minimal_response: bool, no_attributes: bool, ) -> web.Response: """Fetch significant stats from the database as json.""" - timer_start = time.perf_counter() - - with session_scope(hass=hass) 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, + with session_scope(hass=hass, read_only=True) as session: + 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 5d0eb59942..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, @@ -189,21 +188,77 @@ def _async_send_empty_response( """Send an empty response when we know all results are filtered away.""" connection.send_result(msg_id) stream_end_time = end_time or dt_util.utcnow() - _async_send_response(connection, msg_id, start_time, stream_end_time, {}) + connection.send_message( + _generate_websocket_response(msg_id, start_time, stream_end_time, {}) + ) -@callback -def _async_send_response( - connection: ActiveConnection, +def _generate_websocket_response( msg_id: int, start_time: dt, end_time: dt, states: MutableMapping[str, list[dict[str, Any]]], -) -> None: - """Send a response.""" - empty_stream_message = _generate_stream_message(states, start_time, end_time) - empty_response = messages.event_message(msg_id, empty_stream_message) - connection.send_message(JSON_DUMP(empty_response)) +) -> str: + """Generate a websocket response.""" + return JSON_DUMP( + messages.event_message( + msg_id, _generate_stream_message(states, start_time, end_time) + ) + ) + + +def _generate_historical_response( + hass: HomeAssistant, + msg_id: int, + start_time: dt, + end_time: dt, + entity_ids: list[str] | None, + include_start_time_state: bool, + significant_changes_only: bool, + minimal_response: bool, + no_attributes: bool, + send_empty: bool, +) -> tuple[float, dt | None, str | None]: + """Generate a historical response.""" + states = cast( + MutableMapping[str, list[dict[str, Any]]], + history.get_significant_states( + hass, + start_time, + end_time, + entity_ids, + None, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + True, + ), + ) + last_time_ts = 0.0 + for state_list in states.values(): + if ( + state_list + and (state_last_time := state_list[-1][COMPRESSED_STATE_LAST_UPDATED]) + > last_time_ts + ): + last_time_ts = cast(float, state_last_time) + + if last_time_ts == 0: + # If we did not send any states ever, we need to send an empty response + # so the websocket client knows it should render/process/consume the + # data. + if not send_empty: + return last_time_ts, None, None + last_time_dt = end_time + else: + last_time_dt = dt_util.utc_from_timestamp(last_time_ts) + + return ( + last_time_ts, + last_time_dt, + _generate_websocket_response(msg_id, start_time, last_time_dt, states), + ) async def _async_send_historical_states( @@ -213,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, @@ -221,43 +275,23 @@ async def _async_send_historical_states( send_empty: bool, ) -> dt | None: """Fetch history significant_states and send them to the client.""" - states = cast( - MutableMapping[str, list[dict[str, Any]]], - await get_instance(hass).async_add_executor_job( - history.get_significant_states, - hass, - start_time, - end_time, - entity_ids, - filters, - include_start_time_state, - significant_changes_only, - minimal_response, - no_attributes, - True, - ), + instance = get_instance(hass) + last_time_ts, last_time_dt, payload = await instance.async_add_executor_job( + _generate_historical_response, + hass, + msg_id, + start_time, + end_time, + entity_ids, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + send_empty, ) - last_time = 0 - - for state_list in states.values(): - if ( - state_list - and (state_last_time := state_list[-1][COMPRESSED_STATE_LAST_UPDATED]) - > last_time - ): - last_time = state_last_time - - if last_time == 0: - # If we did not send any states ever, we need to send an empty response - # so the websocket client knows it should render/process/consume the - # data. - if not send_empty: - return None - last_time_dt = end_time - else: - last_time_dt = dt_util.utc_from_timestamp(last_time) - _async_send_response(connection, msg_id, start_time, last_time_dt, states) - return last_time_dt if last_time != 0 else None + if payload: + connection.send_message(payload) + return last_time_dt if last_time_ts != 0 else None def _history_compressed_state(state: State, no_attributes: bool) -> dict[str, Any]: @@ -327,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: @@ -348,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 @@ -356,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) ) @@ -379,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, @@ -393,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) @@ -421,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"] @@ -447,7 +464,6 @@ async def ws_stream( start_time, end_time, entity_ids, - filters, include_start_time_state, significant_changes_only, minimal_response, @@ -497,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, @@ -513,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, @@ -555,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 33f32e7229..af27766f51 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -18,7 +18,7 @@ MIN_TIME_UTC = datetime.datetime.min.replace(tzinfo=dt_util.UTC) class HistoryStatsState: """The current stats of the history stats.""" - hours_matched: float | None + seconds_matched: float | None match_count: int | None period: tuple[datetime.datetime, datetime.datetime] @@ -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,28 +122,30 @@ 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 - hours_matched, match_count = self._async_compute_hours_and_changes( + seconds_matched, match_count = self._async_compute_seconds_and_changes( now_timestamp, current_period_start_timestamp, current_period_end_timestamp, ) - self._state = HistoryStatsState(hours_matched, match_count, self._period) + self._state = HistoryStatsState(seconds_matched, match_count, self._period) return self._state 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, @@ -162,10 +167,10 @@ class HistoryStats: no_attributes=True, ).get(self.entity_id, []) - def _async_compute_hours_and_changes( + def _async_compute_seconds_and_changes( self, now_timestamp: float, start_timestamp: float, end_timestamp: float ) -> tuple[float, int]: - """Compute the hours matched and changes from the history list and first state.""" + """Compute the seconds matched and changes from the history list and first state.""" # state_changes_during_period is called with include_start_time_state=True # which is the default and always provides the state at the start # of the period @@ -195,6 +200,6 @@ class HistoryStats: measure_end = min(end_timestamp, now_timestamp) elapsed += measure_end - last_state_change_timestamp - # Save value in hours - hours_matched = elapsed / 3600 - return hours_matched, match_count + # Save value in seconds + seconds_matched = elapsed + return seconds_matched, match_count diff --git a/homeassistant/components/history_stats/helpers.py b/homeassistant/components/history_stats/helpers.py index 23143984f4..0c914e1fd4 100644 --- a/homeassistant/components/history_stats/helpers.py +++ b/homeassistant/components/history_stats/helpers.py @@ -79,7 +79,7 @@ def pretty_ratio( if len(period) != 2 or period[0] == period[1]: return 0.0 - ratio = 100 * 3600 * value / (period[1] - period[0]).total_seconds() + ratio = 100 * value / (period[1] - period[0]).total_seconds() return round(ratio, 1) diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index fc3aedfde2..5b1242423c 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_NAME, CONF_STATE, CONF_TYPE, + CONF_UNIQUE_ID, PERCENTAGE, UnitOfTime, ) @@ -72,6 +73,7 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_DURATION): cv.time_period, vol.Optional(CONF_TYPE, default=CONF_TYPE_TIME): vol.In(CONF_TYPE_KEYS), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ), exactly_two_period_keys, @@ -95,6 +97,7 @@ async def async_setup_platform( duration: datetime.timedelta | None = config.get(CONF_DURATION) sensor_type: str = config[CONF_TYPE] name: str = config[CONF_NAME] + unique_id: str | None = config.get(CONF_UNIQUE_ID) for template in (start, end): if template is not None: @@ -105,7 +108,7 @@ async def async_setup_platform( await coordinator.async_refresh() if not coordinator.last_update_success: raise PlatformNotReady from coordinator.last_exception - async_add_entities([HistoryStatsSensor(coordinator, sensor_type, name)]) + async_add_entities([HistoryStatsSensor(coordinator, sensor_type, name, unique_id)]) class HistoryStatsSensorBase( @@ -150,11 +153,13 @@ class HistoryStatsSensor(HistoryStatsSensorBase): coordinator: HistoryStatsUpdateCoordinator, sensor_type: str, name: str, + unique_id: str | None, ) -> None: """Initialize the HistoryStats sensor.""" super().__init__(coordinator, name) self._attr_native_unit_of_measurement = UNITS[sensor_type] self._type = sensor_type + self._attr_unique_id = unique_id self._process_update() if self._type == CONF_TYPE_TIME: self._attr_device_class = SensorDeviceClass.DURATION @@ -163,13 +168,13 @@ class HistoryStatsSensor(HistoryStatsSensorBase): def _process_update(self) -> None: """Process an update from the coordinator.""" state = self.coordinator.data - if state is None or state.hours_matched is None: + if state is None or state.seconds_matched is None: self._attr_native_value = None return if self._type == CONF_TYPE_TIME: - self._attr_native_value = round(state.hours_matched, 2) + self._attr_native_value = round(state.seconds_matched / 3600, 2) elif self._type == CONF_TYPE_RATIO: - self._attr_native_value = pretty_ratio(state.hours_matched, state.period) + self._attr_native_value = pretty_ratio(state.seconds_matched, state.period) elif self._type == CONF_TYPE_COUNT: self._attr_native_value = state.match_count diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py index c9ee93634b..df1189f9e7 100644 --- a/homeassistant/components/hitron_coda/device_tracker.py +++ b/homeassistant/components/hitron_coda/device_tracker.py @@ -45,7 +45,7 @@ Device = namedtuple("Device", ["mac", "name"]) class HitronCODADeviceScanner(DeviceScanner): - """This class scans for devices using the CODA's web interface.""" + """Scanner for devices using the CODA's web interface.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 4d309fe684..76d75e5172 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -17,6 +17,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -122,6 +123,13 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: ) +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + return True + + def refresh_system( func: Callable[Concatenate[_HiveEntityT, _P], Awaitable[Any]] ) -> Callable[Concatenate[_HiveEntityT, _P], Coroutine[Any, Any, None]]: diff --git a/homeassistant/components/hlk_sw16/config_flow.py b/homeassistant/components/hlk_sw16/config_flow.py index 8338947260..4920e1542d 100644 --- a/homeassistant/components/hlk_sw16/config_flow.py +++ b/homeassistant/components/hlk_sw16/config_flow.py @@ -1,6 +1,7 @@ """Config flow for HLK-SW16.""" import asyncio +import async_timeout from hlk_sw16 import create_hlk_sw16_connection import voluptuous as vol @@ -35,7 +36,8 @@ async def connect_client(hass, user_input): reconnect_interval=DEFAULT_RECONNECT_INTERVAL, keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, ) - return await asyncio.wait_for(client_aw, timeout=CONNECTION_TIMEOUT) + async with async_timeout.timeout(CONNECTION_TIMEOUT): + return await client_aw async def validate_input(hass: HomeAssistant, user_input): diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index f50ab71155..10dc2d360f 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -74,6 +74,8 @@ class ConfigEntryAuth(homeconnect.HomeConnectAPI): device = Dryer(self.hass, app) elif app.type == "Washer": device = Washer(self.hass, app) + elif app.type == "WasherDryer": + device = WasherDryer(self.hass, app) elif app.type == "Dishwasher": device = Dishwasher(self.hass, app) elif app.type == "FridgeFreezer": @@ -145,11 +147,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 +162,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 +270,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 +295,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 +319,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,29 +345,29 @@ 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() + remote_control = self.get_remote_control() + remote_start = self.get_remote_start() + op_state_sensor = self.get_opstate_sensor() + program_sensors = self.get_program_sensors() + program_switches = self.get_program_switches() + return { + "binary_sensor": [door_entity, remote_control, remote_start], + "switch": program_switches, + "sensor": program_sensors + op_state_sensor, + } + + +class WasherDryer( + DeviceWithDoor, + DeviceWithOpState, + DeviceWithPrograms, + DeviceWithRemoteControl, + DeviceWithRemoteStart, +): + """WasherDryer class.""" def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" @@ -437,23 +387,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 +412,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 +459,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/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 89b1f23589..61dd11dbc6 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -50,6 +50,10 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): def __init__(self, device, program_name): """Initialize the entity.""" desc = " ".join(["Program", program_name.split(".")[-1]]) + if device.appliance.type == "WasherDryer": + desc = " ".join( + ["Program", program_name.split(".")[-3], program_name.split(".")[-1]] + ) super().__init__(device, desc) self.program_name = program_name self._state = None diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 5602fd6b59..987a4317ba 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -30,14 +30,18 @@ from homeassistant.helpers.service import ( async_extract_referenced_entity_ids, async_register_admin_service, ) +from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.typing import ConfigType +from .const import DATA_EXPOSED_ENTITIES, DOMAIN +from .exposed_entities import ExposedEntities + ATTR_ENTRY_ID = "entry_id" _LOGGER = logging.getLogger(__name__) -DOMAIN = ha.DOMAIN SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" SERVICE_RELOAD_CONFIG_ENTRY = "reload_config_entry" +SERVICE_RELOAD_CUSTOM_TEMPLATES = "reload_custom_templates" SERVICE_CHECK_CONFIG = "check_config" SERVICE_UPDATE_ENTITY = "update_entity" SERVICE_SET_LOCATION = "set_location" @@ -258,6 +262,14 @@ 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_templates(call: ha.ServiceCall) -> None: + """Service handler to reload custom Jinja.""" + await async_load_custom_templates(hass) + + async_register_admin_service( + hass, ha.DOMAIN, SERVICE_RELOAD_CUSTOM_TEMPLATES, async_handle_reload_templates + ) + async def async_handle_reload_config_entry(call: ha.ServiceCall) -> None: """Service handler for reloading a config entry.""" reload_entries = set() @@ -288,8 +300,10 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no reload of YAML configurations for the domain that support it. Additionally, it also calls the `homeasssitant.reload_core_config` - service, as that reloads the core YAML configuration, and the - `frontend.reload_themes` service, as that reloads the themes. + service, as that reloads the core YAML configuration, the + `frontend.reload_themes` service that reloads the themes, and the + `homeassistant.reload_custom_templates` service that reloads any custom + jinja into memory. We only do so, if there are no configuration errors. """ @@ -315,10 +329,11 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no hass.services.async_call( domain, service, context=call.context, blocking=True ) - for domain, service in { - ha.DOMAIN: SERVICE_RELOAD_CORE_CONFIG, - "frontend": "reload_themes", - }.items() + for domain, service in ( + (ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG), + ("frontend", "reload_themes"), + (ha.DOMAIN, SERVICE_RELOAD_CUSTOM_TEMPLATES), + ) ] await asyncio.gather(*tasks) @@ -327,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..07f14e7ce8 --- /dev/null +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -0,0 +1,537 @@ +"""Control which entities are exposed to voice assistants.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +import dataclasses +from itertools import chain +from typing import Any, TypedDict + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import get_device_class +from homeassistant.helpers.storage import Store + +from .const import DATA_EXPOSED_ENTITIES, DOMAIN + +KNOWN_ASSISTANTS = ("cloud.alexa", "cloud.google_assistant", "conversation") + +STORAGE_KEY = f"{DOMAIN}.exposed_entities" +STORAGE_VERSION = 1 + +SAVE_DELAY = 10 + +DEFAULT_EXPOSED_DOMAINS = { + "climate", + "cover", + "fan", + "humidifier", + "light", + "lock", + "scene", + "script", + "switch", + "vacuum", + "water_heater", +} + +DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES = { + BinarySensorDeviceClass.DOOR, + BinarySensorDeviceClass.GARAGE_DOOR, + BinarySensorDeviceClass.LOCK, + BinarySensorDeviceClass.MOTION, + BinarySensorDeviceClass.OPENING, + BinarySensorDeviceClass.PRESENCE, + BinarySensorDeviceClass.WINDOW, +} + +DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES = { + SensorDeviceClass.AQI, + SensorDeviceClass.CO, + SensorDeviceClass.CO2, + SensorDeviceClass.HUMIDITY, + SensorDeviceClass.PM10, + SensorDeviceClass.PM25, + SensorDeviceClass.TEMPERATURE, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, +} + +DEFAULT_EXPOSED_ASSISTANT = { + "conversation": True, +} + + +@dataclasses.dataclass(frozen=True) +class AssistantPreferences: + """Preferences for an assistant.""" + + expose_new: bool + + def to_json(self) -> dict[str, Any]: + """Return a JSON serializable representation for storage.""" + return {"expose_new": self.expose_new} + + +@dataclasses.dataclass(frozen=True) +class ExposedEntity: + """An exposed entity without a unique_id.""" + + assistants: dict[str, dict[str, Any]] + + def to_json(self) -> dict[str, Any]: + """Return a JSON serializable representation for storage.""" + return { + "assistants": self.assistants, + } + + +class SerializedExposedEntities(TypedDict): + """Serialized exposed entities storage storage collection.""" + + assistants: dict[str, dict[str, Any]] + exposed_entities: dict[str, dict[str, Any]] + + +class ExposedEntities: + """Control assistant settings. + + Settings for entities without a unique_id are stored in the store. + Settings for entities with a unique_id are stored in the entity registry. + """ + + _assistants: dict[str, AssistantPreferences] + entities: dict[str, ExposedEntity] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize.""" + self._hass = hass + self._listeners: dict[str, list[Callable[[], None]]] = {} + self._store: Store[SerializedExposedEntities] = Store( + hass, STORAGE_VERSION, STORAGE_KEY + ) + + async def async_initialize(self) -> None: + """Finish initializing.""" + websocket_api.async_register_command(self._hass, ws_expose_entity) + websocket_api.async_register_command(self._hass, ws_expose_new_entities_get) + websocket_api.async_register_command(self._hass, ws_expose_new_entities_set) + websocket_api.async_register_command(self._hass, ws_list_exposed_entities) + await self._async_load_data() + + @callback + def async_listen_entity_updates( + self, assistant: str, listener: Callable[[], None] + ) -> None: + """Listen for updates to entity expose settings.""" + self._listeners.setdefault(assistant, []).append(listener) + + @callback + def async_set_assistant_option( + self, assistant: str, entity_id: str, key: str, value: Any + ) -> None: + """Set an option for an assistant. + + Notify listeners if expose flag was changed. + """ + entity_registry = er.async_get(self._hass) + if not (registry_entry := entity_registry.async_get(entity_id)): + return self._async_set_legacy_assistant_option( + assistant, entity_id, key, value + ) + + assistant_options: Mapping[str, Any] + if ( + assistant_options := registry_entry.options.get(assistant, {}) + ) and assistant_options.get(key) == value: + return + + assistant_options = assistant_options | {key: value} + entity_registry.async_update_entity_options( + entity_id, assistant, assistant_options + ) + for listener in self._listeners.get(assistant, []): + listener() + + def _async_set_legacy_assistant_option( + self, assistant: str, entity_id: str, key: str, value: Any + ) -> None: + """Set an option for an assistant. + + Notify listeners if expose flag was changed. + """ + if ( + (exposed_entity := self.entities.get(entity_id)) + and (assistant_options := exposed_entity.assistants.get(assistant, {})) + and assistant_options.get(key) == value + ): + return + + if exposed_entity: + new_exposed_entity = self._update_exposed_entity( + assistant, entity_id, key, value + ) + else: + new_exposed_entity = self._new_exposed_entity(assistant, key, value) + self.entities[entity_id] = new_exposed_entity + self._async_schedule_save() + for listener in self._listeners.get(assistant, []): + listener() + + @callback + def async_get_expose_new_entities(self, assistant: str) -> bool: + """Check if new entities are exposed to an assistant.""" + if prefs := self._assistants.get(assistant): + return prefs.expose_new + return DEFAULT_EXPOSED_ASSISTANT.get(assistant, False) + + @callback + def async_set_expose_new_entities(self, assistant: str, expose_new: bool) -> None: + """Enable an assistant to expose new entities.""" + self._assistants[assistant] = AssistantPreferences(expose_new=expose_new) + self._async_schedule_save() + + @callback + def async_get_assistant_settings( + self, assistant: str + ) -> dict[str, Mapping[str, Any]]: + """Get all entity expose settings for an assistant.""" + entity_registry = er.async_get(self._hass) + result: dict[str, Mapping[str, Any]] = {} + + options: Mapping | None + for entity_id, exposed_entity in self.entities.items(): + if options := exposed_entity.assistants.get(assistant): + result[entity_id] = options + + for entity_id, entry in entity_registry.entities.items(): + if options := entry.options.get(assistant): + result[entity_id] = options + + return result + + @callback + def async_get_entity_settings(self, entity_id: str) -> dict[str, Mapping[str, Any]]: + """Get assistant expose settings for an entity.""" + entity_registry = er.async_get(self._hass) + result: dict[str, Mapping[str, Any]] = {} + + assistant_settings: Mapping + if registry_entry := entity_registry.async_get(entity_id): + assistant_settings = registry_entry.options + elif exposed_entity := self.entities.get(entity_id): + assistant_settings = exposed_entity.assistants + else: + raise HomeAssistantError("Unknown entity") + + for assistant in KNOWN_ASSISTANTS: + if options := assistant_settings.get(assistant): + result[assistant] = options + + return result + + @callback + def async_should_expose(self, assistant: str, entity_id: str) -> bool: + """Return True if an entity should be exposed to an assistant.""" + should_expose: bool + + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + entity_registry = er.async_get(self._hass) + if not (registry_entry := entity_registry.async_get(entity_id)): + return self._async_should_expose_legacy_entity(assistant, entity_id) + if assistant in registry_entry.options: + if "should_expose" in registry_entry.options[assistant]: + should_expose = registry_entry.options[assistant]["should_expose"] + return should_expose + + if self.async_get_expose_new_entities(assistant): + should_expose = self._is_default_exposed(entity_id, registry_entry) + else: + should_expose = False + + assistant_options: Mapping[str, Any] = registry_entry.options.get(assistant, {}) + assistant_options = assistant_options | {"should_expose": should_expose} + entity_registry.async_update_entity_options( + entity_id, assistant, assistant_options + ) + + return should_expose + + def _async_should_expose_legacy_entity( + self, assistant: str, entity_id: str + ) -> bool: + """Return True if an entity should be exposed to an assistant.""" + should_expose: bool + + if ( + exposed_entity := self.entities.get(entity_id) + ) and assistant in exposed_entity.assistants: + if "should_expose" in exposed_entity.assistants[assistant]: + should_expose = exposed_entity.assistants[assistant]["should_expose"] + return should_expose + + if self.async_get_expose_new_entities(assistant): + should_expose = self._is_default_exposed(entity_id, None) + else: + should_expose = False + + if exposed_entity: + new_exposed_entity = self._update_exposed_entity( + assistant, entity_id, "should_expose", should_expose + ) + else: + new_exposed_entity = self._new_exposed_entity( + assistant, "should_expose", should_expose + ) + self.entities[entity_id] = new_exposed_entity + self._async_schedule_save() + + return should_expose + + def _is_default_exposed( + self, entity_id: str, registry_entry: er.RegistryEntry | None + ) -> bool: + """Return True if an entity is exposed by default.""" + if registry_entry and ( + registry_entry.entity_category is not None + or registry_entry.hidden_by is not None + ): + return False + + domain = split_entity_id(entity_id)[0] + if domain in DEFAULT_EXPOSED_DOMAINS: + return True + + try: + device_class = get_device_class(self._hass, entity_id) + except HomeAssistantError: + # The entity no longer exists + return False + if ( + domain == "binary_sensor" + and device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES + ): + return True + + if domain == "sensor" and device_class in DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES: + return True + + return False + + def _update_exposed_entity( + self, assistant: str, entity_id: str, key: str, value: Any + ) -> ExposedEntity: + """Update an exposed entity.""" + entity = self.entities[entity_id] + assistants = dict(entity.assistants) + old_settings = assistants.get(assistant, {}) + assistants[assistant] = old_settings | {key: value} + return ExposedEntity(assistants) + + def _new_exposed_entity( + self, assistant: str, key: str, value: Any + ) -> ExposedEntity: + """Create a new exposed entity.""" + return ExposedEntity( + assistants={assistant: {key: value}}, + ) + + async def _async_load_data(self) -> SerializedExposedEntities | None: + """Load from the store.""" + data = await self._store.async_load() + + assistants: dict[str, AssistantPreferences] = {} + exposed_entities: dict[str, ExposedEntity] = {} + + if data: + for domain, preferences in data["assistants"].items(): + assistants[domain] = AssistantPreferences(**preferences) + + if data and "exposed_entities" in data: + for entity_id, preferences in data["exposed_entities"].items(): + exposed_entities[entity_id] = ExposedEntity(**preferences) + + self._assistants = assistants + self.entities = exposed_entities + + return data + + @callback + def _async_schedule_save(self) -> None: + """Schedule saving the preferences.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> SerializedExposedEntities: + """Return JSON-compatible date for storing to file.""" + return { + "assistants": { + domain: preferences.to_json() + for domain, preferences in self._assistants.items() + }, + "exposed_entities": { + entity_id: entity.to_json() + for entity_id, entity in self.entities.items() + }, + } + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_entity", + vol.Required("assistants"): [vol.In(KNOWN_ASSISTANTS)], + vol.Required("entity_ids"): [str], + vol.Required("should_expose"): bool, + } +) +def ws_expose_entity( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose an entity to an assistant.""" + entity_ids: str = msg["entity_ids"] + + if blocked := next( + ( + entity_id + for entity_id in entity_ids + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES + ), + None, + ): + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_ALLOWED, f"can't expose '{blocked}'" + ) + return + + for entity_id in entity_ids: + for assistant in msg["assistants"]: + async_expose_entity(hass, assistant, entity_id, msg["should_expose"]) + connection.send_result(msg["id"]) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_entity/list", + } +) +def ws_list_exposed_entities( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose an entity to an assistant.""" + result: dict[str, Any] = {} + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + entity_registry = er.async_get(hass) + for entity_id in chain(exposed_entities.entities, entity_registry.entities): + result[entity_id] = {} + entity_settings = async_get_entity_settings(hass, entity_id) + for assistant, settings in entity_settings.items(): + if "should_expose" not in settings: + continue + result[entity_id][assistant] = settings["should_expose"] + connection.send_result(msg["id"], {"exposed_entities": result}) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_new_entities/get", + vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS), + } +) +def ws_expose_new_entities_get( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Check if new entities are exposed to an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + expose_new = exposed_entities.async_get_expose_new_entities(msg["assistant"]) + connection.send_result(msg["id"], {"expose_new": expose_new}) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_new_entities/set", + vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS), + vol.Required("expose_new"): bool, + } +) +def ws_expose_new_entities_set( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose new entities to an assistatant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities(msg["assistant"], msg["expose_new"]) + connection.send_result(msg["id"]) + + +@callback +def async_listen_entity_updates( + hass: HomeAssistant, assistant: str, listener: Callable[[], None] +) -> None: + """Listen for updates to entity expose settings.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_listen_entity_updates(assistant, listener) + + +@callback +def async_get_assistant_settings( + hass: HomeAssistant, assistant: str +) -> dict[str, Mapping[str, Any]]: + """Get all entity expose settings for an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_get_assistant_settings(assistant) + + +@callback +def async_get_entity_settings( + hass: HomeAssistant, entity_id: str +) -> dict[str, Mapping[str, Any]]: + """Get assistant expose settings for an entity.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_get_entity_settings(entity_id) + + +@callback +def async_expose_entity( + hass: HomeAssistant, + assistant: str, + entity_id: str, + should_expose: bool, +) -> None: + """Get assistant expose settings for an entity.""" + async_set_assistant_option( + hass, assistant, entity_id, "should_expose", should_expose + ) + + +@callback +def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) -> bool: + """Return True if an entity should be exposed to an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_should_expose(assistant, entity_id) + + +@callback +def async_set_assistant_option( + hass: HomeAssistant, assistant: str, entity_id: str, option: str, value: Any +) -> None: + """Set an option for an assistant. + + Notify listeners if expose flag was changed. + """ + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_assistant_option(assistant, entity_id, option, value) diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index da52ff50d2..2fe27769c3 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -59,6 +59,12 @@ update_entity: target: entity: {} +reload_custom_templates: + name: Reload custom Jinja2 templates + description: >- + 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: name: Reload config entry description: Reload a config entry that matches a target. diff --git a/homeassistant/components/homeassistant/trigger.py b/homeassistant/components/homeassistant/trigger.py index 588b671300..3160af5807 100644 --- a/homeassistant/components/homeassistant/trigger.py +++ b/homeassistant/components/homeassistant/trigger.py @@ -1,16 +1,17 @@ """Home Assistant trigger dispatcher.""" import importlib -from homeassistant.components.device_automation.trigger import ( - DeviceAutomationTriggerProtocol, -) from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + TriggerProtocol, +) from homeassistant.helpers.typing import ConfigType -def _get_trigger_platform(config: ConfigType) -> DeviceAutomationTriggerProtocol: +def _get_trigger_platform(config: ConfigType) -> TriggerProtocol: return importlib.import_module(f"..triggers.{config[CONF_PLATFORM]}", __name__) @@ -20,7 +21,7 @@ async def async_validate_trigger_config( """Validate config.""" platform = _get_trigger_platform(config) if hasattr(platform, "async_validate_trigger_config"): - return await getattr(platform, "async_validate_trigger_config")(hass, config) + return await platform.async_validate_trigger_config(hass, config) return platform.TRIGGER_SCHEMA(config) diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index 0796d49d77..d0e74d5b04 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -80,22 +80,26 @@ async def async_attach_trigger( extra=vol.ALLOW_EXTRA, ) - job = HassJob(action) + job = HassJob(action, f"event trigger {trigger_info}") @callback - def handle_event(event: Event) -> None: - """Listen for events and calls the action when data matches.""" + def filter_event(event: Event) -> bool: + """Filter events.""" try: # Check that the event data and context match the configured # schema if one was provided if event_data_schema: event_data_schema(event.data) if event_context_schema: - event_context_schema(event.context.as_dict()) + event_context_schema(dict(event.context.as_dict())) except vol.Invalid: # If event doesn't match, skip event - return + return False + return True + @callback + def handle_event(event: Event) -> None: + """Listen for events and calls the action when data matches.""" hass.async_run_hass_job( job, { @@ -110,7 +114,8 @@ async def async_attach_trigger( ) removes = [ - hass.bus.async_listen(event_type, handle_event) for event_type in event_types + hass.bus.async_listen(event_type, handle_event, event_filter=filter_event) + for event_type in event_types ] @callback diff --git a/homeassistant/components/homeassistant/triggers/homeassistant.py b/homeassistant/components/homeassistant/triggers/homeassistant.py index e3dc93a978..51686e54c5 100644 --- a/homeassistant/components/homeassistant/triggers/homeassistant.py +++ b/homeassistant/components/homeassistant/triggers/homeassistant.py @@ -27,7 +27,7 @@ async def async_attach_trigger( """Listen for events based on configuration.""" trigger_data = trigger_info["trigger_data"] event = config.get(CONF_EVENT) - job = HassJob(action) + job = HassJob(action, f"homeassistant trigger {trigger_info}") if event == EVENT_SHUTDOWN: diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index 53d3fb1217..d822cd523f 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -100,7 +100,7 @@ async def async_attach_trigger( armed_entities = set() period: dict = {} attribute = config.get(CONF_ATTRIBUTE) - job = HassJob(action) + job = HassJob(action, f"numeric state trigger {trigger_info}") trigger_data = trigger_info["trigger_data"] _variables = trigger_info["variables"] or {} diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index 25622e0a3c..7fc780d797 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -123,7 +123,7 @@ async def async_attach_trigger( unsub_track_same = {} period: dict[str, timedelta] = {} attribute = config.get(CONF_ATTRIBUTE) - job = HassJob(action) + job = HassJob(action, f"state trigger {trigger_info}") trigger_data = trigger_info["trigger_data"] _variables = trigger_info["variables"] or {} diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index f5473d66a5..a29cb5ff6d 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -49,7 +49,7 @@ async def async_attach_trigger( trigger_data = trigger_info["trigger_data"] entities: dict[str, CALLBACK_TYPE] = {} removes = [] - job = HassJob(action) + job = HassJob(action, f"time trigger {trigger_info}") @callback def time_automation_listener(description, now, *, entity_id=None): diff --git a/homeassistant/components/homeassistant/triggers/time_pattern.py b/homeassistant/components/homeassistant/triggers/time_pattern.py index 2a5022bebf..63f9b18cf9 100644 --- a/homeassistant/components/homeassistant/triggers/time_pattern.py +++ b/homeassistant/components/homeassistant/triggers/time_pattern.py @@ -66,7 +66,7 @@ async def async_attach_trigger( hours = config.get(CONF_HOURS) minutes = config.get(CONF_MINUTES) seconds = config.get(CONF_SECONDS) - job = HassJob(action) + job = HassJob(action, f"time pattern trigger {trigger_info}") # If larger units are specified, default the smaller units to zero if minutes is None and hours is not None: diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index 7012111ed6..234f5ae4fe 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -10,9 +10,10 @@ import aiohttp from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.components.hassio import get_supervisor_info, is_hassio -from homeassistant.const import __version__ -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import EVENT_COMPONENT_LOADED, __version__ +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -22,6 +23,7 @@ from homeassistant.helpers.start import async_at_start from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +COMPONENT_LOADED_COOLDOWN = 30 DOMAIN = "homeassistant_alerts" UPDATE_INTERVAL = timedelta(hours=3) _LOGGER = logging.getLogger(__name__) @@ -46,7 +48,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: try: response = await async_get_clientsession(hass).get( f"https://alerts.home-assistant.io/alerts/{alert.alert_id}.json", - timeout=aiohttp.ClientTimeout(total=10), + timeout=aiohttp.ClientTimeout(total=30), ) except asyncio.TimeoutError: _LOGGER.warning("Error fetching %s: timeout", alert.filename) @@ -85,14 +87,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: coordinator.async_add_listener(async_schedule_update_alerts) async def initial_refresh(hass: HomeAssistant) -> None: + refresh_debouncer = Debouncer( + hass, + _LOGGER, + cooldown=COMPONENT_LOADED_COOLDOWN, + immediate=False, + function=coordinator.async_refresh, + ) + + async def _component_loaded(_: Event) -> None: + await refresh_debouncer.async_call() + await coordinator.async_refresh() + hass.bus.async_listen(EVENT_COMPONENT_LOADED, _component_loaded) async_at_start(hass, initial_refresh) 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..8c502f080f 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,17 +16,14 @@ from homeassistant.components.hassio import ( AddonInfo, AddonManager, AddonState, + hostname_from_addon_slug, is_hassio, ) from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN from homeassistant.components.zha.radio_manager import ZhaMultiPANMigrationHelper from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import ( - AbortFlow, - FlowHandler, - FlowManager, - FlowResult, -) +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.singleton import singleton from .const import LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG @@ -63,28 +61,34 @@ 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" -class BaseMultiPanFlow(FlowHandler, ABC): - """Support configuring the Silicon Labs Multiprotocol add-on.""" +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 - def __init__(self) -> None: - """Set up flow instance.""" + +class OptionsFlowHandler(config_entries.OptionsFlow, ABC): + """Handle an options flow for the Silicon Labs Multiprotocol add-on.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Set up the options flow.""" # If we install the add-on we should uninstall it on entry remove. self.install_task: asyncio.Task | None = None self.start_task: asyncio.Task | None = None self._zha_migration_mgr: ZhaMultiPANMigrationHelper | None = None - - @property - @abstractmethod - def flow_manager(self) -> FlowManager: - """Return the flow manager of the flow.""" + self.config_entry = config_entry + self.original_addon_config: dict[str, Any] | None = None + self.revert_reason: str | None = None @abstractmethod async def _async_serial_port_settings(self) -> SerialPortSettings: @@ -106,81 +110,10 @@ class BaseMultiPanFlow(FlowHandler, ABC): def _zha_name(self) -> str: """Return the ZHA name.""" - async def async_step_install_addon( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Install Silicon Labs Multiprotocol add-on.""" - if not self.install_task: - self.install_task = self.hass.async_create_task(self._async_install_addon()) - return self.async_show_progress( - step_id="install_addon", progress_action="install_addon" - ) - - try: - await self.install_task - except AddonError as err: - self.install_task = None - _LOGGER.error(err) - return self.async_show_progress_done(next_step_id="install_failed") - - self.install_task = None - - return self.async_show_progress_done(next_step_id="configure_addon") - - async def async_step_install_failed( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Add-on installation failed.""" - return self.async_abort(reason="addon_install_failed") - - async def async_step_start_addon( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Start Silicon Labs Multiprotocol add-on.""" - if not self.start_task: - self.start_task = self.hass.async_create_task(self._async_start_addon()) - return self.async_show_progress( - step_id="start_addon", progress_action="start_addon" - ) - - try: - await self.start_task - except (AddonError, AbortFlow) as err: - self.start_task = None - _LOGGER.error(err) - return self.async_show_progress_done(next_step_id="start_failed") - - self.start_task = None - return self.async_show_progress_done(next_step_id="finish_addon_setup") - - async def async_step_start_failed( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Add-on start failed.""" - return self.async_abort(reason="addon_start_failed") - - async def _async_start_addon(self) -> None: - """Start Silicon Labs Multiprotocol add-on.""" - addon_manager: AddonManager = get_addon_manager(self.hass) - try: - await addon_manager.async_schedule_start_addon() - finally: - # Continue the flow after show progress when the task is done. - self.hass.async_create_task( - self.flow_manager.async_configure(flow_id=self.flow_id) - ) - - @abstractmethod - async def async_step_configure_addon( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Configure the Silicon Labs Multiprotocol add-on.""" - - @abstractmethod - async def async_step_finish_addon_setup( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Finish setup of the Silicon Labs Multiprotocol add-on.""" + @property + def flow_manager(self) -> config_entries.OptionsFlowManager: + """Return the correct flow manager.""" + return self.hass.config_entries.options async def _async_get_addon_info(self) -> AddonInfo: """Return and cache Silicon Labs Multiprotocol add-on info.""" @@ -213,22 +146,6 @@ class BaseMultiPanFlow(FlowHandler, ABC): self.flow_manager.async_configure(flow_id=self.flow_id) ) - -class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): - """Handle an options flow for the Silicon Labs Multiprotocol add-on.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Set up the options flow.""" - super().__init__() - self.config_entry = config_entry - self.original_addon_config: dict[str, Any] | None = None - self.revert_reason: str | None = None - - @property - def flow_manager(self) -> config_entries.OptionsFlowManager: - """Return the correct flow manager.""" - return self.hass.config_entries.options - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -265,6 +182,33 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): return await self.async_step_install_addon() + async def async_step_install_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Install Silicon Labs Multiprotocol add-on.""" + if not self.install_task: + self.install_task = self.hass.async_create_task(self._async_install_addon()) + return self.async_show_progress( + step_id="install_addon", progress_action="install_addon" + ) + + try: + await self.install_task + except AddonError as err: + self.install_task = None + _LOGGER.error(err) + return self.async_show_progress_done(next_step_id="install_failed") + + self.install_task = None + + return self.async_show_progress_done(next_step_id="configure_addon") + + async def async_step_install_failed( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add-on installation failed.""" + return self.async_abort(reason="addon_install_failed") + async def async_step_configure_addon( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -289,7 +233,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", }, @@ -311,6 +255,43 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): return await self.async_step_start_addon() + async def async_step_start_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Start Silicon Labs Multiprotocol add-on.""" + if not self.start_task: + self.start_task = self.hass.async_create_task(self._async_start_addon()) + return self.async_show_progress( + step_id="start_addon", progress_action="start_addon" + ) + + try: + await self.start_task + except (AddonError, AbortFlow) as err: + self.start_task = None + _LOGGER.error(err) + return self.async_show_progress_done(next_step_id="start_failed") + + self.start_task = None + return self.async_show_progress_done(next_step_id="finish_addon_setup") + + async def async_step_start_failed( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add-on start failed.""" + return self.async_abort(reason="addon_start_failed") + + async def _async_start_addon(self) -> None: + """Start Silicon Labs Multiprotocol add-on.""" + addon_manager: AddonManager = get_addon_manager(self.hass) + try: + await addon_manager.async_schedule_start_addon() + finally: + # Continue the flow after show progress when the task is done. + self.hass.async_create_task( + self.flow_manager.async_configure(flow_id=self.flow_id) + ) + async def async_step_finish_addon_setup( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -356,3 +337,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 c9faa2e28e..2b56a05682 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -50,8 +50,12 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized -from homeassistant.helpers import device_registry, entity_registry, instance_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, + instance_id, +) from homeassistant.helpers.entityfilter import ( BASE_FILTER_SCHEMA, FILTER_SCHEMA, @@ -298,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 @@ -431,20 +435,19 @@ def _async_register_events_and_services(hass: HomeAssistant) -> None: async def async_handle_homekit_unpair(service: ServiceCall) -> None: """Handle unpair HomeKit service call.""" referenced = async_extract_referenced_entity_ids(hass, service) - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) for device_id in referenced.referenced_devices: if not (dev_reg_ent := dev_reg.async_get(device_id)): raise HomeAssistantError(f"No device found for device id: {device_id}") macs = [ cval for ctype, cval in dev_reg_ent.connections - if ctype == device_registry.CONNECTION_NETWORK_MAC + if ctype == dr.CONNECTION_NETWORK_MAC ] matching_instances = [ homekit for homekit in _async_all_homekit_instances(hass) - if homekit.driver - and device_registry.format_mac(homekit.driver.state.mac) in macs + if homekit.driver and dr.format_mac(homekit.driver.state.mac) in macs ] if not matching_instances: raise HomeAssistantError( @@ -594,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( @@ -634,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: @@ -698,7 +705,7 @@ class HomeKit: return False def add_bridge_triggers_accessory( - self, device: device_registry.DeviceEntry, device_triggers: list[dict[str, Any]] + self, device: dr.DeviceEntry, device_triggers: list[dict[str, Any]] ) -> None: """Add device automation triggers to the bridge.""" if self._would_exceed_max_devices(device.name): @@ -734,8 +741,8 @@ class HomeKit: async def async_configure_accessories(self) -> list[State]: """Configure accessories for the included states.""" - dev_reg = device_registry.async_get(self.hass) - ent_reg = entity_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) + ent_reg = er.async_get(self.hass) device_lookup = ent_reg.async_get_device_class_lookup( { (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.BATTERY_CHARGING), @@ -830,8 +837,8 @@ class HomeKit: def _async_register_bridge(self) -> None: """Register the bridge as a device so homekit_controller and exclude it from discovery.""" assert self.driver is not None - dev_reg = device_registry.async_get(self.hass) - formatted_mac = device_registry.format_mac(self.driver.state.mac) + dev_reg = dr.async_get(self.hass) + formatted_mac = dr.format_mac(self.driver.state.mac) # Connections and identifiers are both used here. # # connections exists so homekit_controller can know the @@ -844,7 +851,7 @@ class HomeKit: # because this was the way you had to fix homekit when pairing # failed. # - connection = (device_registry.CONNECTION_NETWORK_MAC, formatted_mac) + connection = (dr.CONNECTION_NETWORK_MAC, formatted_mac) identifier = (DOMAIN, self._entry_id, BRIDGE_SERIAL_NUMBER) self._async_purge_old_bridges(dev_reg, identifier, connection) is_accessory_mode = self._homekit_mode == HOMEKIT_MODE_ACCESSORY @@ -858,13 +865,13 @@ class HomeKit: manufacturer=MANUFACTURER, name=accessory_friendly_name(self._entry_title, self.driver.accessory), model=f"HomeKit {hk_mode_name}", - entry_type=device_registry.DeviceEntryType.SERVICE, + entry_type=dr.DeviceEntryType.SERVICE, ) @callback def _async_purge_old_bridges( self, - dev_reg: device_registry.DeviceRegistry, + dev_reg: dr.DeviceRegistry, identifier: tuple[str, str, str], connection: tuple[str, str], ) -> None: @@ -920,7 +927,7 @@ class HomeKit: async def _async_add_trigger_accessories(self) -> None: """Add devices with triggers to the bridge.""" - dev_reg = device_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) valid_device_ids = [] for device_id in self._devices: if not dev_reg.async_get(device_id): @@ -989,7 +996,7 @@ class HomeKit: @callback def _async_configure_linked_sensors( self, - ent_reg_ent: entity_registry.RegistryEntry, + ent_reg_ent: er.RegistryEntry, device_lookup: dict[str, dict[tuple[str, str | None], str]], state: State, ) -> None: @@ -1051,8 +1058,8 @@ class HomeKit: async def _async_set_device_info_attributes( self, - ent_reg_ent: entity_registry.RegistryEntry, - dev_reg: device_registry.DeviceRegistry, + ent_reg_ent: er.RegistryEntry, + dev_reg: dr.DeviceRegistry, entity_id: str, ) -> None: """Set attributes that will be used for homekit device info.""" @@ -1070,7 +1077,7 @@ class HomeKit: ent_cfg[ATTR_INTEGRATION] = ent_reg_ent.platform def _fill_config_from_device_registry_entry( - self, device_entry: device_registry.DeviceEntry, config: dict[str, Any] + self, device_entry: dr.DeviceEntry, config: dict[str, Any] ) -> None: """Populate a config dict from the registry.""" if device_entry.manufacturer: diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index adab539fb3..dc8a2a7c63 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -148,6 +148,11 @@ def get_accessory( # noqa: C901 and features & CoverEntityFeature.SET_POSITION ): a_type = "Window" + elif ( + device_class == CoverDeviceClass.DOOR + and features & CoverEntityFeature.SET_POSITION + ): + a_type = "Door" elif features & CoverEntityFeature.SET_POSITION: a_type = "WindowCovering" elif features & (CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE): 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/config_flow.py b/homeassistant/components/homekit/config_flow.py index e1d107065a..3747af3edc 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -1,7 +1,6 @@ """Config flow for HomeKit integration.""" from __future__ import annotations -import asyncio from collections.abc import Iterable from copy import deepcopy import random @@ -29,15 +28,18 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import device_registry, entity_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.entityfilter import ( CONF_EXCLUDE_DOMAINS, CONF_EXCLUDE_ENTITIES, CONF_INCLUDE_DOMAINS, CONF_INCLUDE_ENTITIES, ) -from homeassistant.loader import async_get_integration +from homeassistant.loader import async_get_integrations from .const import ( CONF_ENTITY_CONFIG, @@ -163,17 +165,14 @@ def _async_cameras_from_entities(entities: list[str]) -> dict[str, str]: async def _async_name_to_type_map(hass: HomeAssistant) -> dict[str, str]: """Create a mapping of types of devices/entities HomeKit can support.""" - integrations = await asyncio.gather( - *(async_get_integration(hass, domain) for domain in SUPPORTED_DOMAINS), - return_exceptions=True, - ) - name_to_type_map = { - domain: domain - if isinstance(integrations[idx], Exception) - else integrations[idx].name - for idx, domain in enumerate(SUPPORTED_DOMAINS) + integrations = await async_get_integrations(hass, SUPPORTED_DOMAINS) + return { + domain: integration_or_exception.name + if (integration_or_exception := integrations[domain]) + and not isinstance(integration_or_exception, Exception) + else domain + for domain in SUPPORTED_DOMAINS } - return name_to_type_map class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -634,7 +633,7 @@ async def _async_get_supported_devices(hass: HomeAssistant) -> dict[str, str]: results = await device_automation.async_get_device_automations( hass, device_automation.DeviceAutomationType.TRIGGER ) - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) unsorted: dict[str, str] = {} for device_id in results: entry = dev_reg.async_get(device_id) @@ -643,7 +642,7 @@ async def _async_get_supported_devices(hass: HomeAssistant) -> dict[str, str]: def _exclude_by_entity_registry( - ent_reg: entity_registry.EntityRegistry, + ent_reg: er.EntityRegistry, entity_id: str, include_entity_category: bool, include_hidden: bool, @@ -665,7 +664,7 @@ def _async_get_matching_entities( include_hidden: bool = False, ) -> dict[str, str]: """Fetch all entities or entities in the given domains.""" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) return { state.entity_id: ( f"{state.attributes.get(ATTR_FRIENDLY_NAME, state.entity_id)} ({state.entity_id})" diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 58e1e13a3f..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 @@ -119,6 +123,7 @@ SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement" SERV_CARBON_DIOXIDE_SENSOR = "CarbonDioxideSensor" SERV_CARBON_MONOXIDE_SENSOR = "CarbonMonoxideSensor" SERV_CONTACT_SENSOR = "ContactSensor" +SERV_DOOR = "Door" SERV_DOORBELL = "Doorbell" SERV_FANV2 = "Fanv2" SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener" 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_covers.py b/homeassistant/components/homekit/type_covers.py index 4b21bfb77d..05feb58057 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -2,6 +2,7 @@ import logging from pyhap.const import ( + CATEGORY_DOOR, CATEGORY_GARAGE_DOOR_OPENER, CATEGORY_WINDOW, CATEGORY_WINDOW_COVERING, @@ -54,6 +55,7 @@ from .const import ( HK_POSITION_STOPPED, PROP_MAX_VALUE, PROP_MIN_VALUE, + SERV_DOOR, SERV_GARAGE_DOOR_OPENER, SERV_WINDOW, SERV_WINDOW_COVERING, @@ -323,6 +325,18 @@ class OpeningDevice(OpeningDeviceBase, HomeAccessory): super().async_update_state(new_state) +@TYPES.register("Door") +class Door(OpeningDevice): + """Generate a Door accessory for a cover entity. + + The entity must support: set_cover_position. + """ + + def __init__(self, *args): + """Initialize a Door accessory object.""" + super().__init__(*args, category=CATEGORY_DOOR, service=SERV_DOOR) + + @TYPES.register("Window") class Window(OpeningDevice): """Generate a Window accessory for a cover entity with WINDOW device class. 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/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index 55519fdf6f..eae7ed2742 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -305,8 +305,8 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): def set_input_source(self, value): """Send input set value if call came from HomeKit.""" _LOGGER.debug("%s: Set current input to %s", self.entity_id, value) - source = self.sources[value] - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_INPUT_SOURCE: source} + source_name = self._mapped_sources[self.sources[value]] + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_INPUT_SOURCE: source_name} self.async_call_service(DOMAIN, SERVICE_SELECT_SOURCE, params) def set_remote_key(self, value): diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index 1dfcb0f91a..69441b5ebe 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -91,6 +91,8 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): state = self.hass.states.get(self.entity_id) features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + self._mapped_sources_list = [] + self._mapped_sources = {} self.source_key = source_key self.source_list_key = source_list_key self.sources = [] @@ -103,9 +105,7 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): self.entity_id, MAXIMUM_SOURCES, ) - self.sources = [ - cleanup_name_for_homekit(source) for source in sources[:MAXIMUM_SOURCES] - ] + self.sources = sources[:MAXIMUM_SOURCES] if self.sources: self.support_select_source = True @@ -143,6 +143,15 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): serv_input.configure_char(CHAR_CURRENT_VISIBILITY_STATE, value=False) _LOGGER.debug("%s: Added source %s", self.entity_id, source) + def _get_mapped_sources(self, state: State) -> dict[str, str]: + """Return a dict of sources mapped to their homekit safe name.""" + source_list = state.attributes.get(self.source_list_key, []) + if self._mapped_sources_list != source_list: + self._mapped_sources = { + cleanup_name_for_homekit(source): source for source in source_list + } + return self._mapped_sources + def _get_ordered_source_list_from_state(self, state: State) -> list[str]: """Return ordered source list while preserving order with duplicates removed. @@ -150,13 +159,7 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): which will make the source list conflict as HomeKit requires unique source names. """ - seen = set() - sources: list[str] = [] - for source in state.attributes.get(self.source_list_key, []): - if source not in seen: - sources.append(source) - seen.add(source) - return sources + return list(self._get_mapped_sources(state)) @abstractmethod def set_on_off(self, value): @@ -185,8 +188,8 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): return possible_sources = self._get_ordered_source_list_from_state(new_state) - if source in possible_sources: - index = possible_sources.index(source) + if source_name in possible_sources: + index = possible_sources.index(source_name) if index >= MAXIMUM_SOURCES: _LOGGER.debug( "%s: Source %s and above are not supported", @@ -235,7 +238,7 @@ class ActivityRemote(RemoteInputSelectAccessory): def set_input_source(self, value): """Send input set value if call came from HomeKit.""" _LOGGER.debug("%s: Set current input to %s", self.entity_id, value) - source = self.sources[value] + source = self._mapped_sources[self.sources[value]] params = {ATTR_ENTITY_ID: self.entity_id, ATTR_ACTIVITY: source} self.async_call_service(REMOTE_DOMAIN, SERVICE_TURN_ON, params) diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index 27a1156111..240cdd888d 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -6,6 +6,7 @@ import logging from typing import NamedTuple from pyhap.const import CATEGORY_SENSOR +from pyhap.service import Service from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ( @@ -40,6 +41,8 @@ from .const import ( CHAR_SMOKE_DETECTED, CHAR_VOC_DENSITY, PROP_CELSIUS, + PROP_MAX_VALUE, + PROP_MIN_VALUE, SERV_AIR_QUALITY_SENSOR, SERV_CARBON_DIOXIDE_SENSOR, SERV_CARBON_MONOXIDE_SENSOR, @@ -281,15 +284,25 @@ class NitrogenDioxideSensor(AirQualitySensor): @TYPES.register("VolatileOrganicCompoundsSensor") class VolatileOrganicCompoundsSensor(AirQualitySensor): - """Generate a VolatileOrganicCompoundsSensor accessory as VOCs sensor.""" + """Generate a VolatileOrganicCompoundsSensor accessory as VOCs sensor. + + Sensor entity must return VOC in µg/m3. + """ def create_services(self): - """Override the init function for PM 2.5 Sensor.""" - serv_air_quality = self.add_preload_service( + """Override the init function for VOC Sensor.""" + serv_air_quality: Service = self.add_preload_service( SERV_AIR_QUALITY_SENSOR, [CHAR_VOC_DENSITY] ) self.char_quality = serv_air_quality.configure_char(CHAR_AIR_QUALITY, value=0) - self.char_density = serv_air_quality.configure_char(CHAR_VOC_DENSITY, value=0) + self.char_density = serv_air_quality.configure_char( + CHAR_VOC_DENSITY, + value=0, + properties={ + PROP_MIN_VALUE: 0, + PROP_MAX_VALUE: 5000, + }, + ) @callback def async_update_state(self, new_state): diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index b239d67877..eb2cd5d34a 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -7,7 +7,7 @@ from typing import Any from pyhap.const import CATEGORY_SENSOR from homeassistant.core import CALLBACK_TYPE, Context -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.trigger import async_initialize_triggers from .accessories import TYPES, HomeAccessory @@ -42,7 +42,7 @@ class DeviceTriggerAccessory(HomeAccessory): self._remove_triggers: CALLBACK_TYPE | None = None self.triggers = [] assert device_triggers is not None - ent_reg = entity_registry.async_get(self.hass) + ent_reg = er.async_get(self.hass) for idx, trigger in enumerate(device_triggers): type_: str = trigger["type"] subtype: str | None = trigger.get("subtype") diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 53fd9fbfed..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( @@ -447,16 +453,21 @@ def density_to_air_quality_nitrogen_dioxide(density: float) -> int: def density_to_air_quality_voc(density: float) -> int: - """Map VOCs µg/m3 to HomeKit AirQuality level.""" - if density <= 24: + """Map VOCs µg/m3 to HomeKit AirQuality level. + + The VOC mappings use the IAQ guidelines for Europe released by the WHO (World Health Organization). + Referenced from Sensirion_Gas_Sensors_SGP3x_TVOC_Concept.pdf + https://github.com/paulvha/svm30/blob/master/extras/Sensirion_Gas_Sensors_SGP3x_TVOC_Concept.pdf + """ + if density <= 250: # WHO IAQ 1 (HomeKit: Excellent) return 1 - if density <= 48: + if density <= 500: # WHO IAQ 2 (HomeKit: Good) return 2 - if density <= 64: + if density <= 1000: # WHO IAQ 3 (HomeKit: Fair) return 3 - if density <= 96: + if density <= 3000: # WHO IAQ 4 (HomeKit: Inferior) return 4 - return 5 + return 5 # WHOA IAQ 5 (HomeKit: Poor) def get_persist_filename_for_entry_id(entry_id: str) -> str: 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/homematic/light.py b/homeassistant/components/homematic/light.py index 87f3dfb314..39e6df9d0e 100644 --- a/homeassistant/components/homematic/light.py +++ b/homeassistant/components/homematic/light.py @@ -68,9 +68,9 @@ class HMLight(HMDevice, LightEntity): return ColorMode.BRIGHTNESS @property - def supported_color_modes(self) -> set[ColorMode | str]: + def supported_color_modes(self) -> set[ColorMode]: """Flag supported color modes.""" - color_modes: set[ColorMode | str] = set() + color_modes: set[ColorMode] = set() if "COLOR" in self._hmdevice.WRITENODE: color_modes.add(ColorMode.HS) diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index 055db90a68..4ea1a2fc7e 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -14,6 +14,7 @@ PLATFORMS = [ Platform.CLIMATE, Platform.COVER, Platform.LIGHT, + Platform.LOCK, Platform.SENSOR, Platform.SWITCH, Platform.WEATHER, diff --git a/homeassistant/components/homematicip_cloud/helpers.py b/homeassistant/components/homematicip_cloud/helpers.py new file mode 100644 index 0000000000..1680904bbc --- /dev/null +++ b/homeassistant/components/homematicip_cloud/helpers.py @@ -0,0 +1,39 @@ +"""Helper functions for Homematicip Cloud Integration.""" + +from functools import wraps +import json +import logging + +from homeassistant.exceptions import HomeAssistantError + +from . import HomematicipGenericEntity + +_LOGGER = logging.getLogger(__name__) + + +def is_error_response(response) -> bool: + """Response from async call contains errors or not.""" + if isinstance(response, dict): + return response.get("errorCode") not in ("", None) + + return False + + +def handle_errors(func): + """Handle async errors.""" + + @wraps(func) + async def inner(self: HomematicipGenericEntity) -> None: + """Handle errors from async call.""" + result = await func(self) + if is_error_response(result): + _LOGGER.error( + "Error while execute function %s: %s", + __name__, + json.dumps(result), + ) + raise HomeAssistantError( + f"Error while execute function {func.__name__}: {result.get('errorCode')}. See log for more information." + ) + + return inner diff --git a/homeassistant/components/homematicip_cloud/lock.py b/homeassistant/components/homematicip_cloud/lock.py new file mode 100644 index 0000000000..563f010306 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/lock.py @@ -0,0 +1,95 @@ +"""Support for HomematicIP Cloud lock devices.""" +from __future__ import annotations + +import logging +from typing import Any + +from homematicip.aio.device import AsyncDoorLockDrive +from homematicip.base.enums import LockState, MotorState + +from homeassistant.components.lock import LockEntity, LockEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity +from .helpers import handle_errors + +_LOGGER = logging.getLogger(__name__) + +ATTR_AUTO_RELOCK_DELAY = "auto_relock_delay" +ATTR_DOOR_HANDLE_TYPE = "door_handle_type" +ATTR_DOOR_LOCK_DIRECTION = "door_lock_direction" +ATTR_DOOR_LOCK_NEUTRAL_POSITION = "door_lock_neutral_position" +ATTR_DOOR_LOCK_TURNS = "door_lock_turns" + +DEVICE_DLD_ATTRIBUTES = { + "autoRelockDelay": ATTR_AUTO_RELOCK_DELAY, + "doorHandleType": ATTR_DOOR_HANDLE_TYPE, + "doorLockDirection": ATTR_DOOR_LOCK_DIRECTION, + "doorLockNeutralPosition": ATTR_DOOR_LOCK_NEUTRAL_POSITION, + "doorLockTurns": ATTR_DOOR_LOCK_TURNS, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the HomematicIP locks from a config entry.""" + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + + async_add_entities( + HomematicipDoorLockDrive(hap, device) + for device in hap.home.devices + if isinstance(device, AsyncDoorLockDrive) + ) + + +class HomematicipDoorLockDrive(HomematicipGenericEntity, LockEntity): + """Representation of the HomematicIP DoorLockDrive.""" + + _attr_supported_features = LockEntityFeature.OPEN + + @property + def is_locked(self) -> bool | None: + """Return true if device is locked.""" + return ( + self._device.lockState == LockState.LOCKED + and self._device.motorState == MotorState.STOPPED + ) + + @property + def is_locking(self) -> bool: + """Return true if device is locking.""" + return self._device.motorState == MotorState.CLOSING + + @property + def is_unlocking(self) -> bool: + """Return true if device is unlocking.""" + return self._device.motorState == MotorState.OPENING + + @handle_errors + async def async_lock(self, **kwargs: Any) -> None: + """Lock the device.""" + return await self._device.set_lock_state(LockState.LOCKED) + + @handle_errors + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the device.""" + return await self._device.set_lock_state(LockState.UNLOCKED) + + @handle_errors + async def async_open(self, **kwargs: Any) -> None: + """Open the door latch.""" + return await self._device.set_lock_state(LockState.OPEN) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the device.""" + return super().extra_state_attributes | { + attr_key: attr_value + for attr, attr_key in DEVICE_DLD_ATTRIBUTES.items() + if (attr_value := getattr(self._device, attr, None)) is not None + } 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..f5cce1d890 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -63,11 +63,11 @@ 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( - "Failed to initialize the Honeywell client: " - "Connection error: maybe you have exceeded the API rate limit?" + "Failed to initialize the Honeywell client: Connection error" ) from ex loc_id = config_entry.data.get(CONF_LOC_ID) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 3677c0f8d5..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: @@ -292,21 +294,22 @@ class HoneywellUSThermostat(ClimateEntity): hour_cool, minute_cool = divmod( self._device.raw_ui_data["CoolNextPeriod"] * 15, 60 ) - # Set hold time + # Set temporary hold time and temperature if mode in COOLING_MODES: await self._device.set_hold_cool( - datetime.time(hour_cool, minute_cool) + datetime.time(hour_cool, minute_cool), temperature ) if mode in HEATING_MODES: await self._device.set_hold_heat( - datetime.time(hour_heat, minute_heat) + datetime.time(hour_heat, minute_heat), temperature ) - # Set temperature if not in auto - if mode == "cool": - await self._device.set_setpoint_cool(temperature) - if mode == "heat": - await self._device.set_setpoint_heat(temperature) + # Set temperature if not in auto - set the temperature + else: + if mode == "cool": + await self._device.set_setpoint_cool(temperature) + if mode == "heat": + await self._device.set_setpoint_heat(temperature) except aiosomecomfort.SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) @@ -340,21 +343,15 @@ 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 if mode in COOLING_MODES: - await self._device.set_hold_cool(True) - await self._device.set_setpoint_cool(self._cool_away_temp) + await self._device.set_hold_cool(True, self._cool_away_temp) if mode in HEATING_MODES: - await self._device.set_hold_heat(True) - await self._device.set_setpoint_heat(self._heat_away_temp) + await self._device.set_hold_heat(True, self._heat_away_temp) except aiosomecomfort.SomeComfortError: _LOGGER.error( @@ -366,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,15 +414,17 @@ class HoneywellUSThermostat(ClimateEntity): """Get the latest state from the service.""" try: await self._device.refresh() - except ( - aiosomecomfort.SomeComfortError, - OSError, - ): + self._attr_available = True + except aiosomecomfort.SomeComfortError: try: await self._data.client.login() - except aiosomecomfort.SomeComfortError: + except ( + aiosomecomfort.SomeComfortError, + ClientConnectionError, + asyncio.TimeoutError, + ): self._attr_available = False - await self.hass.async_create_task( - self.hass.config_entries.async_reload(self._data.entry_id) - ) + + except (ClientConnectionError, asyncio.TimeoutError): + self._attr_available = False diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 02bb95c38f..8f3b66ddea 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["aiosomecomfort==0.0.8"] + "requirements": ["aiosomecomfort==0.0.14"] } diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index 87f3e02591..73986920b8 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -7,6 +7,13 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Honeywell integration needs to re-authenticate your account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { diff --git a/homeassistant/components/horizon/media_player.py b/homeassistant/components/horizon/media_player.py index 3a05f09501..d91fe7019d 100644 --- a/homeassistant/components/horizon/media_player.py +++ b/homeassistant/components/horizon/media_player.py @@ -142,7 +142,9 @@ class HorizonDevice(MediaPlayerEntity): else: self._attr_state = MediaPlayerState.PAUSED - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play media / switch to channel.""" if media_type == MediaType.CHANNEL: try: diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index cea362e4ba..931d446b2a 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -35,7 +35,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import ensure_unique_string -from homeassistant.util.json import load_json +from homeassistant.util.json import JsonObjectType, load_json_object from .const import DOMAIN, SERVICE_DISMISS @@ -175,9 +175,6 @@ def get_service( registrations = _load_config(json_path) - if registrations is None: - return None - vapid_pub_key = config[ATTR_VAPID_PUB_KEY] vapid_prv_key = config[ATTR_VAPID_PRV_KEY] vapid_email = config[ATTR_VAPID_EMAIL] @@ -197,10 +194,10 @@ def get_service( ) -def _load_config(filename): +def _load_config(filename: str) -> JsonObjectType: """Load configuration.""" with suppress(HomeAssistantError): - return load_json(filename) + return load_json_object(filename) return {} diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 1c201725c0..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={ @@ -460,9 +520,11 @@ class HomeAssistantHTTP: # This will now raise a RunTimeError. # To work around this we now prevent the router from getting frozen # pylint: disable-next=protected-access - self.app._router.freeze = lambda: None # type: ignore[assignment] + 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..77ae80b62f 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -6,19 +6,22 @@ from datetime import timedelta from ipaddress import ip_address import logging import secrets +import time from typing import Any, Final from aiohttp import hdrs from aiohttp.web import Application, Request, StreamResponse, middleware import jwt +from jwt import api_jws 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 from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.json import json_bytes from homeassistant.helpers.storage import Store -from homeassistant.util import dt as dt_util from homeassistant.util.network import is_local from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER @@ -59,23 +62,20 @@ def async_sign_path( refresh_token_id = hass.data[STORAGE_KEY] url = URL(path) - now = dt_util.utcnow() - params = dict(sorted(url.query.items())) - for param in SAFE_QUERY_PARAMS: - params.pop(param, None) - encoded = jwt.encode( + now_timestamp = int(time.time()) + expiration_timestamp = now_timestamp + int(expiration.total_seconds()) + params = [itm for itm in url.query.items() if itm[0] not in SAFE_QUERY_PARAMS] + json_payload = json_bytes( { "iss": refresh_token_id, "path": url.path, "params": params, - "iat": now, - "exp": now + expiration, - }, - secret, - algorithm="HS256", + "iat": now_timestamp, + "exp": expiration_timestamp, + } ) - - params[SIGN_QUERY_PARAM] = encoded + encoded = api_jws.encode(json_payload, secret, "HS256") + params.append((SIGN_QUERY_PARAM, encoded)) url = url.with_query(params) return f"{url.path}?{url.query_string}" @@ -106,11 +106,11 @@ def async_user_not_allowed_do_auth( return "User is local only" try: - remote = ip_address(request.remote) # type: ignore[arg-type] + remote_address = ip_address(request.remote) # type: ignore[arg-type] except ValueError: return "Invalid remote IP" - if is_local(remote): + if is_local(remote_address): return None return "User cannot authenticate remotely" @@ -175,7 +175,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 +184,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 57ae906317..e8e3aa4699 100644 --- a/homeassistant/components/http/security_filter.py +++ b/homeassistant/components/http/security_filter.py @@ -5,6 +5,7 @@ from collections.abc import Awaitable, Callable import logging import re from typing import Final +from urllib.parse import unquote from aiohttp.web import Application, HTTPBadRequest, Request, StreamResponse, middleware @@ -34,23 +35,47 @@ 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: """Create security filter middleware for the app.""" + def _recursive_unquote(value: str) -> str: + """Handle values that are encoded multiple times.""" + if (unquoted := unquote(value)) != value: + unquoted = _recursive_unquote(unquoted) + return unquoted + @middleware async def security_filter_middleware( request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: - """Process request and tblock commonly known exploit attempts.""" - if FILTERS.search(request.path): + """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 ) raise HTTPBadRequest - if FILTERS.search(request.query_string): + if FILTERS.search(_recursive_unquote(request.query_string)): _LOGGER.warning( "Filtered a request with a potential harmful query string: %s", request.raw_path, diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 0f66149871..95197dcbb4 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -44,7 +44,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery, - entity_registry, + entity_registry as er, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import DeviceInfo, Entity @@ -326,7 +326,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Huawei LTE component from config entry.""" url = entry.data[CONF_URL] - def get_connection() -> Connection: + def _connect() -> Connection: """Set up a connection.""" if entry.options.get(CONF_UNAUTHENTICATED_MODE): _LOGGER.debug("Connecting in unauthenticated mode, reduced feature set") @@ -341,7 +341,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return connection try: - connection = await hass.async_add_executor_job(get_connection) + connection = await hass.async_add_executor_job(_connect) except LoginErrorInvalidCredentialsException as ex: raise ConfigEntryAuthFailed from ex except Timeout as ex: @@ -359,8 +359,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Transitional from < 2021.8: update None config entry and entity unique ids if router_info and (serial_number := router_info.get("SerialNumber")): hass.config_entries.async_update_entry(entry, unique_id=serial_number) - ent_reg = entity_registry.async_get(hass) - for entity_entry in entity_registry.async_entries_for_config_entry( + ent_reg = er.async_get(hass) + for entity_entry in er.async_entries_for_config_entry( ent_reg, entry.entry_id ): if not entity_entry.unique_id.startswith("None-"): diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index d4aa170f70..f6c3b69dde 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -111,7 +111,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - async def _try_connect( + async def _connect( self, user_input: dict[str, Any], errors: dict[str, str] ) -> Connection | None: """Try connecting with given data.""" @@ -149,11 +149,11 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return conn @staticmethod - def _logout(conn: Connection) -> None: + def _disconnect(conn: Connection) -> None: try: - conn.user_session.user.logout() # type: ignore[union-attr] + conn.close() except Exception: # pylint: disable=broad-except - _LOGGER.debug("Could not logout", exc_info=True) + _LOGGER.debug("Disconnect error", exc_info=True) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -197,7 +197,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): wlan_settings = {} return device_info, wlan_settings - conn = await self._try_connect(user_input, errors) + conn = await self._connect(user_input, errors) if errors: return await self._async_show_user_form( user_input=user_input, errors=errors @@ -207,7 +207,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): info, wlan_settings = await self.hass.async_add_executor_job( get_device_info, conn ) - await self.hass.async_add_executor_job(self._logout, conn) + await self.hass.async_add_executor_job(self._disconnect, conn) user_input.update( { @@ -298,9 +298,9 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): new_data = {**entry.data, **user_input} errors: dict[str, str] = {} - conn = await self._try_connect(new_data, errors) + conn = await self._connect(new_data, errors) if conn: - await self.hass.async_add_executor_job(self._logout, conn) + await self.hass.async_add_executor_job(self._disconnect, conn) if errors: return await self._async_show_reauth_form( user_input=user_input, errors=errors diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 52d12d2000..b8833b24d9 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -66,7 +66,7 @@ async def async_setup_entry( # Initialize already tracked entities tracked: set[str] = set() - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) known_entities: list[Entity] = [] track_wired_clients = router.config_entry.options.get( CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 7052692927..f63cc4aac3 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -574,7 +574,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { {"0": "Auto", "1": "Manual"}.get(x), None, ), - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, ), }, ), @@ -596,7 +596,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { }.get(x), None, ), - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, ), }, ), diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index d87da5b5ac..2b0ebdebca 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -18,8 +18,11 @@ from homeassistant.components import zeroconf from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client, device_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + device_registry as dr, +) from homeassistant.util.network import is_ipv6_address from .const import ( @@ -75,7 +78,13 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): bridge = await discover_bridge( host, websession=aiohttp_client.async_get_clientsession(self.hass) ) - except aiohttp.ClientError: + except aiohttp.ClientError as err: + LOGGER.warning( + "Error while attempting to retrieve discovery information, " + "is there a bridge alive on IP %s ?", + host, + exc_info=err, + ) return None if bridge_id is not None: bridge_id = normalize_bridge_id(bridge_id) @@ -144,7 +153,9 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) self._async_abort_entries_match({"host": user_input["host"]}) - self.bridge = await self._get_bridge(user_input[CONF_HOST]) + if (bridge := await self._get_bridge(user_input[CONF_HOST])) is None: + return self.async_abort(reason="cannot_connect") + self.bridge = bridge return await self.async_step_link() async def async_step_link( @@ -221,9 +232,12 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) # we need to query the other capabilities too - self.bridge = await self._get_bridge( + bridge = await self._get_bridge( discovery_info.host, discovery_info.properties["bridgeid"] ) + if bridge is None: + return self.async_abort(reason="cannot_connect") + self.bridge = bridge return await self.async_step_link() async def async_step_homekit( @@ -235,7 +249,10 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): as the unique identifier. Therefore, this method uses discovery without a unique ID. """ - self.bridge = await self._get_bridge(discovery_info.host) + bridge = await self._get_bridge(discovery_info.host) + if bridge is None: + return self.async_abort(reason="cannot_connect") + self.bridge = bridge await self._async_handle_discovery_without_unique_id() return await self.async_step_link() @@ -251,7 +268,10 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Check if host exists, abort if so. self._async_abort_entries_match({"host": import_info["host"]}) - self.bridge = await self._get_bridge(import_info["host"]) + bridge = await self._get_bridge(import_info["host"]) + if bridge is None: + return self.async_abort(reason="cannot_connect") + self.bridge = bridge return await self.async_step_link() @@ -306,10 +326,8 @@ class HueV2OptionsFlowHandler(config_entries.OptionsFlow): # create a list of Hue device ID's that the user can select # to ignore availability status - dev_reg = device_registry.async_get(self.hass) - entries = device_registry.async_entries_for_config_entry( - dev_reg, self.config_entry.entry_id - ) + dev_reg = dr.async_get(self.hass) + entries = dr.async_entries_for_config_entry(dev_reg, self.config_entry.entry_id) dev_ids = { identifier[1]: entry.name for entry in entries diff --git a/homeassistant/components/hue/logbook.py b/homeassistant/components/hue/logbook.py index ce09c4c7ac..21d0da074a 100644 --- a/homeassistant/components/hue/logbook.py +++ b/homeassistant/components/hue/logbook.py @@ -35,6 +35,7 @@ TRIGGER_TYPE = { "remote_double_button_long_press": "both {subtype} released after long press", "remote_double_button_short_press": "both {subtype} released", "initial_press": "{subtype} pressed initially", + "long_press": "{subtype} long press", "repeat": "{subtype} held down", "short_release": "{subtype} released after short press", "long_release": "{subtype} released after long press", diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 7c6adc30f9..e55bd2782d 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -11,6 +11,6 @@ "iot_class": "local_push", "loggers": ["aiohue"], "quality_scale": "platinum", - "requirements": ["aiohue==4.6.1"], + "requirements": ["aiohue==4.6.2"], "zeroconf": ["_hue._tcp.local."] } diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index 8d4ca5d724..2c6c167977 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -13,9 +13,12 @@ import voluptuous as vol from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from .bridge import HueBridge from .const import DOMAIN @@ -31,7 +34,7 @@ ATTR_BRIGHTNESS = "brightness" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up scene platform from Hue group scenes.""" bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] @@ -62,7 +65,7 @@ async def async_setup_entry( ) # add platform service to turn_on/activate scene with advanced options - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( SERVICE_ACTIVATE_SCENE, { @@ -71,7 +74,7 @@ async def async_setup_entry( vol.Coerce(int), vol.Range(min=0, max=100) ), vol.Optional(ATTR_TRANSITION): vol.All( - vol.Coerce(float), vol.Range(min=0, max=600) + vol.Coerce(float), vol.Range(min=0, max=3600) ), vol.Optional(ATTR_BRIGHTNESS): vol.All( vol.Coerce(int), vol.Range(min=1, max=255) @@ -118,13 +121,14 @@ class HueSceneEntityBase(HueBaseEntity, SceneEntity): """Return device (service) info.""" # we create a virtual service/device for Hue scenes # so we have a parent for grouped lights and scenes + group_type = self.group.type.value.title() return DeviceInfo( identifiers={(DOMAIN, self.group.id)}, entry_type=DeviceEntryType.SERVICE, name=self.group.metadata.name, manufacturer=self.bridge.api.config.bridge_device.product_data.manufacturer_name, model=self.group.type.value.title(), - suggested_area=self.group.metadata.name, + suggested_area=self.group.metadata.name if group_type == "Room" else None, via_device=(DOMAIN, self.bridge.api.config.bridge_device.id), ) diff --git a/homeassistant/components/hue/services.yaml b/homeassistant/components/hue/services.yaml index 790100373f..b06c393415 100644 --- a/homeassistant/components/hue/services.yaml +++ b/homeassistant/components/hue/services.yaml @@ -39,7 +39,7 @@ activate_scene: selector: number: min: 0 - max: 300 + max: 3600 unit_of_measurement: seconds dynamic: name: Dynamic diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index c3deee4002..bc3ce49cb6 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ATTR_VIA_DEVICE, ) from homeassistant.core import callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from ..const import DOMAIN @@ -29,11 +29,11 @@ async def async_setup_devices(bridge: "HueBridge"): entry = bridge.config_entry hass = bridge.hass api: HueBridgeV2 = bridge.api # to satisfy typing - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_controller = api.devices @callback - def add_device(hue_device: Device) -> device_registry.DeviceEntry: + def add_device(hue_device: Device) -> dr.DeviceEntry: """Register a Hue device in device registry.""" model = f"{hue_device.product_data.product_name} ({hue_device.product_data.model_id})" params = { @@ -51,9 +51,7 @@ async def async_setup_devices(bridge: "HueBridge"): params[ATTR_VIA_DEVICE] = (DOMAIN, api.config.bridge_device.id) zigbee = dev_controller.get_zigbee_connectivity(hue_device.id) if zigbee and zigbee.mac_address: - params[ATTR_CONNECTIONS] = { - (device_registry.CONNECTION_NETWORK_MAC, zigbee.mac_address) - } + params[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, zigbee.mac_address)} return dev_reg.async_get_or_create(config_entry_id=entry.entry_id, **params) @@ -77,9 +75,7 @@ async def async_setup_devices(bridge: "HueBridge"): known_devices = [add_device(hue_device) for hue_device in dev_controller] # Check for nodes that no longer exist and remove them - for device in device_registry.async_entries_for_config_entry( - dev_reg, entry.entry_id - ): + for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id): if device not in known_devices: # handle case where a virtual device was created for a Hue group hue_dev_id = next(x[1] for x in device.identifiers if x[0] == DOMAIN) diff --git a/homeassistant/components/hue/v2/device_trigger.py b/homeassistant/components/hue/v2/device_trigger.py index 538509ed5c..466b593b56 100644 --- a/homeassistant/components/hue/v2/device_trigger.py +++ b/homeassistant/components/hue/v2/device_trigger.py @@ -46,6 +46,7 @@ DEFAULT_BUTTON_EVENT_TYPES = ( ButtonEvent.INITIAL_PRESS, ButtonEvent.REPEAT, ButtonEvent.SHORT_RELEASE, + ButtonEvent.LONG_PRESS, ButtonEvent.LONG_RELEASE, ) diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index 85b7046585..5878f01889 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -55,7 +55,13 @@ class HueBaseEntity(Entity): self._attr_unique_id = resource.id # device is precreated in main handler # this attaches the entity to the precreated device - if self.device is not None: + if self.device is None: + # attach all device-less entities to the bridge itself + # e.g. config based sensors like entertainment area + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, bridge.api.config.bridge.bridge_id)}, + ) + else: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.device.id)}, ) @@ -137,17 +143,14 @@ class HueBaseEntity(Entity): def _handle_event(self, event_type: EventType, resource: HueResource) -> None: """Handle status event for this resource (or it's parent).""" if event_type == EventType.RESOURCE_DELETED: - # remove any services created for zones/rooms + # handle removal of room and zone 'virtual' devices/services # regular devices are removed automatically by the logic in device.py. if resource.type in (ResourceTypes.ROOM, ResourceTypes.ZONE): dev_reg = async_get_device_registry(self.hass) if device := dev_reg.async_get_device({(DOMAIN, resource.id)}): dev_reg.async_remove_device(device.id) - if resource.type in ( - ResourceTypes.GROUPED_LIGHT, - ResourceTypes.SCENE, - ResourceTypes.SMART_SCENE, - ): + # cleanup entities that are not strictly device-bound and have the bridge as parent + if self.device is None: ent_reg = async_get_entity_registry(self.hass) ent_reg.async_remove(self.entity_id) return diff --git a/homeassistant/components/hue/v2/hue_event.py b/homeassistant/components/hue/v2/hue_event.py index 07a54e0f84..e0296bcb43 100644 --- a/homeassistant/components/hue/v2/hue_event.py +++ b/homeassistant/components/hue/v2/hue_event.py @@ -9,7 +9,7 @@ from aiohue.v2.models.relative_rotary import RelativeRotary from homeassistant.const import CONF_DEVICE_ID, CONF_ID, CONF_TYPE, CONF_UNIQUE_ID from homeassistant.core import callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.util import slugify from ..const import ATTR_HUE_EVENT, CONF_SUBTYPE, DOMAIN @@ -29,7 +29,7 @@ async def async_setup_hue_events(bridge: "HueBridge"): hass = bridge.hass api: HueBridgeV2 = bridge.api # to satisfy typing conf_entry = bridge.config_entry - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) btn_controller = api.sensors.button rotary_controller = api.sensors.relative_rotary diff --git a/homeassistant/components/huisbaasje/const.py b/homeassistant/components/huisbaasje/const.py index 9931b33a99..f908483126 100644 --- a/homeassistant/components/huisbaasje/const.py +++ b/homeassistant/components/huisbaasje/const.py @@ -8,14 +8,10 @@ from energyflip.const import ( SOURCE_TYPE_GAS, ) -from homeassistant.const import UnitOfTime, UnitOfVolume - DATA_COORDINATOR = "coordinator" DOMAIN = "huisbaasje" -FLOW_CUBIC_METERS_PER_HOUR = f"{UnitOfVolume.CUBIC_METERS}/{UnitOfTime.HOURS}" - """Interval in seconds between polls to huisbaasje.""" POLLING_INTERVAL = 20 diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index f73d4bf312..369c6eba07 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -21,7 +21,13 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ID, UnitOfEnergy, UnitOfPower, UnitOfVolume +from homeassistant.const import ( + CONF_ID, + UnitOfEnergy, + UnitOfPower, + UnitOfVolume, + UnitOfVolumeFlowRate, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -32,7 +38,6 @@ from homeassistant.helpers.update_coordinator import ( from .const import ( DATA_COORDINATOR, DOMAIN, - FLOW_CUBIC_METERS_PER_HOUR, SENSOR_TYPE_RATE, SENSOR_TYPE_THIS_DAY, SENSOR_TYPE_THIS_MONTH, @@ -179,7 +184,7 @@ SENSORS_INFO = [ ), HuisbaasjeSensorEntityDescription( name="Huisbaasje Current Gas", - native_unit_of_measurement=FLOW_CUBIC_METERS_PER_HOUR, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, sensor_type=SENSOR_TYPE_RATE, state_class=SensorStateClass.MEASUREMENT, key=SOURCE_TYPE_GAS, diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py index 773caa72f9..1c027ba22e 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -48,11 +48,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Humidifier devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = await toggle_entity.async_get_actions(hass, device_id, DOMAIN) # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/humidifier/device_condition.py b/homeassistant/components/humidifier/device_condition.py index 949b25fdd1..05812e35a3 100644 --- a/homeassistant/components/humidifier/device_condition.py +++ b/homeassistant/components/humidifier/device_condition.py @@ -15,7 +15,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -41,11 +45,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Humidifier devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = await toggle_entity.async_get_conditions(hass, device_id, DOMAIN) # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py index ed1620c51a..5fbb248a8b 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -22,7 +22,7 @@ from homeassistant.const import ( PERCENTAGE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -56,11 +56,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Humidifier devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 46e2fc1605..afaa05df46 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -20,10 +20,47 @@ "turn_off": "Turn off {entity_name}" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "name": "[%key:component::humidifier::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "available_modes": { + "name": "Available modes" + }, + "humidity": { + "name": "Target humidity" + }, + "max_humidity": { + "name": "Max target humidity" + }, + "min_humidity": { + "name": "Min target humidity" + }, + "mode": { + "name": "Mode", + "state": { + "normal": "Normal", + "eco": "Eco", + "away": "Away", + "boost": "Boost", + "comfort": "Comfort", + "home": "Home", + "sleep": "Sleep", + "auto": "Auto", + "baby": "Baby" + } + } + } + }, + "dehumidifier": { + "name": "Dehumidifier" + }, + "humidifier": { + "name": "[%key:component::humidifier::entity_component::_::name%]" } } } diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index cbdf909001..e0b381d236 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable, Coroutine +from datetime import datetime from functools import wraps import logging from typing import Any, Concatenate, ParamSpec, TypeVar @@ -139,7 +140,7 @@ async def async_setup_entry( # noqa: C901 await hass.config_entries.async_forward_entry_setups(entry, platforms) - async def _async_systems_update(now): + async def _async_systems_update(_: datetime) -> None: """Refresh internal state for all systems.""" for system in systems: prev = system.online @@ -153,6 +154,7 @@ async def async_setup_entry( # noqa: C901 system.serial, svc_exception, ) + await system.aqualink.close() else: cur = system.online if cur and not prev: @@ -160,7 +162,9 @@ async def async_setup_entry( # noqa: C901 async_dispatcher_send(hass, DOMAIN) - async_track_time_interval(hass, _async_systems_update, UPDATE_INTERVAL) + entry.async_on_unload( + async_track_time_interval(hass, _async_systems_update, UPDATE_INTERVAL) + ) return True diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index 00c9445a3b..8b83f70191 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -83,7 +83,7 @@ class HassAqualinkLight(AqualinkEntity, LightEntity): return self.dev.effect @property - def effect_list(self) -> list: + def effect_list(self) -> list[str]: """Return supported light effects.""" return list(self.dev.supported_effects) diff --git a/homeassistant/components/iaqualink/utils.py b/homeassistant/components/iaqualink/utils.py index b047af5869..87bc863a7f 100644 --- a/homeassistant/components/iaqualink/utils.py +++ b/homeassistant/components/iaqualink/utils.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable +import httpx from iaqualink.exception import AqualinkServiceException from homeassistant.exceptions import HomeAssistantError @@ -12,5 +13,5 @@ async def await_or_reraise(awaitable: Awaitable) -> None: """Execute API call while catching service exceptions.""" try: await awaitable - except AqualinkServiceException as svc_exception: + except (AqualinkServiceException, httpx.HTTPError) as svc_exception: raise HomeAssistantError(f"Aqualink error: {svc_exception}") from svc_exception diff --git a/homeassistant/components/ign_sismologia/geo_location.py b/homeassistant/components/ign_sismologia/geo_location.py index e78dafae8e..794da41ea1 100644 --- a/homeassistant/components/ign_sismologia/geo_location.py +++ b/homeassistant/components/ign_sismologia/geo_location.py @@ -141,7 +141,7 @@ class IgnSismologiaFeedEntityManager: class IgnSismologiaLocationEvent(GeolocationEvent): - """This represents an external event with IGN Sismologia feed data.""" + """Represents an external event with IGN Sismologia feed data.""" _attr_icon = "mdi:pulse" _attr_should_poll = False 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/manual_setup.py b/homeassistant/components/ihc/manual_setup.py index c14d387ba6..b4775f9193 100644 --- a/homeassistant/components/ihc/manual_setup.py +++ b/homeassistant/components/ihc/manual_setup.py @@ -12,7 +12,6 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_URL, CONF_USERNAME, - UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery @@ -81,11 +80,7 @@ LIGHT_SCHEMA = DEVICE_SCHEMA.extend( ) SENSOR_SCHEMA = DEVICE_SCHEMA.extend( - { - vol.Optional( - CONF_UNIT_OF_MEASUREMENT, default=UnitOfTemperature.CELSIUS - ): cv.string - } + {vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string} ) IHC_SCHEMA = vol.Schema( 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_processing/strings.json b/homeassistant/components/image_processing/strings.json index b635fb6aae..861a2acc1f 100644 --- a/homeassistant/components/image_processing/strings.json +++ b/homeassistant/components/image_processing/strings.json @@ -1 +1,16 @@ -{ "title": "Image processing" } +{ + "title": "Image processing", + "entity_component": { + "_": { + "name": "[%key:component::image_processing::title%]", + "state_attributes": { + "faces": { + "name": "Faces" + }, + "total_faces": { + "name": "Total faces" + } + } + } + } +} 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/__init__.py b/homeassistant/components/imap/__init__.py index 7e582aa04d..468181be5f 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -15,7 +15,11 @@ from homeassistant.exceptions import ( ) from .const import DOMAIN -from .coordinator import ImapDataUpdateCoordinator, connect_to_server +from .coordinator import ( + ImapPollingDataUpdateCoordinator, + ImapPushDataUpdateCoordinator, + connect_to_server, +) from .errors import InvalidAuth, InvalidFolder PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -32,7 +36,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (asyncio.TimeoutError, AioImapException) as err: raise ConfigEntryNotReady from err - coordinator = ImapDataUpdateCoordinator(hass, imap_client) + coordinator_class: type[ + ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator + ] + if imap_client.has_capability("IDLE"): + coordinator_class = ImapPushDataUpdateCoordinator + else: + coordinator_class = ImapPollingDataUpdateCoordinator + + coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = ( + coordinator_class(hass, imap_client) + ) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator @@ -49,6 +63,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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): - coordinator: ImapDataUpdateCoordinator = hass.data[DOMAIN].pop(entry.entry_id) + coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = hass.data[ + DOMAIN + ].pop( + entry.entry_id + ) await coordinator.shutdown() return unload_ok diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 7306d07d06..71b09048e6 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping +import ssl from typing import Any from aioimaplib import AioImapException @@ -10,21 +11,37 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult +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,36 +103,54 @@ 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) } ) if not (errors := await validate_input(user_input)): - # To be removed when YAML import is removed - title = user_input.get(CONF_NAME, user_input[CONF_USERNAME]) + title = user_input[CONF_USERNAME] return self.async_create_entry(title=title, data=user_input) - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors - ) - - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_config) + 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: """Perform reauth upon an API authentication error.""" @@ -134,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 8a716fe478..0f53c5928c 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -3,102 +3,343 @@ 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 -from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +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: - raise InvalidAuth - await client.select(data[CONF_FOLDER]) + + 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") + if client.protocol.state == AUTH: + await client.select(data[CONF_FOLDER]) if client.protocol.state != SELECTED: - raise InvalidFolder + raise InvalidFolder(f"Folder {data[CONF_FOLDER]} is invalid") return client -class ImapDataUpdateCoordinator(DataUpdateCoordinator[int]): - """Class for imap 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: str | None = None + message_html: str | None = None + message_untyped_text: str | None = 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 str(self.email_message.get_payload()) + + +class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): + """Base class for imap client.""" config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None: + def __init__( + self, + hass: HomeAssistant, + imap_client: IMAP4_SSL, + update_interval: timedelta | None, + ) -> None: """Initiate imap client.""" - self.hass = hass self.imap_client = imap_client - self.support_push = imap_client.has_capability("IDLE") + self._last_message_id: str | None = None super().__init__( hass, _LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=10) if not self.support_push else None, + update_interval=update_interval, ) - async def _async_update_data(self) -> int: - """Update the number of unread emails.""" - try: - if self.imap_client is None: - self.imap_client = await connect_to_server(self.config_entry.data) - except (AioImapException, asyncio.TimeoutError) as err: - raise UpdateFailed(err) from err + async def async_start(self) -> None: + """Start coordinator.""" - return await self.refresh_email_count() + async def _async_reconnect_if_needed(self) -> None: + """Connect to imap server.""" + if self.imap_client is None: + self.imap_client = await connect_to_server(self.config_entry.data) - async def refresh_email_count(self) -> int: - """Check the number of found emails.""" - try: - await self.imap_client.noop() - result, lines = await self.imap_client.search( - self.config_entry.data[CONF_SEARCH], - charset=self.config_entry.data[CONF_CHARSET], + 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[:2048], + "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, ) - except (AioImapException, asyncio.TimeoutError) as err: - raise UpdateFailed(err) from err + async def _async_fetch_number_of_messages(self) -> int | None: + """Fetch last message and messages count.""" + await self._async_reconnect_if_needed() + await self.imap_client.noop() + result, lines = await self.imap_client.search( + self.config_entry.data[CONF_SEARCH], + charset=self.config_entry.data[CONF_CHARSET], + ) if result != "OK": raise UpdateFailed( f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) - if self.support_push: - self.hass.async_create_task(self.async_wait_server_push()) - return len(lines[0].split()) + if not (count := len(message_ids := lines[0].split())): + self._last_message_id = None + return 0 + 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) - async def async_wait_server_push(self) -> None: - """Wait for data push from server.""" - try: - idle: asyncio.Future = await self.imap_client.idle_start() - await self.imap_client.wait_server_push() - self.imap_client.idle_done() - async with async_timeout.timeout(10): - await idle + return count - except (AioImapException, asyncio.TimeoutError): - _LOGGER.warning( - "Lost %s (will attempt to reconnect)", - self.config_entry.data[CONF_SERVER], - ) - self.imap_client = None - await self.async_request_refresh() - - async def shutdown(self, *_) -> None: + async def _cleanup(self, log_error: bool = False) -> None: """Close resources.""" if self.imap_client: - await self.imap_client.stop_wait_server_push() - await self.imap_client.logout() + try: + if self.imap_client.has_pending_idle(): + self.imap_client.idle_done() + await self.imap_client.stop_wait_server_push() + await self.imap_client.close() + await self.imap_client.logout() + except (AioImapException, asyncio.TimeoutError): + if log_error: + _LOGGER.debug("Error while cleaning up imap connection") + self.imap_client = None + + async def shutdown(self, *_: Any) -> None: + """Close resources.""" + await self._cleanup(log_error=True) + + +class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): + """Class for imap client.""" + + def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None: + """Initiate imap client.""" + super().__init__(hass, imap_client, timedelta(seconds=10)) + + async def _async_update_data(self) -> int | None: + """Update the number of unread emails.""" + try: + return await self._async_fetch_number_of_messages() + except ( + AioImapException, + UpdateFailed, + asyncio.TimeoutError, + ) as 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") + 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") + await self._cleanup() + self.async_set_update_error(ex) + raise ConfigEntryAuthFailed() from ex + + +class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): + """Class for imap client.""" + + def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None: + """Initiate imap client.""" + super().__init__(hass, imap_client, None) + self._push_wait_task: asyncio.Task[None] | None = None + + async def _async_update_data(self) -> int | None: + """Update the number of unread emails.""" + await self.async_start() + return None + + async def async_start(self) -> None: + """Start coordinator.""" + self._push_wait_task = self.hass.async_create_background_task( + self._async_wait_push_loop(), "Wait for IMAP data push" + ) + + async def _async_wait_push_loop(self) -> None: + """Wait for data push from server.""" + while True: + 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 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 asyncio.sleep(BACKOFF_TIME) + except ( + UpdateFailed, + AioImapException, + asyncio.TimeoutError, + ) as ex: + await self._cleanup() + self.async_set_update_error(ex) + await asyncio.sleep(BACKOFF_TIME) + continue + else: + self.async_set_updated_data(number_of_messages) + try: + idle: asyncio.Future = await self.imap_client.idle_start() + await self.imap_client.wait_server_push() + self.imap_client.idle_done() + async with async_timeout.timeout(10): + await idle + + except (AioImapException, asyncio.TimeoutError): + _LOGGER.debug( + "Lost %s (will attempt to reconnect after %s s)", + self.config_entry.data[CONF_SERVER], + BACKOFF_TIME, + ) + await self._cleanup() + await asyncio.sleep(BACKOFF_TIME) + + async def shutdown(self, *_: Any) -> None: + """Close resources.""" + if self._push_wait_task: + self._push_wait_task.cancel() + await super().shutdown() 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 20457209e9..776abc174a 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -1,67 +1,17 @@ """IMAP sensor support.""" from __future__ import annotations -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_USERNAME 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.helpers.update_coordinator import CoordinatorEntity -from . import ImapDataUpdateCoordinator -from .const import ( - CONF_CHARSET, - CONF_FOLDER, - CONF_SEARCH, - CONF_SERVER, - DEFAULT_PORT, - DOMAIN, -) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_SERVER): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_CHARSET, default="utf-8"): cv.string, - vol.Optional(CONF_FOLDER, default="INBOX"): cv.string, - vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the IMAP platform.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.4.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, - ) - ) +from . import ImapPollingDataUpdateCoordinator, ImapPushDataUpdateCoordinator +from .const import DOMAIN async def async_setup_entry( @@ -69,24 +19,28 @@ async def async_setup_entry( ) -> None: """Set up the Imap sensor.""" - coordinator: ImapDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = ( + hass.data[DOMAIN][entry.entry_id] + ) async_add_entities([ImapSensor(coordinator)]) -class ImapSensor(CoordinatorEntity[ImapDataUpdateCoordinator], SensorEntity): +class ImapSensor( + CoordinatorEntity[ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator], + SensorEntity, +): """Representation of an IMAP sensor.""" _attr_icon = "mdi:email-outline" _attr_has_entity_name = True - def __init__(self, coordinator: ImapDataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator, + ) -> 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)}, @@ -95,11 +49,6 @@ class ImapSensor(CoordinatorEntity[ImapDataUpdateCoordinator], SensorEntity): ) @property - def native_value(self) -> int: + def native_value(self) -> int | None: """Return the number of emails found.""" return self.coordinator.data - - async def async_update(self) -> None: - """Check for idle state before updating.""" - if not await self.coordinator.imap_client.stop_wait_server_push(): - await super().async_update() diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 25bcf840c3..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,17 +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%]" } }, - "issues": { - "deprecated_yaml": { - "title": "The IMAP YAML configuration is being removed", - "description": "Configuring IMAP using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the IMAP YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + "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 b14de63268..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) @@ -95,9 +98,25 @@ class EmailReader: self._folder = folder self._verify_ssl = verify_ssl self._last_id = None + self._last_message = None self._unread_ids = deque([]) self.connection = None + @property + def last_id(self) -> int | None: + """Return last email uid that was processed.""" + return self._last_id + + @property + def last_unread_id(self) -> int | None: + """Return last email uid received.""" + # We assume the last id in the list is the last unread id + # We cannot know if that is the newest one, because it could arrive later + # https://stackoverflow.com/questions/12409862/python-imap-the-order-of-uids + if self._unread_ids: + return int(self._unread_ids[-1]) + return self._last_id + def connect(self): """Login and setup the connection.""" ssl_context = client_context() if self._verify_ssl else None @@ -128,21 +147,21 @@ class EmailReader: try: self.connection.select(self._folder, readonly=True) - if not self._unread_ids: - search = f"SINCE {datetime.date.today():%d-%b-%Y}" - if self._last_id is not None: - search = f"UID {self._last_id}:*" - - _, data = self.connection.uid("search", None, search) - self._unread_ids = deque(data[0].split()) + if self._last_id is None: + # search for today and yesterday + time_from = datetime.datetime.now() - datetime.timedelta(days=1) + search = f"SINCE {time_from:%d-%b-%Y}" + else: + search = f"UID {self._last_id}:*" + _, data = self.connection.uid("search", None, search) + self._unread_ids = deque(data[0].split()) while self._unread_ids: message_uid = self._unread_ids.popleft() if self._last_id is None or int(message_uid) > self._last_id: self._last_id = int(message_uid) - return self._fetch_message(message_uid) - - return self._fetch_message(str(self._last_id)) + self._last_message = self._fetch_message(message_uid) + return self._last_message except imaplib.IMAP4.error: _LOGGER.info("Connection to %s lost, attempting to reconnect", self._server) @@ -254,22 +273,30 @@ class EmailContentSensor(SensorEntity): def update(self) -> None: """Read emails and publish state change.""" email_message = self._email_reader.read_next() + while ( + self._last_id is None or self._last_id != self._email_reader.last_unread_id + ): + if email_message is None: + self._message = None + self._state_attributes = {} + return - if email_message is None: - self._message = None - self._state_attributes = {} - return + self._last_id = self._email_reader.last_id - if self.sender_allowed(email_message): - message = EmailContentSensor.get_msg_subject(email_message) + if self.sender_allowed(email_message): + message = EmailContentSensor.get_msg_subject(email_message) - if self._value_template is not None: - message = self.render_template(email_message) + if self._value_template is not None: + message = self.render_template(email_message) - self._message = message - self._state_attributes = { - ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), - ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), - ATTR_DATE: email_message["Date"], - ATTR_BODY: EmailContentSensor.get_msg_text(email_message), - } + self._message = message + self._state_attributes = { + ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), + ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), + ATTR_DATE: email_message["Date"], + ATTR_BODY: EmailContentSensor.get_msg_text(email_message), + } + + if self._last_id == self._email_reader.last_unread_id: + break + email_message = self._email_reader.read_next() diff --git a/homeassistant/components/imap_email_content/strings.json b/homeassistant/components/imap_email_content/strings.json 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/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index b1b391aaaa..0205e690c4 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -28,6 +28,10 @@ class IncomfortSensorEntityDescription(SensorEntityDescription): """Describes Incomfort sensor entity.""" extra_key: str | None = None + # IncomfortSensor does not support DEVICE_CLASS_NAME + # Restrict the type to satisfy the type checker and catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + name: str | None = None SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( 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 a32958592f..d8e1e133f5 100644 --- a/homeassistant/components/input_boolean/strings.json +++ b/homeassistant/components/input_boolean/strings.json @@ -1,9 +1,21 @@ { "title": "Input boolean", - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "name": "[%key:component::input_boolean::title%]", + "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/__init__.py b/homeassistant/components/insteon/__init__.py index 3105214bad..1667f5fb77 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -24,11 +24,10 @@ from .const import ( CONF_X10, DOMAIN, INSTEON_PLATFORMS, - ON_OFF_EVENTS, ) from .schemas import convert_yaml_to_config_flow from .utils import ( - add_on_off_event_device, + add_insteon_events, async_register_services, get_device_platforms, register_new_device_callback, @@ -159,8 +158,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for address in devices: device = devices[address] platforms = get_device_platforms(device) - if ON_OFF_EVENTS in platforms: - add_on_off_event_device(hass, device) + add_insteon_events(hass, device) + if not platforms: create_insteon_device(hass, device, entry.entry_id) _LOGGER.debug("Insteon device count: %s", len(devices)) diff --git a/homeassistant/components/insteon/api/__init__.py b/homeassistant/components/insteon/api/__init__.py index b12ae993d9..fa006c6a6d 100644 --- a/homeassistant/components/insteon/api/__init__.py +++ b/homeassistant/components/insteon/api/__init__.py @@ -28,6 +28,12 @@ from .properties import ( websocket_reset_properties, websocket_write_properties, ) +from .scenes import ( + websocket_delete_scene, + websocket_get_scene, + websocket_get_scenes, + websocket_save_scene, +) URL_BASE = "/insteon_static" @@ -39,6 +45,11 @@ def async_load_api(hass): websocket_api.async_register_command(hass, websocket_add_device) websocket_api.async_register_command(hass, websocket_cancel_add_device) + websocket_api.async_register_command(hass, websocket_get_scenes) + websocket_api.async_register_command(hass, websocket_get_scene) + websocket_api.async_register_command(hass, websocket_save_scene) + websocket_api.async_register_command(hass, websocket_delete_scene) + websocket_api.async_register_command(hass, websocket_get_aldb) websocket_api.async_register_command(hass, websocket_change_aldb_record) websocket_api.async_register_command(hass, websocket_create_aldb_record) diff --git a/homeassistant/components/insteon/api/scenes.py b/homeassistant/components/insteon/api/scenes.py new file mode 100644 index 0000000000..894ae2da6a --- /dev/null +++ b/homeassistant/components/insteon/api/scenes.py @@ -0,0 +1,122 @@ +"""Web socket API for Insteon scenes.""" + +from pyinsteon import devices +from pyinsteon.constants import ResponseStatus +from pyinsteon.managers.scene_manager import ( + DeviceLinkSchema, + async_add_or_update_scene, + async_delete_scene, + async_get_scene, + async_get_scenes, +) +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant + +from ..const import ID, TYPE + + +def _scene_to_dict(scene): + """Return a dictionary mapping of a scene.""" + device_dict = {} + for addr, links in scene["devices"].items(): + str_addr = str(addr) + device_dict[str_addr] = [] + for data in links: + device_dict[str_addr].append( + { + "data1": data.data1, + "data2": data.data2, + "data3": data.data3, + "has_controller": data.has_controller, + "has_responder": data.has_responder, + } + ) + return {"name": scene["name"], "group": scene["group"], "devices": device_dict} + + +@websocket_api.websocket_command({vol.Required(TYPE): "insteon/scenes/get"}) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_scenes( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Get all Insteon scenes.""" + scenes = await async_get_scenes(work_dir=hass.config.config_dir) + scenes_dict = { + scene_num: _scene_to_dict(scene) for scene_num, scene in scenes.items() + } + connection.send_result(msg[ID], scenes_dict) + + +@websocket_api.websocket_command( + {vol.Required(TYPE): "insteon/scene/get", vol.Required("scene_id"): int} +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_scene( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Get an Insteon scene.""" + scene_id = msg["scene_id"] + scene = await async_get_scene(scene_num=scene_id, work_dir=hass.config.config_dir) + connection.send_result(msg[ID], _scene_to_dict(scene)) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/scene/save", + vol.Required("name"): str, + vol.Required("scene_id"): int, + vol.Required("links"): DeviceLinkSchema, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_save_scene( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Save an Insteon scene.""" + scene_id = msg["scene_id"] + name = msg["name"] + links = msg["links"] + + scene_id, result = await async_add_or_update_scene( + scene_num=scene_id, links=links, name=name, work_dir=hass.config.config_dir + ) + await devices.async_save(workdir=hass.config.config_dir) + connection.send_result( + msg[ID], {"scene_id": scene_id, "result": result == ResponseStatus.SUCCESS} + ) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/scene/delete", + vol.Required("scene_id"): int, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_delete_scene( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Delete an Insteon scene.""" + scene_id = msg["scene_id"] + + result = await async_delete_scene( + scene_num=scene_id, work_dir=hass.config.config_dir + ) + await devices.async_save(workdir=hass.config.config_dir) + connection.send_result( + msg[ID], {"scene_id": scene_id, "result": result == ResponseStatus.SUCCESS} + ) diff --git a/homeassistant/components/insteon/binary_sensor.py b/homeassistant/components/insteon/binary_sensor.py index 1357cbaed5..9d1ec352be 100644 --- a/homeassistant/components/insteon/binary_sensor.py +++ b/homeassistant/components/insteon/binary_sensor.py @@ -14,11 +14,11 @@ from pyinsteon.groups import ( ) from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -54,13 +54,13 @@ async def async_setup_entry( """Add the Insteon entities for the platform.""" async_add_insteon_entities( hass, - BINARY_SENSOR_DOMAIN, + Platform.BINARY_SENSOR, InsteonBinarySensorEntity, async_add_entities, discovery_info, ) - signal = f"{SIGNAL_ADD_ENTITIES}_{BINARY_SENSOR_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.BINARY_SENSOR}" async_dispatcher_connect(hass, signal, async_add_insteon_binary_sensor_entities) async_add_insteon_binary_sensor_entities() diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index f88dadf122..cf5f4ac2c0 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -9,7 +9,6 @@ from pyinsteon.constants import ThermostatMode from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, ClimateEntity, ClimateEntityFeature, @@ -17,7 +16,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -64,13 +63,13 @@ async def async_setup_entry( """Add the Insteon entities for the platform.""" async_add_insteon_entities( hass, - CLIMATE_DOMAIN, + Platform.CLIMATE, InsteonClimateEntity, async_add_entities, discovery_info, ) - signal = f"{SIGNAL_ADD_ENTITIES}_{CLIMATE_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.CLIMATE}" async_dispatcher_connect(hass, signal, async_add_insteon_climate_entities) async_add_insteon_climate_entities() diff --git a/homeassistant/components/insteon/const.py b/homeassistant/components/insteon/const.py index 5337ccd36c..6904019958 100644 --- a/homeassistant/components/insteon/const.py +++ b/homeassistant/components/insteon/const.py @@ -130,7 +130,6 @@ EVENT_GROUP_OFF = "insteon.button_off" EVENT_GROUP_ON_FAST = "insteon.button_on_fast" EVENT_GROUP_OFF_FAST = "insteon.button_off_fast" EVENT_CONF_BUTTON = "button" -ON_OFF_EVENTS = "on_off_events" STATE_NAME_LABEL_MAP = { DIMMABLE_LIGHT_MAIN: "Main", diff --git a/homeassistant/components/insteon/cover.py b/homeassistant/components/insteon/cover.py index 645450166b..69a66d304c 100644 --- a/homeassistant/components/insteon/cover.py +++ b/homeassistant/components/insteon/cover.py @@ -4,11 +4,11 @@ from typing import Any from homeassistant.components.cover import ( ATTR_POSITION, - DOMAIN as COVER_DOMAIN, CoverEntity, CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -29,10 +29,10 @@ async def async_setup_entry( def async_add_insteon_cover_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( - hass, COVER_DOMAIN, InsteonCoverEntity, async_add_entities, discovery_info + hass, Platform.COVER, InsteonCoverEntity, async_add_entities, discovery_info ) - signal = f"{SIGNAL_ADD_ENTITIES}_{COVER_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.COVER}" async_dispatcher_connect(hass, signal, async_add_insteon_cover_entities) async_add_insteon_cover_entities() diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index c7512ba027..b0d664a821 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -4,12 +4,9 @@ from __future__ import annotations import math from typing import Any -from homeassistant.components.fan import ( - DOMAIN as FAN_DOMAIN, - FanEntity, - FanEntityFeature, -) +from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -36,10 +33,10 @@ async def async_setup_entry( def async_add_insteon_fan_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( - hass, FAN_DOMAIN, InsteonFanEntity, async_add_entities, discovery_info + hass, Platform.FAN, InsteonFanEntity, async_add_entities, discovery_info ) - signal = f"{SIGNAL_ADD_ENTITIES}_{FAN_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.FAN}" async_dispatcher_connect(hass, signal, async_add_insteon_fan_entities) async_add_insteon_fan_entities() diff --git a/homeassistant/components/insteon/ipdb.py b/homeassistant/components/insteon/ipdb.py index 46302d3e6a..ee799e103f 100644 --- a/homeassistant/components/insteon/ipdb.py +++ b/homeassistant/components/insteon/ipdb.py @@ -4,21 +4,19 @@ from pyinsteon.device_types.ipdb import ( ClimateControl_Thermostat, ClimateControl_WirelessThermostat, DimmableLightingControl, + DimmableLightingControl_Dial, DimmableLightingControl_DinRail, DimmableLightingControl_FanLinc, - DimmableLightingControl_InLineLinc, + DimmableLightingControl_InLineLinc01, + DimmableLightingControl_InLineLinc02, DimmableLightingControl_KeypadLinc_6, DimmableLightingControl_KeypadLinc_8, DimmableLightingControl_LampLinc, DimmableLightingControl_OutletLinc, - DimmableLightingControl_SwitchLinc, + DimmableLightingControl_SwitchLinc01, + DimmableLightingControl_SwitchLinc02, DimmableLightingControl_ToggleLinc, EnergyManagement_LoadController, - GeneralController_ControlLinc, - GeneralController_MiniRemote_4, - GeneralController_MiniRemote_8, - GeneralController_MiniRemote_Switch, - GeneralController_RemoteLinc, SecurityHealthSafety_DoorSensor, SecurityHealthSafety_LeakSensor, SecurityHealthSafety_MotionSensor, @@ -28,12 +26,15 @@ from pyinsteon.device_types.ipdb import ( SwitchedLightingControl, SwitchedLightingControl_ApplianceLinc, SwitchedLightingControl_DinRail, - SwitchedLightingControl_InLineLinc, + SwitchedLightingControl_I3Outlet, + SwitchedLightingControl_InLineLinc01, + SwitchedLightingControl_InLineLinc02, SwitchedLightingControl_KeypadLinc_6, SwitchedLightingControl_KeypadLinc_8, SwitchedLightingControl_OnOffOutlet, SwitchedLightingControl_OutletLinc, - SwitchedLightingControl_SwitchLinc, + SwitchedLightingControl_SwitchLinc01, + SwitchedLightingControl_SwitchLinc02, SwitchedLightingControl_ToggleLinc, WindowCovering, X10Dimmable, @@ -41,70 +42,62 @@ from pyinsteon.device_types.ipdb import ( X10OnOffSensor, ) -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR -from homeassistant.components.climate import DOMAIN as CLIMATE -from homeassistant.components.cover import DOMAIN as COVER -from homeassistant.components.fan import DOMAIN as FAN -from homeassistant.components.light import DOMAIN as LIGHT -from homeassistant.components.lock import DOMAIN as LOCK -from homeassistant.components.switch import DOMAIN as SWITCH - -from .const import ON_OFF_EVENTS +from homeassistant.const import Platform DEVICE_PLATFORM = { - AccessControl_Morningstar: {LOCK: [1]}, - DimmableLightingControl: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_DinRail: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_FanLinc: {LIGHT: [1], FAN: [2], ON_OFF_EVENTS: [1, 2]}, - DimmableLightingControl_InLineLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + AccessControl_Morningstar: {Platform.LOCK: [1]}, + DimmableLightingControl: {Platform.LIGHT: [1]}, + DimmableLightingControl_Dial: {Platform.LIGHT: [1]}, + DimmableLightingControl_DinRail: {Platform.LIGHT: [1]}, + DimmableLightingControl_FanLinc: {Platform.LIGHT: [1], Platform.FAN: [2]}, + DimmableLightingControl_InLineLinc01: {Platform.LIGHT: [1]}, + DimmableLightingControl_InLineLinc02: {Platform.LIGHT: [1]}, DimmableLightingControl_KeypadLinc_6: { - LIGHT: [1], - SWITCH: [3, 4, 5, 6], - ON_OFF_EVENTS: [1, 3, 4, 5, 6], + Platform.LIGHT: [1], + Platform.SWITCH: [3, 4, 5, 6], }, DimmableLightingControl_KeypadLinc_8: { - LIGHT: [1], - SWITCH: range(2, 9), - ON_OFF_EVENTS: range(1, 9), + Platform.LIGHT: [1], + Platform.SWITCH: range(2, 9), }, - DimmableLightingControl_LampLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_OutletLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_SwitchLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_ToggleLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - EnergyManagement_LoadController: {SWITCH: [1], BINARY_SENSOR: [2]}, - GeneralController_ControlLinc: {ON_OFF_EVENTS: [1]}, - GeneralController_MiniRemote_4: {ON_OFF_EVENTS: range(1, 5)}, - GeneralController_MiniRemote_8: {ON_OFF_EVENTS: range(1, 9)}, - GeneralController_MiniRemote_Switch: {ON_OFF_EVENTS: [1, 2]}, - GeneralController_RemoteLinc: {ON_OFF_EVENTS: [1]}, - SecurityHealthSafety_DoorSensor: {BINARY_SENSOR: [1, 3, 4], ON_OFF_EVENTS: [1]}, - SecurityHealthSafety_LeakSensor: {BINARY_SENSOR: [2, 4]}, - SecurityHealthSafety_MotionSensor: {BINARY_SENSOR: [1, 2, 3], ON_OFF_EVENTS: [1]}, - SecurityHealthSafety_OpenCloseSensor: {BINARY_SENSOR: [1]}, - SecurityHealthSafety_Smokebridge: {BINARY_SENSOR: [1, 2, 3, 4, 6, 7]}, - SensorsActuators_IOLink: {SWITCH: [1], BINARY_SENSOR: [2], ON_OFF_EVENTS: [1, 2]}, - SwitchedLightingControl: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_ApplianceLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_DinRail: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_InLineLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_LampLinc: {Platform.LIGHT: [1]}, + DimmableLightingControl_OutletLinc: {Platform.LIGHT: [1]}, + DimmableLightingControl_SwitchLinc01: {Platform.LIGHT: [1]}, + DimmableLightingControl_SwitchLinc02: {Platform.LIGHT: [1]}, + DimmableLightingControl_ToggleLinc: {Platform.LIGHT: [1]}, + EnergyManagement_LoadController: { + Platform.SWITCH: [1], + Platform.BINARY_SENSOR: [2], + }, + SecurityHealthSafety_DoorSensor: {Platform.BINARY_SENSOR: [1, 3, 4]}, + SecurityHealthSafety_LeakSensor: {Platform.BINARY_SENSOR: [2, 4]}, + SecurityHealthSafety_MotionSensor: {Platform.BINARY_SENSOR: [1, 2, 3]}, + SecurityHealthSafety_OpenCloseSensor: {Platform.BINARY_SENSOR: [1]}, + SecurityHealthSafety_Smokebridge: {Platform.BINARY_SENSOR: [1, 2, 3, 4, 6, 7]}, + SensorsActuators_IOLink: {Platform.SWITCH: [1], Platform.BINARY_SENSOR: [2]}, + SwitchedLightingControl: {Platform.SWITCH: [1]}, + SwitchedLightingControl_ApplianceLinc: {Platform.SWITCH: [1]}, + SwitchedLightingControl_DinRail: {Platform.SWITCH: [1]}, + SwitchedLightingControl_I3Outlet: {Platform.SWITCH: [1, 2]}, + SwitchedLightingControl_InLineLinc01: {Platform.SWITCH: [1]}, + SwitchedLightingControl_InLineLinc02: {Platform.SWITCH: [1]}, SwitchedLightingControl_KeypadLinc_6: { - SWITCH: [1, 3, 4, 5, 6], - ON_OFF_EVENTS: [1, 3, 4, 5, 6], + Platform.SWITCH: [1, 3, 4, 5, 6], }, SwitchedLightingControl_KeypadLinc_8: { - SWITCH: range(1, 9), - ON_OFF_EVENTS: range(1, 9), + Platform.SWITCH: range(1, 9), }, - SwitchedLightingControl_OnOffOutlet: {SWITCH: [1, 2], ON_OFF_EVENTS: [1, 2]}, - SwitchedLightingControl_OutletLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_SwitchLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_ToggleLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - ClimateControl_Thermostat: {CLIMATE: [1]}, - ClimateControl_WirelessThermostat: {CLIMATE: [1]}, - WindowCovering: {COVER: [1]}, - X10Dimmable: {LIGHT: [1]}, - X10OnOff: {SWITCH: [1]}, - X10OnOffSensor: {BINARY_SENSOR: [1]}, + SwitchedLightingControl_OnOffOutlet: {Platform.SWITCH: [1, 2]}, + SwitchedLightingControl_OutletLinc: {Platform.SWITCH: [1]}, + SwitchedLightingControl_SwitchLinc01: {Platform.SWITCH: [1]}, + SwitchedLightingControl_SwitchLinc02: {Platform.SWITCH: [1]}, + SwitchedLightingControl_ToggleLinc: {Platform.SWITCH: [1]}, + ClimateControl_Thermostat: {Platform.CLIMATE: [1]}, + ClimateControl_WirelessThermostat: {Platform.CLIMATE: [1]}, + WindowCovering: {Platform.COVER: [1]}, + X10Dimmable: {Platform.LIGHT: [1]}, + X10OnOff: {Platform.SWITCH: [1]}, + X10OnOffSensor: {Platform.BINARY_SENSOR: [1]}, } diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py index 579945e6f8..44574c696b 100644 --- a/homeassistant/components/insteon/light.py +++ b/homeassistant/components/insteon/light.py @@ -3,13 +3,9 @@ from typing import Any from pyinsteon.config import ON_LEVEL -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - DOMAIN as LIGHT_DOMAIN, - ColorMode, - LightEntity, -) +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -32,10 +28,14 @@ async def async_setup_entry( def async_add_insteon_light_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( - hass, LIGHT_DOMAIN, InsteonDimmerEntity, async_add_entities, discovery_info + hass, + Platform.LIGHT, + InsteonDimmerEntity, + async_add_entities, + discovery_info, ) - signal = f"{SIGNAL_ADD_ENTITIES}_{LIGHT_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.LIGHT}" async_dispatcher_connect(hass, signal, async_add_insteon_light_entities) async_add_insteon_light_entities() diff --git a/homeassistant/components/insteon/lock.py b/homeassistant/components/insteon/lock.py index 17a7cf2011..75487e7696 100644 --- a/homeassistant/components/insteon/lock.py +++ b/homeassistant/components/insteon/lock.py @@ -2,8 +2,9 @@ from typing import Any -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity +from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -24,10 +25,10 @@ async def async_setup_entry( def async_add_insteon_lock_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( - hass, LOCK_DOMAIN, InsteonLockEntity, async_add_entities, discovery_info + hass, Platform.LOCK, InsteonLockEntity, async_add_entities, discovery_info ) - signal = f"{SIGNAL_ADD_ENTITIES}_{LOCK_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.LOCK}" async_dispatcher_connect(hass, signal, async_add_insteon_lock_entities) async_add_insteon_lock_entities() diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 60933962cc..cc8495384b 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -17,8 +17,8 @@ "iot_class": "local_push", "loggers": ["pyinsteon", "pypubsub"], "requirements": [ - "pyinsteon==1.3.1", - "insteon-frontend-home-assistant==0.2.0" + "pyinsteon==1.4.2", + "insteon-frontend-home-assistant==0.3.5" ], "usb": [ { diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index 6bcde545e3..785aa90dd4 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -170,6 +170,18 @@ TRIGGER_SCENE_SCHEMA = vol.Schema( ADD_DEFAULT_LINKS_SCHEMA = vol.Schema({vol.Required(CONF_ENTITY_ID): cv.entity_id}) +SCENE_ENTITY_SCHEMA = vol.Schema( + [ + { + vol.Required(CONF_ADDRESS): str, + vol.Required("data1"): int, + vol.Required("data2"): int, + vol.Required("data3"): int, + } + ] +) + + def normalize_byte_entry_to_int(entry: int | bytes | str): """Format a hex entry value.""" if isinstance(entry, int): diff --git a/homeassistant/components/insteon/switch.py b/homeassistant/components/insteon/switch.py index d9a15d383c..8f7c396f21 100644 --- a/homeassistant/components/insteon/switch.py +++ b/homeassistant/components/insteon/switch.py @@ -1,8 +1,9 @@ """Support for INSTEON dimmers via PowerLinc Modem.""" from typing import Any -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity +from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -23,10 +24,14 @@ async def async_setup_entry( def async_add_insteon_switch_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( - hass, SWITCH_DOMAIN, InsteonSwitchEntity, async_add_entities, discovery_info + hass, + Platform.SWITCH, + InsteonSwitchEntity, + async_add_entities, + discovery_info, ) - signal = f"{SIGNAL_ADD_ENTITIES}_{SWITCH_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.SWITCH}" async_dispatcher_connect(hass, signal, async_add_insteon_switch_entities) async_add_insteon_switch_entities() diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index c5dbba9c25..8915342d2e 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -1,11 +1,13 @@ """Utilities used by insteon component.""" import asyncio +from collections.abc import Callable import logging from pyinsteon import devices from pyinsteon.address import Address from pyinsteon.constants import ALDBStatus, DeviceAction -from pyinsteon.events import OFF_EVENT, OFF_FAST_EVENT, ON_EVENT, ON_FAST_EVENT +from pyinsteon.device_types.device_base import Device +from pyinsteon.events import OFF_EVENT, OFF_FAST_EVENT, ON_EVENT, ON_FAST_EVENT, Event from pyinsteon.managers.link_manager import ( async_enter_linking_mode, async_enter_unlinking_mode, @@ -27,7 +29,7 @@ from homeassistant.const import ( CONF_PLATFORM, ENTITY_MATCH_ALL, ) -from homeassistant.core import ServiceCall, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -47,7 +49,6 @@ from .const import ( EVENT_GROUP_OFF_FAST, EVENT_GROUP_ON, EVENT_GROUP_ON_FAST, - ON_OFF_EVENTS, SIGNAL_ADD_DEFAULT_LINKS, SIGNAL_ADD_DEVICE_OVERRIDE, SIGNAL_ADD_ENTITIES, @@ -89,49 +90,52 @@ from .schemas import ( _LOGGER = logging.getLogger(__name__) -def add_on_off_event_device(hass, device): - """Register an Insteon device as an on/off event device.""" +def _register_event(event: Event, listener: Callable) -> None: + """Register the events raised by a device.""" + _LOGGER.debug( + "Registering on/off event for %s %d %s", + str(event.address), + event.group, + event.name, + ) + event.subscribe(listener, force_strong_ref=True) + + +def add_insteon_events(hass: HomeAssistant, device: Device) -> None: + """Register Insteon device events.""" @callback - def async_fire_group_on_off_event(name, address, group, button): + def async_fire_group_on_off_event( + name: str, address: Address, group: int, button: str + ): # Firing an event when a button is pressed. if button and button[-2] == "_": button_id = button[-1].lower() else: button_id = None - schema = {CONF_ADDRESS: address} + schema = {CONF_ADDRESS: address, "group": group} if button_id: schema[EVENT_CONF_BUTTON] = button_id if name == ON_EVENT: event = EVENT_GROUP_ON - if name == OFF_EVENT: + elif name == OFF_EVENT: event = EVENT_GROUP_OFF - if name == ON_FAST_EVENT: + elif name == ON_FAST_EVENT: event = EVENT_GROUP_ON_FAST - if name == OFF_FAST_EVENT: + elif name == OFF_FAST_EVENT: event = EVENT_GROUP_OFF_FAST + else: + event = f"insteon.{name}" _LOGGER.debug("Firing event %s with %s", event, schema) hass.bus.async_fire(event, schema) - for group in device.events: - if isinstance(group, int): - for event in device.events[group]: - if event in [ - OFF_EVENT, - ON_EVENT, - OFF_FAST_EVENT, - ON_FAST_EVENT, - ]: - _LOGGER.debug( - "Registering on/off event for %s %d %s", - str(device.address), - group, - event, - ) - device.events[group][event].subscribe( - async_fire_group_on_off_event, force_strong_ref=True - ) + for name_or_group, event in device.events.items(): + if isinstance(name_or_group, int): + for _, event in device.events[name_or_group].items(): + _register_event(event, async_fire_group_on_off_event) + else: + _register_event(event, async_fire_group_on_off_event) def register_new_device_callback(hass): @@ -152,12 +156,8 @@ def register_new_device_callback(hass): await device.async_status() platforms = get_device_platforms(device) for platform in platforms: - if platform == ON_OFF_EVENTS: - add_on_off_event_device(hass, device) - - else: - signal = f"{SIGNAL_ADD_ENTITIES}_{platform}" - dispatcher_send(hass, signal, {"address": device.address}) + signal = f"{SIGNAL_ADD_ENTITIES}_{platform}" + dispatcher_send(hass, signal, {"address": device.address}) devices.subscribe(async_new_insteon_device, force_strong_ref=True) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 54e50b7b1d..d55a113664 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -174,29 +174,38 @@ class IntegrationSensor(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(): - try: - self._state = Decimal(state.state) - except (DecimalException, ValueError) as err: - _LOGGER.warning( - "%s could not restore last state %s: %s", - self.entity_id, - state.state, - err, - ) - else: - self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) - if self._unit_of_measurement is None: - self._unit_of_measurement = state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT + if (state := await self.async_get_last_state()) is not None: + if state.state == STATE_UNAVAILABLE: + self._attr_available = False + elif state.state != STATE_UNKNOWN: + try: + self._state = Decimal(state.state) + except (DecimalException, ValueError) as err: + _LOGGER.warning( + "%s could not restore last state %s: %s", + self.entity_id, + state.state, + err, ) + self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) + self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + @callback def calc_integration(event: Event) -> None: """Handle the sensor state changes.""" 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/intellifire/binary_sensor.py b/homeassistant/components/intellifire/binary_sensor.py index d189a09a73..5a7407836f 100644 --- a/homeassistant/components/intellifire/binary_sensor.py +++ b/homeassistant/components/intellifire/binary_sensor.py @@ -44,25 +44,25 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] ), IntellifireBinarySensorEntityDescription( key="timer_on", - name="Timer On", + name="Timer on", icon="mdi:camera-timer", value_fn=lambda data: data.timer_on, ), IntellifireBinarySensorEntityDescription( key="pilot_light_on", - name="Pilot Light On", + name="Pilot light on", icon="mdi:fire-alert", value_fn=lambda data: data.pilot_on, ), IntellifireBinarySensorEntityDescription( key="thermostat_on", - name="Thermostat On", + name="Thermostat on", icon="mdi:home-thermometer-outline", value_fn=lambda data: data.thermostat_on, ), IntellifireBinarySensorEntityDescription( key="error_pilot_flame", - name="Pilot Flame Error", + name="Pilot flame error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_pilot_flame, device_class=BinarySensorDeviceClass.PROBLEM, @@ -76,7 +76,7 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] ), IntellifireBinarySensorEntityDescription( key="error_fan_delay", - name="Fan Delay Error", + name="Fan delay error", icon="mdi:fan-alert", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_fan_delay, @@ -84,21 +84,21 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] ), IntellifireBinarySensorEntityDescription( key="error_maintenance", - name="Maintenance Error", + name="Maintenance error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_maintenance, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_disabled", - name="Disabled Error", + name="Disabled error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_disabled, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_fan", - name="Fan Error", + name="Fan error", icon="mdi:fan-alert", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_fan, @@ -106,35 +106,35 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] ), IntellifireBinarySensorEntityDescription( key="error_lights", - name="Lights Error", + name="Lights error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_lights, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_accessory", - name="Accessory Error", + name="Accessory error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_accessory, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_soft_lock_out", - name="Soft Lock Out Error", + name="Soft lock out error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_soft_lock_out, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_ecm_offline", - name="ECM Offline Error", + name="ECM offline error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_ecm_offline, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_offline", - name="Offline Error", + name="Offline error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_offline, device_class=BinarySensorDeviceClass.PROBLEM, diff --git a/homeassistant/components/intellifire/coordinator.py b/homeassistant/components/intellifire/coordinator.py index b6753adef7..5003ed9143 100644 --- a/homeassistant/components/intellifire/coordinator.py +++ b/homeassistant/components/intellifire/coordinator.py @@ -67,7 +67,7 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData return DeviceInfo( manufacturer="Hearth and Home", model="IFT-WFM", - name="IntelliFire Fireplace", + name="IntelliFire", identifiers={("IntelliFire", f"{self.read_api.data.serial}]")}, sw_version=self.read_api.data.fw_ver_str, configuration_url=f"http://{self._api.fireplace_ip}/poll", diff --git a/homeassistant/components/intellifire/entity.py b/homeassistant/components/intellifire/entity.py index 3c427250f1..6ef63f5347 100644 --- a/homeassistant/components/intellifire/entity.py +++ b/homeassistant/components/intellifire/entity.py @@ -11,6 +11,7 @@ class IntellifireEntity(CoordinatorEntity[IntellifireDataUpdateCoordinator]): """Define a generic class for Intellifire entities.""" _attr_attribution = "Data provided by unpublished Intellifire API" + _attr_has_entity_name = True def __init__( self, @@ -20,8 +21,6 @@ class IntellifireEntity(CoordinatorEntity[IntellifireDataUpdateCoordinator]): """Class initializer.""" super().__init__(coordinator=coordinator) self.entity_description = description - # Set the Display name the User will see - self._attr_name = f"Fireplace {description.name}" self._attr_unique_id = f"{description.key}_{coordinator.read_api.data.serial}" # Configure the Device Info self._attr_device_info = self.coordinator.device_info diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index 0f43856938..debc8237fc 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -46,7 +46,6 @@ INTELLIFIRE_FANS: tuple[IntellifireFanEntityDescription, ...] = ( IntellifireFanEntityDescription( key="fan", name="Fan", - has_entity_name=True, set_fn=lambda control_api, speed: control_api.set_fan_speed(speed=speed), value_fn=lambda data: data.fanspeed, speed_range=(1, 4), @@ -72,7 +71,7 @@ async def async_setup_entry( class IntellifireFan(IntellifireEntity, FanEntity): - """This is Fan entity for the fireplace.""" + """Fan entity for the fireplace.""" entity_description: IntellifireFanEntityDescription _attr_supported_features = FanEntityFeature.SET_SPEED diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py index f1fd81ab45..383d61b8d4 100644 --- a/homeassistant/components/intellifire/light.py +++ b/homeassistant/components/intellifire/light.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, LOGGER from .coordinator import IntellifireDataUpdateCoordinator from .entity import IntellifireEntity @@ -41,7 +41,6 @@ INTELLIFIRE_LIGHTS: tuple[IntellifireLightEntityDescription, ...] = ( IntellifireLightEntityDescription( key="lights", name="Lights", - has_entity_name=True, set_fn=lambda control_api, level: control_api.set_lights(level=level), value_fn=lambda data: data.light_level, ), @@ -49,7 +48,7 @@ INTELLIFIRE_LIGHTS: tuple[IntellifireLightEntityDescription, ...] = ( class IntellifireLight(IntellifireEntity, LightEntity): - """This is a Light entity for the fireplace.""" + """Light entity for the fireplace.""" entity_description: IntellifireLightEntityDescription _attr_color_mode = ColorMode.BRIGHTNESS @@ -95,3 +94,4 @@ async def async_setup_entry( for description in INTELLIFIRE_LIGHTS ) return + LOGGER.debug("Disabling Lights - IntelliFire device does not appear to have one") diff --git a/homeassistant/components/intellifire/sensor.py b/homeassistant/components/intellifire/sensor.py index 12f66a3f27..e888ea1bbc 100644 --- a/homeassistant/components/intellifire/sensor.py +++ b/homeassistant/components/intellifire/sensor.py @@ -57,7 +57,7 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( IntellifireSensorEntityDescription( key="flame_height", icon="mdi:fire-circle", - name="Flame Height", + name="Flame height", state_class=SensorStateClass.MEASUREMENT, # UI uses 1-5 for flame height, backing lib uses 0-4 value_fn=lambda data: (data.flameheight + 1), @@ -72,7 +72,7 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( ), IntellifireSensorEntityDescription( key="target_temp", - name="Target Temperature", + name="Target temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -116,7 +116,7 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( ), IntellifireSensorEntityDescription( key="ecm_latency", - name="ECM Latency", + name="ECM latency", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.ecm_latency, entity_registry_enabled_default=False, diff --git a/homeassistant/components/intellifire/switch.py b/homeassistant/components/intellifire/switch.py index ef0363696c..98abaa3884 100644 --- a/homeassistant/components/intellifire/switch.py +++ b/homeassistant/components/intellifire/switch.py @@ -44,7 +44,7 @@ INTELLIFIRE_SWITCHES: tuple[IntellifireSwitchEntityDescription, ...] = ( ), IntellifireSwitchEntityDescription( key="pilot", - name="Pilot Light", + name="Pilot light", icon="mdi:fire-alert", on_fn=lambda control_api: control_api.pilot_on(), off_fn=lambda control_api: control_api.pilot_off(), diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index a52f4897d2..2f5ea26a8a 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, State from homeassistant.helpers import ( - area_registry, + area_registry as ar, config_validation as cv, integration_platform, intent, @@ -109,9 +109,9 @@ class GetStateIntentHandler(intent.IntentHandler): # Look up area first to fail early area_name = slots.get("area", {}).get("value") - area: area_registry.AreaEntry | None = None + area: ar.AreaEntry | None = None if area_name is not None: - areas = area_registry.async_get(hass) + areas = ar.async_get(hass) area = areas.async_get_area(area_name) or areas.async_get_area_by_name( area_name ) @@ -140,16 +140,18 @@ class GetStateIntentHandler(intent.IntentHandler): area=area, domains=domains, device_classes=device_classes, + assistant=intent_obj.assistant, ) ) _LOGGER.debug( - "Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s", + "Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s, assistant=%s", len(states), name, area, domains, device_classes, + intent_obj.assistant, ) # Create response diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 128c9332ae..2ec898bfb0 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -1,7 +1,6 @@ """Handle intents with scripts.""" from __future__ import annotations -import copy import logging import voluptuous as vol @@ -57,8 +56,8 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Activate Alexa component.""" - intents = copy.deepcopy(config[DOMAIN]) + """Set up the intent script component.""" + intents = config[DOMAIN] template.attach(hass, intents) for intent_type, conf in intents.items(): diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index e28c1a218e..052ed9f94a 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -1,7 +1,6 @@ """Native Home Assistant iOS app component.""" import datetime from http import HTTPStatus -from typing import TYPE_CHECKING import voluptuous as vol @@ -14,7 +13,7 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType -from homeassistant.util.json import load_json +from homeassistant.util.json import load_json_object from .const import ( CONF_ACTION_BACKGROUND_COLOR, @@ -252,22 +251,19 @@ def device_name_for_push_id(hass, push_id): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the iOS component.""" - conf = config.get(DOMAIN) + conf: ConfigType | None = config.get(DOMAIN) ios_config = await hass.async_add_executor_job( - load_json, hass.config.path(CONFIGURATION_FILE) + load_json_object, hass.config.path(CONFIGURATION_FILE) ) - if TYPE_CHECKING: - assert isinstance(ios_config, dict) - if ios_config == {}: ios_config[ATTR_DEVICES] = {} - ios_config[CONF_USER] = conf or {} + if CONF_PUSH not in (conf_user := conf or {}): + conf_user[CONF_PUSH] = {} - if CONF_PUSH not in ios_config[CONF_USER]: - ios_config[CONF_USER][CONF_PUSH] = {} + ios_config[CONF_USER] = conf_user hass.data[DOMAIN] = ios_config diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 0870e2234d..849a2055ce 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -24,8 +24,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity, entity_registry -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers import device_registry as dr, entity, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -186,7 +185,9 @@ class IotaWattSensor(CoordinatorEntity[IotawattUpdater], SensorEntity): def device_info(self) -> entity.DeviceInfo: """Return device info.""" return entity.DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._sensor_data.hub_mac_address)}, + connections={ + (dr.CONNECTION_NETWORK_MAC, self._sensor_data.hub_mac_address) + }, manufacturer="IoTaWatt", model="IoTaWatt", ) @@ -196,7 +197,7 @@ class IotaWattSensor(CoordinatorEntity[IotawattUpdater], SensorEntity): """Handle updated data from the coordinator.""" if self._key not in self.coordinator.data["sensors"]: if self._attr_unique_id: - entity_registry.async_get(self.hass).async_remove(self.entity_id) + er.async_get(self.hass).async_remove(self.entity_id) else: self.hass.async_create_task(self.async_remove()) return diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index 8e46bf27d5..bfd1b820c7 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -43,7 +43,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import Throttle @@ -89,7 +89,7 @@ async def async_setup_entry( # Migrate old unique_id @callback - def _async_migrator(entity_entry: entity_registry.RegistryEntry): + def _async_migrator(entity_entry: er.RegistryEntry): # Reject if new unique_id if entity_entry.unique_id.count(",") == 2: return None @@ -105,9 +105,7 @@ async def async_setup_entry( ) return {"new_unique_id": new_unique_id} - await entity_registry.async_migrate_entries( - hass, config_entry.entry_id, _async_migrator - ) + await er.async_migrate_entries(hass, config_entry.entry_id, _async_migrator) async_add_entities([IPMAWeather(location, api, config_entry.data)], True) 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 7fd5ed4129..d8810b0ad4 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -1,22 +1,13 @@ """The islamic_prayer_times component.""" -from datetime import timedelta -import logging - -from prayer_times_calculator import PrayerTimesCalculator, exceptions -from requests.exceptions import ConnectionError as ConnError +from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_call_later, async_track_point_in_time -import homeassistant.util.dt as dt_util -from .const import CONF_CALC_METHOD, DATA_UPDATED, DEFAULT_CALC_METHOD, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .coordinator import IslamicPrayerDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -25,154 +16,32 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Islamic Prayer Component.""" - client = IslamicPrayerClient(hass, config_entry) - hass.data[DOMAIN] = client - await client.async_setup() + coordinator = IslamicPrayerDataUpdateCoordinator(hass) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, coordinator) + config_entry.async_on_unload( + config_entry.add_update_listener(async_options_updated) + ) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Islamic Prayer entry from config_entry.""" - if hass.data[DOMAIN].event_unsub: - hass.data[DOMAIN].event_unsub() - hass.data.pop(DOMAIN) - return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ): + coordinator: IslamicPrayerDataUpdateCoordinator = hass.data.pop(DOMAIN) + if coordinator.event_unsub: + coordinator.event_unsub() + return unload_ok -class IslamicPrayerClient: - """Islamic Prayer Client Object.""" - - def __init__(self, hass, config_entry): - """Initialize the Islamic Prayer client.""" - self.hass = hass - self.config_entry = config_entry - self.prayer_times_info = {} - self.available = True - self.event_unsub = None - - @property - def calc_method(self): - """Return the calculation method.""" - return self.config_entry.options[CONF_CALC_METHOD] - - def get_new_prayer_times(self): - """Fetch prayer times for today.""" - calc = PrayerTimesCalculator( - latitude=self.hass.config.latitude, - longitude=self.hass.config.longitude, - calculation_method=self.calc_method, - date=str(dt_util.now().date()), - ) - return calc.fetch_prayer_times() - - async def async_schedule_future_update(self): - """Schedule future update for sensors. - - Midnight is a calculated time. The specifics of the calculation - depends on the method of the prayer time calculation. This calculated - midnight is the time at which the time to pray the Isha prayers have - expired. - - Calculated Midnight: The Islamic midnight. - Traditional Midnight: 12:00AM - - Update logic for prayer times: - - If the Calculated Midnight is before the traditional midnight then wait - until the traditional midnight to run the update. This way the day - will have changed over and we don't need to do any fancy calculations. - - If the Calculated Midnight is after the traditional midnight, then wait - until after the calculated Midnight. We don't want to update the prayer - times too early or else the timings might be incorrect. - - Example: - calculated midnight = 11:23PM (before traditional midnight) - Update time: 12:00AM - - calculated midnight = 1:35AM (after traditional midnight) - update time: 1:36AM. - - """ - _LOGGER.debug("Scheduling next update for Islamic prayer times") - - now = dt_util.utcnow() - - midnight_dt = self.prayer_times_info["Midnight"] - - if now > dt_util.as_utc(midnight_dt): - next_update_at = midnight_dt + timedelta(days=1, minutes=1) - _LOGGER.debug( - "Midnight is after day the changes so schedule update for after" - " Midnight the next day" - ) - else: - _LOGGER.debug( - "Midnight is before the day changes so schedule update for the next" - " start of day" - ) - next_update_at = dt_util.start_of_local_day(now + timedelta(days=1)) - - _LOGGER.info("Next update scheduled for: %s", next_update_at) - - self.event_unsub = async_track_point_in_time( - self.hass, self.async_update, next_update_at - ) - - async def async_update(self, *_): - """Update sensors with new prayer times.""" - try: - prayer_times = await self.hass.async_add_executor_job( - self.get_new_prayer_times - ) - self.available = True - except (exceptions.InvalidResponseError, ConnError): - self.available = False - _LOGGER.debug("Error retrieving prayer times") - async_call_later(self.hass, 60, self.async_update) - return - - for prayer, time in prayer_times.items(): - self.prayer_times_info[prayer] = dt_util.parse_datetime( - f"{dt_util.now().date()} {time}" - ) - await self.async_schedule_future_update() - - _LOGGER.debug("New prayer times retrieved. Updating sensors") - async_dispatcher_send(self.hass, DATA_UPDATED) - - async def async_setup(self): - """Set up the Islamic prayer client.""" - await self.async_add_options() - - try: - await self.hass.async_add_executor_job(self.get_new_prayer_times) - except (exceptions.InvalidResponseError, ConnError) as err: - raise ConfigEntryNotReady from err - - await self.async_update() - self.config_entry.add_update_listener(self.async_options_updated) - - await self.hass.config_entries.async_forward_entry_setups( - self.config_entry, PLATFORMS - ) - - return True - - async def async_add_options(self): - """Add options for entry.""" - if not self.config_entry.options: - data = dict(self.config_entry.data) - calc_method = data.pop(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) - - self.hass.config_entries.async_update_entry( - self.config_entry, data=data, options={CONF_CALC_METHOD: calc_method} - ) - - @staticmethod - async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Triggered by config entry options updates.""" - if hass.data[DOMAIN].event_unsub: - hass.data[DOMAIN].event_unsub() - await hass.data[DOMAIN].async_update() +async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Triggered by config entry options updates.""" + coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN] + if coordinator.event_unsub: + coordinator.event_unsub() + await coordinator.async_request_refresh() diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index 5278750d36..d0d314fe67 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -1,10 +1,13 @@ """Config flow for Islamic Prayer Times integration.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import CALC_METHODS, CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN, NAME @@ -22,7 +25,9 @@ class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return IslamicPrayerOptionsFlowHandler(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 initialized by the user.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -40,7 +45,9 @@ class IslamicPrayerOptionsFlowHandler(config_entries.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 options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/islamic_prayer_times/const.py b/homeassistant/components/islamic_prayer_times/const.py index e037f486aa..2a73a33bef 100644 --- a/homeassistant/components/islamic_prayer_times/const.py +++ b/homeassistant/components/islamic_prayer_times/const.py @@ -1,23 +1,12 @@ """Constants for the Islamic Prayer component.""" +from typing import Final + from prayer_times_calculator import PrayerTimesCalculator -DOMAIN = "islamic_prayer_times" -NAME = "Islamic Prayer Times" -PRAYER_TIMES_ICON = "mdi:calendar-clock" +DOMAIN: Final = "islamic_prayer_times" +NAME: Final = "Islamic Prayer Times" -SENSOR_TYPES = { - "Fajr": "prayer", - "Sunrise": "time", - "Dhuhr": "prayer", - "Asr": "prayer", - "Maghrib": "prayer", - "Isha": "prayer", - "Midnight": "time", -} - -CONF_CALC_METHOD = "calculation_method" +CONF_CALC_METHOD: Final = "calculation_method" CALC_METHODS: list[str] = list(PrayerTimesCalculator.CALCULATION_METHODS) -DEFAULT_CALC_METHOD = "isna" - -DATA_UPDATED = "Islamic_prayer_data_updated" +DEFAULT_CALC_METHOD: Final = "isna" diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py new file mode 100644 index 0000000000..1a8b0bf703 --- /dev/null +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -0,0 +1,121 @@ +"""Coordinator for the Islamic prayer times integration.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging + +from prayer_times_calculator import PrayerTimesCalculator, exceptions +from requests.exceptions import ConnectionError as ConnError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.event import async_call_later, async_track_point_in_time +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetime]]): + """Islamic Prayer Client Object.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the Islamic Prayer client.""" + self.event_unsub: CALLBACK_TYPE | None = None + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + ) + + @property + def calc_method(self) -> str: + """Return the calculation method.""" + return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) + + def get_new_prayer_times(self) -> dict[str, str]: + """Fetch prayer times for today.""" + calc = PrayerTimesCalculator( + latitude=self.hass.config.latitude, + longitude=self.hass.config.longitude, + calculation_method=self.calc_method, + date=str(dt_util.now().date()), + ) + return calc.fetch_prayer_times() + + @callback + def async_schedule_future_update(self, midnight_dt: datetime) -> None: + """Schedule future update for sensors. + + Midnight is a calculated time. The specifics of the calculation + depends on the method of the prayer time calculation. This calculated + midnight is the time at which the time to pray the Isha prayers have + expired. + + Calculated Midnight: The Islamic midnight. + Traditional Midnight: 12:00AM + + Update logic for prayer times: + + If the Calculated Midnight is before the traditional midnight then wait + until the traditional midnight to run the update. This way the day + will have changed over and we don't need to do any fancy calculations. + + If the Calculated Midnight is after the traditional midnight, then wait + until after the calculated Midnight. We don't want to update the prayer + times too early or else the timings might be incorrect. + + Example: + calculated midnight = 11:23PM (before traditional midnight) + Update time: 12:00AM + + calculated midnight = 1:35AM (after traditional midnight) + update time: 1:36AM. + + """ + _LOGGER.debug("Scheduling next update for Islamic prayer times") + + now = dt_util.utcnow() + + if now > midnight_dt: + next_update_at = midnight_dt + timedelta(days=1, minutes=1) + _LOGGER.debug( + "Midnight is after the day changes so schedule update for after Midnight the next day" + ) + else: + _LOGGER.debug( + "Midnight is before the day changes so schedule update for the next start of day" + ) + next_update_at = dt_util.start_of_local_day(now + timedelta(days=1)) + + _LOGGER.debug("Next update scheduled for: %s", next_update_at) + + self.event_unsub = async_track_point_in_time( + self.hass, self.async_request_update, next_update_at + ) + + async def async_request_update(self, *_) -> None: + """Request update from coordinator.""" + await self.async_request_refresh() + + async def _async_update_data(self) -> dict[str, datetime]: + """Update sensors with new prayer times.""" + try: + prayer_times = await self.hass.async_add_executor_job( + self.get_new_prayer_times + ) + except (exceptions.InvalidResponseError, ConnError) as err: + async_call_later(self.hass, 60, self.async_request_update) + raise UpdateFailed from err + + prayer_times_info: dict[str, datetime] = {} + for prayer, time in prayer_times.items(): + if prayer_time := dt_util.parse_datetime(f"{dt_util.now().date()} {time}"): + prayer_times_info[prayer] = dt_util.as_utc(prayer_time) + + self.async_schedule_future_update(prayer_times_info["Midnight"]) + return prayer_times_info diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index a90a2c53c5..abaefec408 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -1,12 +1,51 @@ """Platform to retrieve Islamic prayer times information for Home Assistant.""" -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_UPDATED, DOMAIN, PRAYER_TIMES_ICON, SENSOR_TYPES +from . import IslamicPrayerDataUpdateCoordinator +from .const import DOMAIN, NAME + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="Fajr", + name="Fajr prayer", + ), + SensorEntityDescription( + key="Sunrise", + name="Sunrise time", + ), + SensorEntityDescription( + key="Dhuhr", + name="Dhuhr prayer", + ), + SensorEntityDescription( + key="Asr", + name="Asr prayer", + ), + SensorEntityDescription( + key="Maghrib", + name="Maghrib prayer", + ), + SensorEntityDescription( + key="Isha", + name="Isha prayer", + ), + SensorEntityDescription( + key="Midnight", + name="Midnight time", + ), +) async def async_setup_entry( @@ -16,46 +55,38 @@ async def async_setup_entry( ) -> None: """Set up the Islamic prayer times sensor platform.""" - client = hass.data[DOMAIN] + coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN] - entities = [] - for sensor_type in SENSOR_TYPES: - entities.append(IslamicPrayerTimeSensor(sensor_type, client)) - - async_add_entities(entities, True) + async_add_entities( + IslamicPrayerTimeSensor(coordinator, description) + for description in SENSOR_TYPES + ) -class IslamicPrayerTimeSensor(SensorEntity): +class IslamicPrayerTimeSensor( + CoordinatorEntity[IslamicPrayerDataUpdateCoordinator], SensorEntity +): """Representation of an Islamic prayer time sensor.""" _attr_device_class = SensorDeviceClass.TIMESTAMP - _attr_icon = PRAYER_TIMES_ICON - _attr_should_poll = False + _attr_has_entity_name = True - def __init__(self, sensor_type, client): + def __init__( + self, + coordinator: IslamicPrayerDataUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: """Initialize the Islamic prayer time sensor.""" - self.sensor_type = sensor_type - self.client = client + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = description.key + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + name=NAME, + entry_type=DeviceEntryType.SERVICE, + ) @property - def name(self): - """Return the name of the sensor.""" - return f"{self.sensor_type} {SENSOR_TYPES[self.sensor_type]}" - - @property - def unique_id(self): - """Return the unique id of the entity.""" - return self.sensor_type - - @property - def native_value(self): + def native_value(self) -> datetime: """Return the state of the sensor.""" - return self.client.prayer_times_info.get(self.sensor_type).astimezone( - dt_util.UTC - ) - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - self.async_on_remove( - async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) - ) + return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 3612e87f8e..e6e23fdf83 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -25,21 +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, ISY_CONF_FIRMWARE, ISY_CONF_MODEL, @@ -49,96 +43,22 @@ from .const import ( SCHEME_HTTP, SCHEME_HTTPS, ) -from .helpers import _categorize_nodes, _categorize_programs, _categorize_variables +from .helpers import _categorize_nodes, _categorize_programs from .models import IsyData 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 @@ -153,9 +73,6 @@ async def async_setup_entry( tls_version = isy_config.get(CONF_TLS_VER) ignore_identifier = isy_options.get(CONF_IGNORE_STRING, DEFAULT_IGNORE_STRING) sensor_identifier = isy_options.get(CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING) - variable_identifier = isy_options.get( - CONF_VAR_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING - ) if host.scheme == SCHEME_HTTP: https = False @@ -210,9 +127,7 @@ async def async_setup_entry( _categorize_nodes(isy_data, isy.nodes, ignore_identifier, sensor_identifier) _categorize_programs(isy_data, isy.programs) - # Categorize variables call to be removed with variable sensors in 2023.5.0 - _categorize_variables(isy_data, isy.variables, variable_identifier) - # Gather ISY Variables to be added. Identifier used to enable by default. + # Gather ISY Variables to be added. if isy.variables.children: isy_data.devices[CONF_VARIABLES] = _create_service_device_info( isy, name=CONF_VARIABLES.title(), unique_id=CONF_VARIABLES @@ -268,25 +183,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/helpers.py b/homeassistant/components/isy994/helpers.py index 53ad87c9dd..611d046771 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -22,7 +22,6 @@ from pyisy.constants import ( ) from pyisy.nodes import Group, Node, Nodes from pyisy.programs import Programs -from pyisy.variables import Variables from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, Platform from homeassistant.helpers.entity import DeviceInfo @@ -349,8 +348,6 @@ def _categorize_nodes( if getattr(node, "is_dimmable", False): aux_controls = ROOT_AUX_CONTROLS.intersection(node.aux_properties) for control in aux_controls: - # Deprecated all aux properties as sensors. Update in 2023.5.0 to remove extras. - isy_data.aux_properties[Platform.SENSOR].append((node, control)) platform = NODE_AUX_FILTERS[control] isy_data.aux_properties[platform].append((node, control)) if hasattr(node, TAG_ENABLED): @@ -432,20 +429,6 @@ def _categorize_programs(isy_data: IsyData, programs: Programs) -> None: isy_data.programs[platform].append(entity) -def _categorize_variables( - isy_data: IsyData, variables: Variables, identifier: str -) -> None: - """Gather the ISY Variables to be added as sensors.""" - try: - isy_data.variables[Platform.SENSOR] = [ - variables[vtype][vid] - for (vtype, vname, vid) in variables.children - if identifier in vname - ] - except KeyError as err: - _LOGGER.error("Error adding ISY Variables: %s", err) - - def convert_isy_value_to_hass( value: int | float | None, uom: str | 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/lock.py b/homeassistant/components/isy994/lock.py index c5372135bb..9bf487def0 100644 --- a/homeassistant/components/isy994/lock.py +++ b/homeassistant/components/isy994/lock.py @@ -8,16 +8,43 @@ from pyisy.constants import ISY_VALUE_UNKNOWN from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) -from .const import _LOGGER, DOMAIN +from .const import DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity +from .services import ( + SERVICE_DELETE_USER_CODE_SCHEMA, + SERVICE_DELETE_ZWAVE_LOCK_USER_CODE, + SERVICE_SET_USER_CODE_SCHEMA, + SERVICE_SET_ZWAVE_LOCK_USER_CODE, +) VALUE_TO_STATE = {0: False, 100: True} +@callback +def async_setup_lock_services(hass: HomeAssistant) -> None: + """Create lock-specific services for the ISY Integration.""" + platform = async_get_current_platform() + + platform.async_register_entity_service( + SERVICE_SET_ZWAVE_LOCK_USER_CODE, + SERVICE_SET_USER_CODE_SCHEMA, + "async_set_zwave_lock_user_code", + ) + platform.async_register_entity_service( + SERVICE_DELETE_ZWAVE_LOCK_USER_CODE, + SERVICE_DELETE_USER_CODE_SCHEMA, + "async_delete_zwave_lock_user_code", + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -32,6 +59,7 @@ async def async_setup_entry( entities.append(ISYLockProgramEntity(name, status, actions)) async_add_entities(entities) + async_setup_lock_services(hass) class ISYLockEntity(ISYNodeEntity, LockEntity): @@ -47,12 +75,26 @@ class ISYLockEntity(ISYNodeEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Send the lock command to the ISY device.""" if not await self._node.secure_lock(): - _LOGGER.error("Unable to lock device") + raise HomeAssistantError(f"Unable to lock device {self._node.address}") async def async_unlock(self, **kwargs: Any) -> None: """Send the unlock command to the ISY device.""" if not await self._node.secure_unlock(): - _LOGGER.error("Unable to lock device") + raise HomeAssistantError(f"Unable to unlock device {self._node.address}") + + async def async_set_zwave_lock_user_code(self, user_num: int, code: int) -> None: + """Set a user lock code for a Z-Wave Lock.""" + if not await self._node.set_zwave_lock_code(user_num, code): + raise HomeAssistantError( + f"Could not set user code {user_num} for {self._node.address}" + ) + + async def async_delete_zwave_lock_user_code(self, user_num: int) -> None: + """Delete a user lock code for a Z-Wave Lock.""" + if not await self._node.delete_zwave_lock_code(user_num): + raise HomeAssistantError( + f"Could not delete user code {user_num} for {self._node.address}" + ) class ISYLockProgramEntity(ISYProgramEntity, LockEntity): @@ -66,9 +108,9 @@ class ISYLockProgramEntity(ISYProgramEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" if not await self._actions.run_then(): - _LOGGER.error("Unable to lock device") + raise HomeAssistantError(f"Unable to lock device {self._node.address}") async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" if not await self._actions.run_else(): - _LOGGER.error("Unable to unlock device") + raise HomeAssistantError(f"Unable to unlock device {self._node.address}") diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index 991b79e7be..3aa81027b4 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -24,7 +24,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyisy"], - "requirements": ["pyisy==3.1.13"], + "requirements": ["pyisy==3.1.14"], "ssdp": [ { "manufacturer": "Universal Devices Inc.", diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index efad6dbc5c..5f36fed6b6 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -22,7 +22,6 @@ from pyisy.constants import ( ) from pyisy.helpers import EventListener, NodeProperty from pyisy.nodes import Node, NodeChangedEvent -from pyisy.variables import Variable from homeassistant.components.sensor import ( SensorDeviceClass, @@ -44,7 +43,7 @@ from .const import ( UOM_ON_OFF, UOM_TO_STATES, ) -from .entity import ISYEntity, ISYNodeEntity +from .entity import ISYNodeEntity from .helpers import convert_isy_value_to_hass # Disable general purpose and redundant sensors by default @@ -100,8 +99,8 @@ ISY_CONTROL_TO_STATE_CLASS = { control: SensorStateClass.MEASUREMENT for control in ISY_CONTROL_TO_DEVICE_CLASS } ISY_CONTROL_TO_ENTITY_CATEGORY = { - PROP_RAMP_RATE: EntityCategory.CONFIG, - PROP_ON_LEVEL: EntityCategory.CONFIG, + PROP_RAMP_RATE: EntityCategory.DIAGNOSTIC, + PROP_ON_LEVEL: EntityCategory.DIAGNOSTIC, PROP_COMMS_ERROR: EntityCategory.DIAGNOSTIC, } @@ -111,7 +110,7 @@ async def async_setup_entry( ) -> None: """Set up the ISY sensor platform.""" isy_data = hass.data[DOMAIN][entry.entry_id] - entities: list[ISYSensorEntity | ISYSensorVariableEntity] = [] + entities: list[ISYSensorEntity] = [] devices: dict[str, DeviceInfo] = isy_data.devices for node in isy_data.nodes[Platform.SENSOR]: @@ -134,9 +133,6 @@ async def async_setup_entry( ) ) - for variable in isy_data.variables[Platform.SENSOR]: - entities.append(ISYSensorVariableEntity(variable)) - async_add_entities(entities) @@ -292,35 +288,3 @@ class ISYAuxSensorEntity(ISYSensorEntity): def available(self) -> bool: """Return entity availability.""" return cast(bool, self._node.enabled) - - -class ISYSensorVariableEntity(ISYEntity, SensorEntity): - """Representation of an ISY variable as a sensor device.""" - - # Deprecated sensors, will be removed in 2023.5.0 - _attr_entity_registry_enabled_default = False - - def __init__(self, variable_node: Variable) -> None: - """Initialize the ISY binary sensor program.""" - super().__init__(variable_node) - self._name = variable_node.name - - @property - def native_value(self) -> float | int | None: - """Return the state of the variable.""" - return convert_isy_value_to_hass(self._node.status, "", self._node.prec) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Get the state attributes for the device.""" - return { - "init_value": convert_isy_value_to_hass( - self._node.init, "", self._node.prec - ), - "last_edited": self._node.last_edited, - } - - @property - def icon(self) -> str: - """Return the icon.""" - return "mdi:counter" diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 05e0425c3f..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,12 +29,14 @@ 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" CONF_PARAMETER = "parameter" CONF_PARAMETERS = "parameters" +CONF_USER_NUM = "user_num" +CONF_CODE = "code" CONF_VALUE = "value" CONF_INIT = "init" CONF_ISY = "isy" @@ -96,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)), @@ -129,21 +100,12 @@ SERVICE_SET_ZWAVE_PARAMETER_SCHEMA = { vol.Required(CONF_SIZE): vol.All(vol.Coerce(int), vol.In(VALID_PARAMETER_SIZES)), } -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_SET_USER_CODE_SCHEMA = { + vol.Required(CONF_USER_NUM): vol.Coerce(int), + vol.Required(CONF_CODE): vol.Coerce(int), +} + +SERVICE_DELETE_USER_CODE_SCHEMA = {vol.Required(CONF_USER_NUM): vol.Coerce(int)} SERVICE_SEND_PROGRAM_COMMAND_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_ADDRESS, CONF_NAME), @@ -157,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) @@ -281,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, @@ -363,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 @@ -449,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 e336eaa574..b84fcdd73e 100644 --- a/homeassistant/components/isy994/services.yaml +++ b/homeassistant/components/isy994/services.yaml @@ -118,6 +118,52 @@ set_zwave_parameter: - "1" - "2" - "4" +set_zwave_lock_user_code: + name: Set Z-Wave Lock User Code + description: >- + Set a Z-Wave Lock User Code via the ISY. + target: + entity: + integration: isy994 + domain: lock + fields: + user_num: + name: User Number + description: The user slot number on the lock + required: true + example: 8 + selector: + number: + min: 1 + max: 255 + code: + name: Code + description: The code to set for the user. + required: true + example: 33491663 + selector: + number: + min: 1 + max: 99999999 + mode: box +delete_zwave_lock_user_code: + name: Delete Z-Wave Lock User Code + description: >- + Delete a Z-Wave Lock User Code via the ISY. + target: + entity: + integration: isy994 + domain: lock + fields: + user_num: + name: User Number + description: The user slot number on the lock + required: true + example: 8 + selector: + number: + min: 1 + max: 255 rename_node: name: Rename Node on ISY description: >- @@ -135,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: >- @@ -266,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/itunes/media_player.py b/homeassistant/components/itunes/media_player.py index c9b0e4a07a..78fd8b2a5b 100644 --- a/homeassistant/components/itunes/media_player.py +++ b/homeassistant/components/itunes/media_player.py @@ -380,7 +380,9 @@ class ItunesDevice(MediaPlayerEntity): response = self.client.previous() self.update_state(response) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Send the play_media command to the media player.""" if media_type == MediaType.PLAYLIST: response = self.client.play_playlist(media_id) 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 78fd87b8e2..4c9eb3a607 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -1,6 +1,7 @@ """Support for the iZone HVAC.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -94,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( @@ -139,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 @@ -246,7 +252,7 @@ class ControllerDevice(ClimateEntity): zone.async_schedule_update_ha_state() @property - def unique_id(self): + def unique_id(self) -> str: """Return the ID of the controller device.""" return self._controller.device_uid @@ -256,7 +262,7 @@ class ControllerDevice(ClimateEntity): return f"iZone Controller {self._controller.device_uid}" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the optional state attributes.""" return { "supply_temperature": show_temp( @@ -306,13 +312,13 @@ class ControllerDevice(ClimateEntity): @property @_return_on_connection_error(PRESET_NONE) - def preset_mode(self): + def preset_mode(self) -> str: """Eco mode is external air.""" return PRESET_ECO if self._controller.free_air else PRESET_NONE @property @_return_on_connection_error([PRESET_NONE]) - def preset_modes(self): + def preset_modes(self) -> list[str]: """Available preset modes, normal or eco.""" if self._controller.free_air_enabled: return [PRESET_NONE, PRESET_ECO] @@ -507,7 +513,7 @@ class ZoneDevice(ClimateEntity): return self._controller.available @property - def unique_id(self): + def unique_id(self) -> str: """Return the ID of the controller device.""" return f"{self._controller.unique_id}_z{self._zone.index + 1}" @@ -539,29 +545,29 @@ class ZoneDevice(ClimateEntity): return list(self._state_to_pizone) @property - def current_temperature(self): + def current_temperature(self) -> float: """Return the current temperature.""" return self._zone.temp_current @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self._zone.type != Zone.Type.AUTO: return None return self._zone.temp_setpoint @property - def target_temperature_step(self): + def target_temperature_step(self) -> float: """Return the supported step of target temperature.""" return 0.5 @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" return self._controller.min_temp @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return self._controller.max_temp @@ -626,7 +632,7 @@ class ZoneDevice(ClimateEntity): return self._zone.index @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the optional state attributes.""" return { "airflow_max": self._zone.airflow_max, 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/__init__.py b/homeassistant/components/jellyfin/__init__.py index 39085317a5..4ee9702072 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -20,10 +20,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry_data[CONF_CLIENT_DEVICE_ID] = entry.entry_id hass.config_entries.async_update_entry(entry, data=entry_data) - client = create_client( - device_id=entry.data[CONF_CLIENT_DEVICE_ID], - device_name=hass.config.location_name, - ) + device_id = entry.data[CONF_CLIENT_DEVICE_ID] + device_name = ascii(hass.config.location_name) + + client = create_client(device_id=device_id, device_name=device_name) try: user_id, connect_result = await validate_input(hass, dict(entry.data), client) @@ -36,7 +36,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: server_info: dict[str, Any] = connect_result["Servers"][0] coordinators: dict[str, JellyfinDataUpdateCoordinator[Any]] = { - "sessions": SessionsDataUpdateCoordinator(hass, client, server_info, user_id), + "sessions": SessionsDataUpdateCoordinator( + hass, client, server_info, entry.data[CONF_CLIENT_DEVICE_ID], user_id + ), } for coordinator in coordinators.values(): diff --git a/homeassistant/components/jellyfin/coordinator.py b/homeassistant/components/jellyfin/coordinator.py index b7563dcd86..3d5b150f39 100644 --- a/homeassistant/components/jellyfin/coordinator.py +++ b/homeassistant/components/jellyfin/coordinator.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, LOGGER +from .const import DOMAIN, LOGGER, USER_APP_NAME JellyfinDataT = TypeVar( "JellyfinDataT", @@ -29,6 +29,7 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[JellyfinDataT], ABC): hass: HomeAssistant, api_client: JellyfinClient, system_info: dict[str, Any], + client_device_id: str, user_id: str, ) -> None: """Initialize the coordinator.""" @@ -42,8 +43,11 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[JellyfinDataT], ABC): self.server_id: str = system_info["Id"] self.server_name: str = system_info["Name"] self.server_version: str | None = system_info.get("Version") + self.client_device_id: str = client_device_id self.user_id: str = user_id + self.session_ids: set[str] = set() + async def _async_update_data(self) -> JellyfinDataT: """Get the latest data from Jellyfin.""" return await self._fetch_data() @@ -65,7 +69,10 @@ class SessionsDataUpdateCoordinator( ) sessions_by_id: dict[str, dict[str, Any]] = { - session["Id"]: session for session in sessions + session["Id"]: session + for session in sessions + if session["DeviceId"] != self.client_device_id + and session["Client"] != USER_APP_NAME } return sessions_by_id diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 60fae2caac..2025e1a2a6 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -19,7 +19,7 @@ from homeassistant.util.dt import parse_datetime from .browse_media import build_item_response, build_root_response from .client_wrapper import get_artwork_url -from .const import CONTENT_TYPE_MAP, DOMAIN, USER_APP_NAME +from .const import CONTENT_TYPE_MAP, DOMAIN, LOGGER from .coordinator import JellyfinDataUpdateCoordinator from .entity import JellyfinEntity from .models import JellyfinData @@ -34,14 +34,23 @@ async def async_setup_entry( jellyfin_data: JellyfinData = hass.data[DOMAIN][entry.entry_id] coordinator = jellyfin_data.coordinators["sessions"] - async_add_entities( - ( - JellyfinMediaPlayer(coordinator, session_id, session_data) - for session_id, session_data in coordinator.data.items() - if session_data["DeviceId"] != jellyfin_data.client_device_id - and session_data["Client"] != USER_APP_NAME - ), - ) + @callback + def handle_coordinator_update() -> None: + """Add media player per session.""" + entities: list[MediaPlayerEntity] = [] + for session_id, session_data in coordinator.data.items(): + if session_id not in coordinator.session_ids: + entity: MediaPlayerEntity = JellyfinMediaPlayer( + coordinator, session_id, session_data + ) + LOGGER.debug("Creating media player for session: %s", session_id) + coordinator.session_ids.add(session_id) + entities.append(entity) + async_add_entities(entities) + + handle_coordinator_update() + + entry.async_on_unload(coordinator.async_add_listener(handle_coordinator_update)) class JellyfinMediaPlayer(JellyfinEntity, MediaPlayerEntity): @@ -253,7 +262,7 @@ class JellyfinMediaPlayer(JellyfinEntity, MediaPlayerEntity): self._attr_state = MediaPlayerState.IDLE def play_media( - self, media_type: str, media_id: str, **kwargs: dict[str, Any] + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" self.coordinator.api_client.jellyfin.remote_play_media( @@ -274,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/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 3d28e2bb0c..e127d78229 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -116,6 +116,13 @@ class JewishCalendarBinarySensor(BinarySensorEntity): await super().async_added_to_hass() self._schedule_update() + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._update_unsub: + self._update_unsub() + self._update_unsub = None + return await super().async_will_remove_from_hass() + @callback def _update(self, now: datetime | None = None) -> None: """Update the state of the sensor.""" diff --git a/homeassistant/components/jvc_projector/__init__.py b/homeassistant/components/jvc_projector/__init__.py new file mode 100644 index 0000000000..996d745a1d --- /dev/null +++ b/homeassistant/components/jvc_projector/__init__.py @@ -0,0 +1,65 @@ +"""The jvc_projector integration.""" + +from __future__ import annotations + +from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import JvcProjectorDataUpdateCoordinator + +PLATFORMS = [Platform.REMOTE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up integration from a config entry.""" + device = JvcProjector( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + password=entry.data[CONF_PASSWORD], + ) + + try: + await device.connect(True) + except JvcProjectorConnectError as err: + await device.disconnect() + raise ConfigEntryNotReady( + f"Unable to connect to {entry.data[CONF_HOST]}" + ) from err + except JvcProjectorAuthError as err: + await device.disconnect() + raise ConfigEntryAuthFailed("Password authentication failed") from err + + coordinator = JvcProjectorDataUpdateCoordinator(hass, device) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + async def disconnect(event: Event) -> None: + await device.disconnect() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect) + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await hass.data[DOMAIN][entry.entry_id].device.disconnect() + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/jvc_projector/config_flow.py b/homeassistant/components/jvc_projector/config_flow.py new file mode 100644 index 0000000000..181d11e1f5 --- /dev/null +++ b/homeassistant/components/jvc_projector/config_flow.py @@ -0,0 +1,129 @@ +"""Config flow for the jvc_projector integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError +from jvcprojector.projector import DEFAULT_PORT +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac +from homeassistant.util.network import is_host_valid + +from .const import DOMAIN, NAME + + +class JvcProjectorConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for the JVC Projector integration.""" + + VERSION = 1 + + _reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user initiated device additions.""" + errors = {} + + if user_input is not None: + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + password = user_input.get(CONF_PASSWORD) + + try: + if not is_host_valid(host): + raise InvalidHost + + mac = await get_mac_address(host, port, password) + except InvalidHost: + errors["base"] = "invalid_host" + except JvcProjectorConnectError: + errors["base"] = "cannot_connect" + except JvcProjectorAuthError: + errors["base"] = "invalid_auth" + else: + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host, CONF_PORT: port, CONF_PASSWORD: password} + ) + + return self.async_create_entry( + title=NAME, + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_PASSWORD: password, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Perform reauth on password authentication error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + assert self._reauth_entry + + errors = {} + + if user_input is not None: + host = self._reauth_entry.data[CONF_HOST] + port = self._reauth_entry.data[CONF_PORT] + password = user_input[CONF_PASSWORD] + + try: + await get_mac_address(host, port, password) + except JvcProjectorConnectError: + errors["base"] = "cannot_connect" + except JvcProjectorAuthError: + errors["base"] = "invalid_auth" + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data={CONF_HOST: host, CONF_PORT: port, CONF_PASSWORD: password}, + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}), + errors=errors, + ) + + +class InvalidHost(Exception): + """Error indicating invalid network host.""" + + +async def get_mac_address(host: str, port: int, password: str | None) -> str: + """Get device mac address for config flow.""" + device = JvcProjector(host, port=port, password=password) + try: + await device.connect(True) + finally: + await device.disconnect() + return device.mac diff --git a/homeassistant/components/jvc_projector/const.py b/homeassistant/components/jvc_projector/const.py new file mode 100644 index 0000000000..e15aa93bfa --- /dev/null +++ b/homeassistant/components/jvc_projector/const.py @@ -0,0 +1,5 @@ +"""Constants for the jvc_projector integration.""" + +NAME = "JVC Projector" +DOMAIN = "jvc_projector" +MANUFACTURER = "JVC" diff --git a/homeassistant/components/jvc_projector/coordinator.py b/homeassistant/components/jvc_projector/coordinator.py new file mode 100644 index 0000000000..a63d68781b --- /dev/null +++ b/homeassistant/components/jvc_projector/coordinator.py @@ -0,0 +1,62 @@ +"""Data update coordinator for the jvc_projector integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from jvcprojector import ( + JvcProjector, + JvcProjectorAuthError, + JvcProjectorConnectError, + const, +) + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import NAME + +_LOGGER = logging.getLogger(__name__) + +INTERVAL_SLOW = timedelta(seconds=60) +INTERVAL_FAST = timedelta(seconds=6) + + +class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): + """Data update coordinator for the JVC Projector integration.""" + + def __init__(self, hass: HomeAssistant, device: JvcProjector) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name=NAME, + update_interval=INTERVAL_SLOW, + ) + + self.device = device + self.unique_id = format_mac(device.mac) + + async def _async_update_data(self) -> dict[str, str]: + """Get the latest state data.""" + try: + state = await self.device.get_state() + except JvcProjectorConnectError as err: + raise UpdateFailed(f"Unable to connect to {self.device.host}") from err + except JvcProjectorAuthError as err: + raise ConfigEntryAuthFailed("Password authentication failed") from err + + old_interval = self.update_interval + + if state[const.POWER] != const.STANDBY: + self.update_interval = INTERVAL_FAST + else: + self.update_interval = INTERVAL_SLOW + + if self.update_interval != old_interval: + _LOGGER.debug("Changed update interval to %s", self.update_interval) + + return state diff --git a/homeassistant/components/jvc_projector/entity.py b/homeassistant/components/jvc_projector/entity.py new file mode 100644 index 0000000000..5d1821c6b5 --- /dev/null +++ b/homeassistant/components/jvc_projector/entity.py @@ -0,0 +1,38 @@ +"""Base Entity for the jvc_projector integration.""" + +from __future__ import annotations + +import logging + +from jvcprojector import JvcProjector + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER, NAME +from .coordinator import JvcProjectorDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]): + """Defines a base JVC Projector entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: JvcProjectorDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._attr_unique_id = coordinator.unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.unique_id)}, + name=NAME, + model=self.device.model, + manufacturer=MANUFACTURER, + ) + + @property + def device(self) -> JvcProjector: + """Return the device representing the projector.""" + return self.coordinator.device diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json new file mode 100644 index 0000000000..bc01da5d89 --- /dev/null +++ b/homeassistant/components/jvc_projector/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "jvc_projector", + "name": "JVC Projector", + "codeowners": ["@SteveEasley"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/jvc_projector", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["jvcprojector"], + "requirements": ["pyjvcprojector==1.0.6"] +} diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py new file mode 100644 index 0000000000..e33eef74c4 --- /dev/null +++ b/homeassistant/components/jvc_projector/remote.py @@ -0,0 +1,76 @@ +"""Remote platform for the jvc_projector integration.""" + +from __future__ import annotations + +from collections.abc import Iterable +import logging +from typing import Any + +from jvcprojector import const + +from homeassistant.components.remote import RemoteEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import JvcProjectorEntity + +COMMANDS = { + "menu": const.REMOTE_MENU, + "up": const.REMOTE_UP, + "down": const.REMOTE_DOWN, + "left": const.REMOTE_LEFT, + "right": const.REMOTE_RIGHT, + "ok": const.REMOTE_OK, + "back": const.REMOTE_BACK, + "mpc": const.REMOTE_MPC, + "hide": const.REMOTE_HIDE, + "info": const.REMOTE_INFO, + "input": const.REMOTE_INPUT, + "cmd": const.REMOTE_CMD, + "advanced_menu": const.REMOTE_ADVANCED_MENU, + "picture_mode": const.REMOTE_PICTURE_MODE, + "color_profile": const.REMOTE_COLOR_PROFILE, + "lens_control": const.REMOTE_LENS_CONTROL, + "setting_memory": const.REMOTE_SETTING_MEMORY, + "gamma_settings": const.REMOTE_GAMMA_SETTINGS, +} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the JVC Projector platform from a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([JvcProjectorRemote(coordinator)], True) + + +class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity): + """Representation of a JVC Projector device.""" + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self.coordinator.data["power"] in [const.ON, const.WARMING] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.device.power_on() + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.device.power_off() + await self.coordinator.async_refresh() + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a remote command to the device.""" + for cmd in command: + if cmd not in COMMANDS: + raise HomeAssistantError(f"{cmd} is not a known command") + _LOGGER.debug("Sending command '%s'", cmd) + await self.device.remote(COMMANDS[cmd]) diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json new file mode 100644 index 0000000000..11e2f66f91 --- /dev/null +++ b/homeassistant/components/jvc_projector/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "IP address or hostname of projector", + "port": "IP port of projector (default is 20554)", + "password": "Optional password if projector is configured for one" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Password authentication failed", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "Password authentication failed" + } + } +} diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index 68465c26c4..207c9e353a 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -6,7 +6,7 @@ import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import ( CONF_CONSIDER_HOME, @@ -67,8 +67,8 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> _LOGGER.debug( "Cleaning device_tracker entities since some interfaces are now untracked:" ) - ent_reg = entity_registry.async_get(hass) - dev_reg = device_registry.async_get(hass) + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) # We keep devices currently connected to new_tracked_interfaces keep_devices: set[str] = { mac diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index fd4265a4ef..c51d30431b 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -12,7 +12,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -40,7 +40,7 @@ async def async_setup_entry( update_from_router() - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) # Restore devices that are not a part of active clients list. restored = [] for entity_entry in registry.entities.values(): diff --git a/homeassistant/components/keymitt_ble/manifest.json b/homeassistant/components/keymitt_ble/manifest.json index 995a592beb..760cc67cbd 100644 --- a/homeassistant/components/keymitt_ble/manifest.json +++ b/homeassistant/components/keymitt_ble/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/keymitt_ble", "iot_class": "assumed_state", "loggers": ["keymitt_ble"], - "requirements": ["PyMicroBot==0.0.8"] + "requirements": ["PyMicroBot==0.0.9"] } diff --git a/homeassistant/components/keymitt_ble/switch.py b/homeassistant/components/keymitt_ble/switch.py index 099ad1f228..3e5883ae5d 100644 --- a/homeassistant/components/keymitt_ble/switch.py +++ b/homeassistant/components/keymitt_ble/switch.py @@ -8,7 +8,11 @@ import voluptuous as vol from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from .const import DOMAIN from .coordinator import MicroBotDataUpdateCoordinator @@ -23,14 +27,12 @@ CALIBRATE_SCHEMA = { async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up MicroBot based on a config entry.""" coordinator: MicroBotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([MicroBotBinarySwitch(coordinator, entry)]) - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( CALIBRATE, CALIBRATE_SCHEMA, diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 5a5daa5008..e177a861af 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -2,7 +2,9 @@ from __future__ import annotations import asyncio +import contextlib import logging +from pathlib import Path from typing import Final import voluptuous as vol @@ -10,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 ( @@ -67,7 +69,9 @@ from .const import ( KNX_ADDRESS, SUPPORTED_PLATFORMS, ) +from .device import KNXInterfaceDevice from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure +from .project import KNXProject from .schema import ( BinarySensorSchema, ButtonSchema, @@ -88,6 +92,8 @@ from .schema import ( ga_validator, sensor_type_validator, ) +from .telegrams import Telegrams +from .websocket import register_panel _LOGGER = logging.getLogger(__name__) @@ -219,6 +225,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf = dict(conf) hass.data[DATA_KNX_CONFIG] = conf + return True @@ -252,13 +259,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: knx_module.exposures.append( create_knx_exposure(hass, knx_module.xknx, expose_config) ) - + # always forward sensor for system entities (telegram counter, etc.) + await hass.config_entries.async_forward_entry_setup(entry, Platform.SENSOR) await hass.config_entries.async_forward_entry_setups( entry, [ platform for platform in SUPPORTED_PLATFORMS - if platform in config and platform is not Platform.NOTIFY + if platform in config and platform not in (Platform.SENSOR, Platform.NOTIFY) ], ) @@ -300,6 +308,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA, ) + await register_panel(hass) + return True @@ -335,6 +345,21 @@ async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + + def remove_keyring_files(file_path: Path) -> None: + """Remove keyring files.""" + with contextlib.suppress(FileNotFoundError): + file_path.unlink() + with contextlib.suppress(FileNotFoundError, OSError): + file_path.parent.rmdir() + + if (_knxkeys_file := entry.data.get(CONF_KNX_KNXKEY_FILENAME)) is not None: + file_path = Path(hass.config.path(STORAGE_DIR)) / _knxkeys_file + await hass.async_add_executor_job(remove_keyring_files, file_path) + + class KNXModule: """Representation of KNX Object.""" @@ -349,10 +374,20 @@ class KNXModule: self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {} self.entry = entry - self.init_xknx() + self.project = KNXProject(hass=hass, entry=entry) + + self.xknx = XKNX( + connection_config=self.connection_config(), + rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], + state_updater=self.entry.data[CONF_KNX_STATE_UPDATER], + ) self.xknx.connection_manager.register_connection_state_changed_cb( self.connection_state_changed_cb ) + self.telegrams = Telegrams(hass, self.xknx, self.project) + self.interface_device = KNXInterfaceDevice( + hass=hass, entry=entry, xknx=self.xknx + ) self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} self._group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} @@ -365,16 +400,9 @@ class KNXModule: ) self.entry.async_on_unload(self.entry.add_update_listener(async_update_entry)) - def init_xknx(self) -> None: - """Initialize XKNX object.""" - self.xknx = XKNX( - connection_config=self.connection_config(), - rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], - state_updater=self.entry.data[CONF_KNX_STATE_UPDATER], - ) - async def start(self) -> None: """Start XKNX object. Connect to tunneling or Routing device.""" + await self.project.load_project() await self.xknx.start() async def stop(self, event: Event | None = None) -> None: @@ -384,6 +412,14 @@ class KNXModule: def connection_config(self) -> ConnectionConfig: """Return the connection_config.""" _conn_type: str = self.entry.data[CONF_KNX_CONNECTION_TYPE] + _knxkeys_file: str | None = ( + self.hass.config.path( + STORAGE_DIR, + self.entry.data[CONF_KNX_KNXKEY_FILENAME], + ) + if self.entry.data.get(CONF_KNX_KNXKEY_FILENAME) is not None + else None + ) if _conn_type == CONF_KNX_ROUTING: return ConnectionConfig( connection_type=ConnectionType.ROUTING, @@ -392,6 +428,10 @@ class KNXModule: multicast_port=self.entry.data[CONF_KNX_MCAST_PORT], local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), threaded=True, ) if _conn_type == CONF_KNX_TUNNELING: @@ -402,6 +442,10 @@ class KNXModule: local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), route_back=self.entry.data.get(CONF_KNX_ROUTE_BACK, False), auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), threaded=True, ) if _conn_type == CONF_KNX_TUNNELING_TCP: @@ -410,16 +454,12 @@ class KNXModule: gateway_ip=self.entry.data[CONF_HOST], gateway_port=self.entry.data[CONF_PORT], auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), threaded=True, ) - knxkeys_file: str | None = ( - self.hass.config.path( - STORAGE_DIR, - self.entry.data[CONF_KNX_KNXKEY_FILENAME], - ) - if self.entry.data.get(CONF_KNX_KNXKEY_FILENAME) is not None - else None - ) if _conn_type == CONF_KNX_TUNNELING_TCP_SECURE: return ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP_SECURE, @@ -432,7 +472,7 @@ class KNXModule: CONF_KNX_SECURE_DEVICE_AUTHENTICATION ), knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=knxkeys_file, + knxkeys_file_path=_knxkeys_file, ), auto_reconnect=True, threaded=True, @@ -450,13 +490,17 @@ class KNXModule: CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE ), knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=knxkeys_file, + knxkeys_file_path=_knxkeys_file, ), auto_reconnect=True, threaded=True, ) return ConnectionConfig( auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), threaded=True, ) @@ -479,31 +523,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", @@ -622,7 +664,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 7465c394dd..81610d62dc 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -3,16 +3,24 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import AsyncGenerator +from pathlib import Path +import shutil 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.secure.keyring import XMLInterface, load_keyring +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 from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import callback @@ -27,7 +35,6 @@ from .const import ( CONF_KNX_DEFAULT_RATE_LIMIT, CONF_KNX_DEFAULT_STATE_UPDATER, CONF_KNX_INDIVIDUAL_ADDRESS, - CONF_KNX_KNXKEY_FILENAME, CONF_KNX_KNXKEY_PASSWORD, CONF_KNX_LOCAL_IP, CONF_KNX_MCAST_GRP, @@ -42,10 +49,10 @@ from .const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, - CONST_KNX_STORAGE_KEY, DEFAULT_ROUTING_IA, DOMAIN, KNXConfigEntryData, @@ -65,6 +72,9 @@ DEFAULT_ENTRY_DATA = KNXConfigEntryData( state_updater=CONF_KNX_DEFAULT_STATE_UPDATER, ) +CONF_KEYRING_FILE: Final = "knxkeys_file" +DEFAULT_KNX_KEYRING_FILENAME: Final = "keyring.knxkeys" + CONF_KNX_TUNNELING_TYPE: Final = "tunneling_type" CONF_KNX_TUNNELING_TYPE_LABELS: Final = { CONF_KNX_TUNNELING: "UDP (Tunnelling v1)", @@ -93,6 +103,9 @@ class KNXCommonFlow(ABC, FlowHandler): """Initialize KNXCommonFlow.""" self.initial_data = initial_data self.new_entry_data = KNXConfigEntryData() + self.new_title: str | None = None + + self._keyring: Keyring | None = None self._found_gateways: list[GatewayDescriptor] = [] self._found_tunnels: list[GatewayDescriptor] = [] self._selected_tunnel: GatewayDescriptor | None = None @@ -102,9 +115,25 @@ class KNXCommonFlow(ABC, FlowHandler): self._async_scan_gen: AsyncGenerator[GatewayDescriptor, None] | None = None @abstractmethod - def finish_flow(self, title: str) -> FlowResult: + def finish_flow(self) -> FlowResult: """Finish the flow.""" + @property + def connection_type(self) -> str: + """Return the configured connection type.""" + _new_type = self.new_entry_data.get(CONF_KNX_CONNECTION_TYPE) + if _new_type is None: + return self.initial_data[CONF_KNX_CONNECTION_TYPE] + return _new_type + + @property + def tunnel_endpoint_ia(self) -> str | None: + """Return the configured tunnel endpoint individual address.""" + return self.new_entry_data.get( + CONF_KNX_TUNNEL_ENDPOINT_IA, + self.initial_data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), + ) + async def async_step_connection_type( self, user_input: dict | None = None ) -> FlowResult: @@ -135,8 +164,12 @@ class KNXCommonFlow(ABC, FlowHandler): return await self.async_step_tunnel() # Automatic connection type - self.new_entry_data = KNXConfigEntryData(connection_type=CONF_KNX_AUTOMATIC) - return self.finish_flow(title=CONF_KNX_AUTOMATIC.capitalize()) + self.new_entry_data = KNXConfigEntryData( + connection_type=CONF_KNX_AUTOMATIC, + tunnel_endpoint_ia=None, + ) + self.new_title = CONF_KNX_AUTOMATIC.capitalize() + return self.finish_flow() supported_connection_types = { CONF_KNX_TUNNELING: CONF_KNX_TUNNELING.capitalize(), @@ -194,13 +227,18 @@ class KNXCommonFlow(ABC, FlowHandler): port=self._selected_tunnel.port, route_back=False, connection_type=connection_type, + device_authentication=None, + user_id=None, + user_password=None, + tunnel_endpoint_ia=None, ) if connection_type == CONF_KNX_TUNNELING_TCP_SECURE: return self.async_show_menu( step_id="secure_key_source", menu_options=["secure_knxkeys", "secure_tunnel_manual"], ) - return self.finish_flow(title=f"Tunneling @ {self._selected_tunnel}") + self.new_title = f"Tunneling @ {self._selected_tunnel}" + return self.finish_flow() if not self._found_tunnels: return await self.async_step_manual_tunnel() @@ -225,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], @@ -263,7 +305,11 @@ 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, + tunnel_endpoint_ia=None, ) if selected_tunnelling_type == CONF_KNX_TUNNELING_TCP_SECURE: @@ -271,7 +317,12 @@ class KNXCommonFlow(ABC, FlowHandler): step_id="secure_key_source", menu_options=["secure_knxkeys", "secure_tunnel_manual"], ) - return self.finish_flow(title=f"Tunneling @ {_host}") + self.new_title = ( + "Tunneling " + f"{'UDP' if selected_tunnelling_type == CONF_KNX_TUNNELING else 'TCP'} " + f"@ {_host}" + ) + return self.finish_flow() _reconfiguring_existing_tunnel = ( self.initial_data.get(CONF_KNX_CONNECTION_TYPE) @@ -342,10 +393,10 @@ class KNXCommonFlow(ABC, FlowHandler): device_authentication=user_input[CONF_KNX_SECURE_DEVICE_AUTHENTICATION], user_id=user_input[CONF_KNX_SECURE_USER_ID], user_password=user_input[CONF_KNX_SECURE_USER_PASSWORD], + tunnel_endpoint_ia=None, ) - return self.finish_flow( - title=f"Secure Tunneling @ {self.new_entry_data[CONF_HOST]}" - ) + self.new_title = f"Secure Tunneling @ {self.new_entry_data[CONF_HOST]}" + return self.finish_flow() fields = { vol.Required( @@ -399,12 +450,8 @@ class KNXCommonFlow(ABC, FlowHandler): CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE ], ) - return self.finish_flow( - title=( - "Secure Routing as" - f" {self.new_entry_data[CONF_KNX_INDIVIDUAL_ADDRESS]}" - ) - ) + self.new_title = f"Secure Routing as {self.new_entry_data[CONF_KNX_INDIVIDUAL_ADDRESS]}" + return self.finish_flow() fields = { vol.Required( @@ -437,92 +484,104 @@ class KNXCommonFlow(ABC, FlowHandler): ) async def async_step_secure_knxkeys( - self, user_input: dict | None = None + self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Configure secure knxkeys used to authenticate.""" - errors = {} - description_placeholders = {} + """Manage upload of new KNX Keyring file.""" + errors: dict[str, str] = {} if user_input is not None: - connection_type = self.new_entry_data[CONF_KNX_CONNECTION_TYPE] - storage_key = CONST_KNX_STORAGE_KEY + user_input[CONF_KNX_KNXKEY_FILENAME] - try: - keyring = await load_keyring( - path=self.hass.config.path(STORAGE_DIR, storage_key), - password=user_input[CONF_KNX_KNXKEY_PASSWORD], - ) - except FileNotFoundError: - errors[CONF_KNX_KNXKEY_FILENAME] = "keyfile_not_found" - except InvalidSecureConfiguration: - errors[CONF_KNX_KNXKEY_PASSWORD] = "keyfile_invalid_signature" - else: - if ( - connection_type == CONF_KNX_TUNNELING_TCP_SECURE - and self._selected_tunnel is not None - ): - if host_ia := self._selected_tunnel.individual_address: - self._tunnel_endpoints = keyring.get_tunnel_interfaces_by_host( - host=host_ia - ) - if not self._tunnel_endpoints: - errors["base"] = "keyfile_no_tunnel_for_host" - description_placeholders = {CONF_HOST: str(host_ia)} - - if connection_type == CONF_KNX_ROUTING_SECURE: - if not (keyring.backbone is not None and keyring.backbone.key): - errors["base"] = "keyfile_no_backbone_key" - - if not errors: + password = user_input[CONF_KNX_KNXKEY_PASSWORD] + errors = await self._save_uploaded_knxkeys_file( + uploaded_file_id=user_input[CONF_KEYRING_FILE], + password=password, + ) + if not errors and self._keyring: self.new_entry_data |= KNXConfigEntryData( - knxkeys_filename=storage_key, - knxkeys_password=user_input[CONF_KNX_KNXKEY_PASSWORD], + knxkeys_filename=f"{DOMAIN}/{DEFAULT_KNX_KEYRING_FILENAME}", + knxkeys_password=password, backbone_key=None, sync_latency_tolerance=None, - device_authentication=None, - user_id=None, - user_password=None, ) - if connection_type == CONF_KNX_ROUTING_SECURE: - return self.finish_flow( - title=( - "Secure Routing as" - f" {self.new_entry_data[CONF_KNX_INDIVIDUAL_ADDRESS]}" - ) - ) - return await self.async_step_knxkeys_tunnel_select() + # Routing + if self.connection_type in (CONF_KNX_ROUTING, CONF_KNX_ROUTING_SECURE): + return self.finish_flow() + + # Tunneling / Automatic + # skip selection step if we have a keyfile update that includes a configured tunnel + if self.tunnel_endpoint_ia is not None and self.tunnel_endpoint_ia in [ + str(_if.individual_address) for _if in self._keyring.interfaces + ]: + return self.finish_flow() + if not errors: + return await self.async_step_knxkeys_tunnel_select() - if _default_filename := self.initial_data.get(CONF_KNX_KNXKEY_FILENAME): - _default_filename = _default_filename.lstrip(CONST_KNX_STORAGE_KEY) fields = { - vol.Required( - CONF_KNX_KNXKEY_FILENAME, default=_default_filename - ): selector.TextSelector(), + vol.Required(CONF_KEYRING_FILE): selector.FileSelector( + config=selector.FileSelectorConfig(accept=".knxkeys") + ), vol.Required( CONF_KNX_KNXKEY_PASSWORD, default=self.initial_data.get(CONF_KNX_KNXKEY_PASSWORD), ): selector.TextSelector(), } - return self.async_show_form( step_id="secure_knxkeys", data_schema=vol.Schema(fields), errors=errors, - description_placeholders=description_placeholders, ) async def async_step_knxkeys_tunnel_select( self, user_input: dict | None = None ) -> FlowResult: """Select if a specific tunnel should be used from knxkeys file.""" + errors = {} + description_placeholders = {} if user_input is not None: - if user_input[CONF_KNX_SECURE_USER_ID] == CONF_KNX_AUTOMATIC: - selected_user_id = None + selected_tunnel_ia: str | None = None + _if_user_id: int | None = None + if user_input[CONF_KNX_TUNNEL_ENDPOINT_IA] == CONF_KNX_AUTOMATIC: + self.new_entry_data |= KNXConfigEntryData( + tunnel_endpoint_ia=None, + ) else: - selected_user_id = int(user_input[CONF_KNX_SECURE_USER_ID]) - self.new_entry_data |= KNXConfigEntryData(user_id=selected_user_id) - return self.finish_flow( - title=f"Secure Tunneling @ {self.new_entry_data[CONF_HOST]}" + selected_tunnel_ia = user_input[CONF_KNX_TUNNEL_ENDPOINT_IA] + self.new_entry_data |= KNXConfigEntryData( + tunnel_endpoint_ia=selected_tunnel_ia, + user_id=None, + user_password=None, + device_authentication=None, + ) + _if_user_id = next( + ( + _if.user_id + for _if in self._tunnel_endpoints + if str(_if.individual_address) == selected_tunnel_ia + ), + None, + ) + _tunnel_identifier = selected_tunnel_ia or self.new_entry_data.get( + CONF_HOST ) + _tunnel_suffix = f" @ {_tunnel_identifier}" if _tunnel_identifier else "" + self.new_title = ( + f"{'Secure ' if _if_user_id else ''}Tunneling{_tunnel_suffix}" + ) + return self.finish_flow() + + # this step is only called from async_step_secure_knxkeys so self._keyring is always set + assert self._keyring + + # Filter for selected tunnel + if self._selected_tunnel is not None: + if host_ia := self._selected_tunnel.individual_address: + self._tunnel_endpoints = self._keyring.get_tunnel_interfaces_by_host( + host=host_ia + ) + if not self._tunnel_endpoints: + errors["base"] = "keyfile_no_tunnel_for_host" + description_placeholders = {CONF_HOST: str(host_ia)} + else: + self._tunnel_endpoints = self._keyring.interfaces tunnel_endpoint_options = [ selector.SelectOptionDict( @@ -532,8 +591,12 @@ class KNXCommonFlow(ABC, FlowHandler): for endpoint in self._tunnel_endpoints: tunnel_endpoint_options.append( selector.SelectOptionDict( - value=str(endpoint.user_id), - label=f"{endpoint.individual_address} (User ID: {endpoint.user_id})", + value=str(endpoint.individual_address), + label=( + f"{endpoint.individual_address} " + f"{'🔐 ' if endpoint.user_id else ''}" + f"(Data Secure GAs: {len(endpoint.group_addresses)})" + ), ) ) return self.async_show_form( @@ -541,7 +604,7 @@ class KNXCommonFlow(ABC, FlowHandler): data_schema=vol.Schema( { vol.Required( - CONF_KNX_SECURE_USER_ID, default=CONF_KNX_AUTOMATIC + CONF_KNX_TUNNEL_ENDPOINT_IA, default=CONF_KNX_AUTOMATIC ): selector.SelectSelector( selector.SelectSelectorConfig( options=tunnel_endpoint_options, @@ -550,6 +613,8 @@ class KNXCommonFlow(ABC, FlowHandler): ), } ), + errors=errors, + description_placeholders=description_placeholders, ) async def async_step_routing(self, user_input: dict | None = None) -> FlowResult: @@ -580,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: @@ -597,14 +663,20 @@ 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, + tunnel_endpoint_ia=None, ) if connection_type == CONF_KNX_ROUTING_SECURE: + self.new_title = f"Secure Routing as {_individual_address}" return self.async_show_menu( step_id="secure_key_source", menu_options=["secure_knxkeys", "secure_routing_manual"], ) - return self.finish_flow(title=f"Routing as {_individual_address}") + self.new_title = f"Routing as {_individual_address}" + return self.finish_flow() routers = [router for router in self._found_gateways if router.supports_routing] if not routers: @@ -631,6 +703,33 @@ class KNXCommonFlow(ABC, FlowHandler): step_id="routing", data_schema=vol.Schema(fields), errors=errors ) + async def _save_uploaded_knxkeys_file( + self, uploaded_file_id: str, password: str + ) -> dict[str, str]: + """Validate the uploaded file and move it to the storage directory. Return errors.""" + + def _process_upload() -> tuple[Keyring | None, dict[str, str]]: + keyring: Keyring | None = None + errors = {} + with process_uploaded_file(self.hass, uploaded_file_id) as file_path: + try: + keyring = sync_load_keyring( + path=file_path, + password=password, + ) + except InvalidSecureConfiguration: + errors[CONF_KNX_KNXKEY_PASSWORD] = "keyfile_invalid_signature" + else: + dest_path = Path(self.hass.config.path(STORAGE_DIR, DOMAIN)) + dest_path.mkdir(exist_ok=True) + dest_file = dest_path / DEFAULT_KNX_KEYRING_FILENAME + shutil.move(file_path, dest_file) + return keyring, errors + + keyring, errors = await self.hass.async_add_executor_job(_process_upload) + self._keyring = keyring + return errors + class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN): """Handle a KNX config flow.""" @@ -648,8 +747,9 @@ class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN): return KNXOptionsFlow(config_entry) @callback - def finish_flow(self, title: str) -> FlowResult: + def finish_flow(self) -> FlowResult: """Create the ConfigEntry.""" + title = self.new_title or f"KNX {self.new_entry_data[CONF_KNX_CONNECTION_TYPE]}" return self.async_create_entry( title=title, data=DEFAULT_ENTRY_DATA | self.new_entry_data, @@ -673,13 +773,13 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): super().__init__(initial_data=config_entry.data) # type: ignore[arg-type] @callback - def finish_flow(self, title: str | None) -> FlowResult: + def finish_flow(self) -> FlowResult: """Update the ConfigEntry and finish the flow.""" new_data = DEFAULT_ENTRY_DATA | self.initial_data | self.new_entry_data self.hass.config_entries.async_update_entry( self.config_entry, data=new_data, - title=title or UNDEFINED, + title=self.new_title or UNDEFINED, ) return self.async_create_entry(title="", data={}) @@ -689,7 +789,11 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): """Manage KNX options.""" return self.async_show_menu( step_id="options_init", - menu_options=["connection_type", "communication_settings"], + menu_options=[ + "connection_type", + "communication_settings", + "secure_knxkeys", + ], ) async def async_step_communication_settings( @@ -701,7 +805,7 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): state_updater=user_input[CONF_KNX_STATE_UPDATER], rate_limit=user_input[CONF_KNX_RATE_LIMIT], ) - return self.finish_flow(title=None) + return self.finish_flow() data_schema = { vol.Required( diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 058223bfaa..858f1cefea 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -1,9 +1,12 @@ """Constants for the KNX integration.""" from __future__ import annotations +from collections.abc import Awaitable, Callable from enum import Enum from typing import Final, TypedDict +from xknx.telegram import Telegram + from homeassistant.components.climate import ( PRESET_AWAY, PRESET_COMFORT, @@ -39,6 +42,7 @@ CONF_KNX_TUNNELING_TCP_SECURE: Final = "tunneling_tcp_secure" CONF_KNX_LOCAL_IP: Final = "local_ip" CONF_KNX_MCAST_GRP: Final = "multicast_group" CONF_KNX_MCAST_PORT: Final = "multicast_port" +CONF_KNX_TUNNEL_ENDPOINT_IA: Final = "tunnel_endpoint_ia" CONF_KNX_RATE_LIMIT: Final = "rate_limit" CONF_KNX_ROUTE_BACK: Final = "route_back" @@ -75,6 +79,9 @@ DATA_HASS_CONFIG: Final = "knx_hass_config" ATTR_COUNTER: Final = "counter" ATTR_SOURCE: Final = "source" +AsyncMessageCallbackType = Callable[[Telegram], Awaitable[None]] +MessageCallbackType = Callable[[Telegram], None] + class KNXConfigEntryData(TypedDict, total=False): """Config entry for the KNX integration.""" @@ -89,6 +96,7 @@ class KNXConfigEntryData(TypedDict, total=False): rate_limit: int host: str port: int + tunnel_endpoint_ia: str | None user_id: int | None user_password: str | None @@ -99,6 +107,20 @@ class KNXConfigEntryData(TypedDict, total=False): sync_latency_tolerance: int | None +class KNXBusMonitorMessage(TypedDict): + """KNX bus monitor message.""" + + destination_address: str + destination_text: str | None + payload: str + type: str + value: str | None + source_address: str + source_text: str | None + direction: str + timestamp: str + + class ColorTempModes(Enum): """Color temperature modes for config validation.""" diff --git a/homeassistant/components/knx/device.py b/homeassistant/components/knx/device.py new file mode 100644 index 0000000000..452de577ce --- /dev/null +++ b/homeassistant/components/knx/device.py @@ -0,0 +1,51 @@ +"""Handle KNX Devices.""" +from __future__ import annotations + +from xknx import XKNX +from xknx.core import XknxConnectionState +from xknx.io.gateway_scanner import GatewayDescriptor + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo + +from .const import DOMAIN + + +class KNXInterfaceDevice: + """Class for KNX Interface Device handling.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, xknx: XKNX) -> None: + """Initialize interface device class.""" + self.device_registry = dr.async_get(hass) + self.gateway_descriptor: GatewayDescriptor | None = None + self.xknx = xknx + + _device_id = (DOMAIN, f"_{entry.entry_id}_interface") + self.device = self.device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + default_name="KNX Interface", + identifiers={_device_id}, + ) + self.device_info = DeviceInfo(identifiers={_device_id}) + + self.xknx.connection_manager.register_connection_state_changed_cb( + self.connection_state_changed_cb + ) + + async def update(self) -> None: + """Update interface properties on new connection.""" + self.gateway_descriptor = await self.xknx.knxip_interface.gateway_info() + + self.device_registry.async_update_device( + device_id=self.device.id, + model=str(self.gateway_descriptor.name) + if self.gateway_descriptor + else None, + ) + + async def connection_state_changed_cb(self, state: XknxConnectionState) -> None: + """Call invoked after a KNX connection state change was received.""" + if state is XknxConnectionState.CONNECTED: + await self.update() diff --git a/homeassistant/components/knx/device_trigger.py b/homeassistant/components/knx/device_trigger.py new file mode 100644 index 0000000000..8a074b43b7 --- /dev/null +++ b/homeassistant/components/knx/device_trigger.py @@ -0,0 +1,103 @@ +"""Provides device triggers for KNX.""" +from __future__ import annotations + +from typing import Any, Final + +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers import selector +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import KNXModule +from .const import DOMAIN +from .project import KNXProject +from .schema import ga_list_validator +from .telegrams import TelegramDict + +TRIGGER_TELEGRAM: Final = "telegram" +EXTRA_FIELD_DESTINATION: Final = "destination" # no translation support + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Optional(EXTRA_FIELD_DESTINATION): ga_list_validator, + vol.Required(CONF_TYPE): TRIGGER_TELEGRAM, + } +) + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """List device triggers for KNX devices.""" + triggers = [] + + knx: KNXModule = hass.data[DOMAIN] + if knx.interface_device.device.id == device_id: + # Add trigger for KNX telegrams to interface device + triggers.append( + { + # Required fields of TRIGGER_BASE_SCHEMA + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device_id, + # Required fields of TRIGGER_SCHEMA + CONF_TYPE: TRIGGER_TELEGRAM, + } + ) + + return triggers + + +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: + """List trigger capabilities.""" + project: KNXProject = hass.data[DOMAIN].project + options = [ + selector.SelectOptionDict(value=ga.address, label=f"{ga.address} - {ga.name}") + for ga in project.group_addresses.values() + ] + return { + "extra_fields": vol.Schema( + { + vol.Optional(EXTRA_FIELD_DESTINATION): selector.SelectSelector( + selector.SelectSelectorConfig( + mode=selector.SelectSelectorMode.DROPDOWN, + multiple=True, + custom_value=True, + options=options, + ), + ), + } + ) + } + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + dst_addresses: list[str] = config.get(EXTRA_FIELD_DESTINATION, []) + job = HassJob(action, f"KNX device trigger {trigger_info}") + knx: KNXModule = hass.data[DOMAIN] + + @callback + def async_call_trigger_action(telegram: TelegramDict) -> None: + """Filter Telegram and call trigger action.""" + if dst_addresses and telegram["destination"] not in dst_addresses: + return + hass.async_run_hass_job( + job, + {"trigger": telegram}, + ) + + return knx.telegrams.async_listen_telegram( + async_call_trigger_action, name="KNX device trigger call" + ) diff --git a/homeassistant/components/knx/diagnostics.py b/homeassistant/components/knx/diagnostics.py index 60a41c9a40..2fada718d3 100644 --- a/homeassistant/components/knx/diagnostics.py +++ b/homeassistant/components/knx/diagnostics.py @@ -40,6 +40,11 @@ async def async_get_config_entry_diagnostics( diag["config_entry_data"] = async_redact_data(dict(config_entry.data), TO_REDACT) + if proj_info := knx_module.project.info: + diag["project_info"] = async_redact_data(proj_info, "name") + else: + diag["project_info"] = None + raw_config = await conf_util.async_hass_config_yaml(hass) diag["configuration_yaml"] = raw_config.get(DOMAIN) try: diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 05e367faee..308fc4eacd 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -161,8 +161,14 @@ class KNXExposeSensor: """Set new value on xknx ExposeSensor.""" try: await self.device.set(value) - except ConversionError: - _LOGGER.exception("Error during sending of expose sensor value") + except ConversionError as err: + _LOGGER.warning( + 'Could not expose %s %s value "%s" to KNX: %s', + self.entity_id, + self.expose_attribute or "state", + value, + err, + ) class KNXExposeTime: diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index e4260f5e86..f5ef8f61b8 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -256,7 +256,7 @@ class KNXLight(KnxEntity, LightEntity): return None @property - def color_mode(self) -> ColorMode | None: + def color_mode(self) -> ColorMode: """Return the color mode of the light.""" if self._device.supports_xyy_color: return ColorMode.XY @@ -276,7 +276,7 @@ class KNXLight(KnxEntity, LightEntity): return ColorMode.ONOFF @property - def supported_color_modes(self) -> set | None: + def supported_color_modes(self) -> set[ColorMode]: """Flag supported color modes.""" return {self.color_mode} diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index c8c5c37a48..b850954e8a 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -1,12 +1,18 @@ { "domain": "knx", "name": "KNX", + "after_dependencies": ["panel_custom"], "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "config_flow": true, + "dependencies": ["file_upload", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/knx", "integration_type": "hub", "iot_class": "local_push", - "loggers": ["xknx"], + "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", - "requirements": ["xknx==2.5.0"] + "requirements": [ + "xknx==2.9.0", + "xknxproject==3.1.0", + "knx_frontend==2023.5.16.204359" + ] } diff --git a/homeassistant/components/knx/project.py b/homeassistant/components/knx/project.py new file mode 100644 index 0000000000..274ef5cb9a --- /dev/null +++ b/homeassistant/components/knx/project.py @@ -0,0 +1,117 @@ +"""Handle KNX project data.""" +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Final + +from xknx.dpt import DPTBase +from xknxproject import XKNXProj +from xknxproject.models import ( + Device, + GroupAddress as GroupAddressModel, + KNXProject as KNXProjectModel, + ProjectInfo, +) + +from homeassistant.components.file_upload import process_uploaded_file +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STORAGE_VERSION: Final = 1 +STORAGE_KEY: Final = f"{DOMAIN}/knx_project.json" + + +@dataclass +class GroupAddressInfo: + """Group address info for runtime usage.""" + + address: str + name: str + description: str + dpt_main: int | None + dpt_sub: int | None + transcoder: type[DPTBase] | None + + +def _create_group_address_info(ga_model: GroupAddressModel) -> GroupAddressInfo: + """Convert GroupAddress dict value into GroupAddressInfo instance.""" + dpt = ga_model["dpt"] + transcoder = DPTBase.transcoder_by_dpt(dpt["main"], dpt.get("sub")) if dpt else None + return GroupAddressInfo( + address=ga_model["address"], + name=ga_model["name"], + description=ga_model["description"], + transcoder=transcoder, + dpt_main=dpt["main"] if dpt else None, + dpt_sub=dpt["sub"] if dpt else None, + ) + + +class KNXProject: + """Manage KNX project data.""" + + loaded: bool + devices: dict[str, Device] + group_addresses: dict[str, GroupAddressInfo] + info: ProjectInfo | None + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialize project data.""" + self.hass = hass + self._store = Store[KNXProjectModel](hass, STORAGE_VERSION, STORAGE_KEY) + + self.initial_state() + + def initial_state(self) -> None: + """Set initial state for project data.""" + self.loaded = False + self.devices = {} + self.group_addresses = {} + self.info = None + + async def load_project(self, data: KNXProjectModel | None = None) -> None: + """Load project data from storage.""" + if project := data or await self._store.async_load(): + self.devices = project["devices"] + self.info = project["info"] + + for ga_model in project["group_addresses"].values(): + ga_info = _create_group_address_info(ga_model) + self.group_addresses[ga_info.address] = ga_info + + _LOGGER.debug( + "Loaded KNX project data with %s group addresses from storage", + len(self.group_addresses), + ) + self.loaded = True + + async def process_project_file(self, file_id: str, password: str) -> None: + """Process an uploaded project file.""" + + def _parse_project() -> KNXProjectModel: + with process_uploaded_file(self.hass, file_id) as file_path: + xknxproj = XKNXProj( + file_path, + password=password, + language=self.hass.config.language, + ) + return xknxproj.parse() + + project = await self.hass.async_add_executor_job(_parse_project) + await self._store.async_save(project) + await self.load_project(data=project) + + async def remove_project_file(self) -> None: + """Remove project file from storage.""" + await self._store.async_remove() + self.initial_state() 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 64cd6151f7..ea5ba2f63a 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -1,9 +1,13 @@ """Support for KNX/IP sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta from typing import Any from xknx import XKNX +from xknx.core.connection_state import XknxConnectionState, XknxConnectionType from xknx.devices import Sensor as XknxSensor from homeassistant import config_entries @@ -11,12 +15,15 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, SensorDeviceClass, SensorEntity, + SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, CONF_NAME, CONF_TYPE, + EntityCategory, Platform, ) from homeassistant.core import HomeAssistant @@ -24,10 +31,87 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.util.enum import try_parse_enum +from . import KNXModule from .const import ATTR_SOURCE, DATA_KNX_CONFIG, DOMAIN from .knx_entity import KnxEntity from .schema import SensorSchema +SCAN_INTERVAL = timedelta(seconds=10) + + +@dataclass +class KNXSystemEntityDescription(SensorEntityDescription): + """Class describing KNX system sensor entities.""" + + always_available: bool = True + entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + has_entity_name: bool = True + should_poll: bool = True + value_fn: Callable[[KNXModule], StateType | datetime] = lambda knx: None + + +SYSTEM_ENTITY_DESCRIPTIONS = ( + KNXSystemEntityDescription( + key="individual_address", + always_available=False, + icon="mdi:router-network", + should_poll=False, + value_fn=lambda knx: str(knx.xknx.current_address), + ), + KNXSystemEntityDescription( + key="connected_since", + always_available=False, + device_class=SensorDeviceClass.TIMESTAMP, + should_poll=False, + value_fn=lambda knx: knx.xknx.connection_manager.connected_since, + ), + KNXSystemEntityDescription( + key="connection_type", + always_available=False, + device_class=SensorDeviceClass.ENUM, + options=[opt.value for opt in XknxConnectionType], + should_poll=False, + value_fn=lambda knx: knx.xknx.connection_manager.connection_type.value, # type: ignore[no-any-return] + ), + KNXSystemEntityDescription( + key="telegrams_incoming", + icon="mdi:upload-network", + entity_registry_enabled_default=False, + force_update=True, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_incoming, + ), + KNXSystemEntityDescription( + key="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", + icon="mdi:download-network", + entity_registry_enabled_default=False, + force_update=True, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_outgoing, + ), + KNXSystemEntityDescription( + key="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", + icon="mdi:plus-network", + force_update=True, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_outgoing + + knx.xknx.connection_manager.cemi_count_incoming + + knx.xknx.connection_manager.cemi_count_incoming_error, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -35,10 +119,18 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor(s) for KNX platform.""" - xknx: XKNX = hass.data[DOMAIN].xknx - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.SENSOR] + knx_module: KNXModule = hass.data[DOMAIN] - async_add_entities(KNXSensor(xknx, entity_config) for entity_config in config) + async_add_entities( + KNXSystemSensor(knx_module, description) + for description in SYSTEM_ENTITY_DESCRIPTIONS + ) + + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG].get(Platform.SENSOR) + if config: + async_add_entities( + KNXSensor(knx_module.xknx, entity_config) for entity_config in config + ) def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor: @@ -87,3 +179,51 @@ class KNXSensor(KnxEntity, SensorEntity): if self._device.last_telegram is not None: attr[ATTR_SOURCE] = str(self._device.last_telegram.source_address) return attr + + +class KNXSystemSensor(SensorEntity): + """Representation of a KNX system sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + knx: KNXModule, + description: KNXSystemEntityDescription, + ) -> None: + """Initialize of a KNX system sensor.""" + self.entity_description: KNXSystemEntityDescription = description + self.knx = knx + + 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 + def native_value(self) -> StateType | datetime: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.knx) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.entity_description.always_available: + return True + return self.knx.xknx.connection_manager.state is XknxConnectionState.CONNECTED + + async def after_update_callback(self, _: XknxConnectionState) -> None: + """Call after device was updated.""" + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Store register state change callback.""" + self.knx.xknx.connection_manager.register_connection_state_changed_cb( + self.after_update_callback + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect device object when removed.""" + self.knx.xknx.connection_manager.unregister_connection_state_changed_cb( + self.after_update_callback + ) diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index d1ac3793c0..d4a1eae11c 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": { @@ -42,14 +42,13 @@ } }, "secure_knxkeys": { - "title": "Keyfile", - "description": "Please enter the information for your `.knxkeys` file.", + "title": "Import KNX Keyring", + "description": "Please select a `.knxkeys` file to import.", "data": { - "knxkeys_filename": "The filename of your `.knxkeys` file (including extension)", + "knxkeys_file": "Keyring file", "knxkeys_password": "The password to decrypt the `.knxkeys` file" }, "data_description": { - "knxkeys_filename": "The file is expected to be found in your config directory in `.storage/knx/`.\nIn Home Assistant OS this would be `/config/.storage/knx/`\nExample: `my_project.knxkeys`", "knxkeys_password": "This was set when exporting the file from ETS." } }, @@ -94,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%]" } } }, @@ -126,7 +125,8 @@ "title": "KNX Settings", "menu_options": { "connection_type": "Configure KNX interface", - "communication_settings": "Communication settings" + "communication_settings": "Communication settings", + "secure_knxkeys": "Import a `.knxkeys` file" } }, "communication_settings": { @@ -184,11 +184,10 @@ "title": "[%key:component::knx::config::step::secure_knxkeys::title%]", "description": "[%key:component::knx::config::step::secure_knxkeys::description%]", "data": { - "knxkeys_filename": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_filename%]", + "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_file%]", "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]" }, "data_description": { - "knxkeys_filename": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_filename%]", "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]" } }, @@ -254,5 +253,38 @@ "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" + } + } + }, + "device_automation": { + "trigger_type": { + "telegram": "Telegram sent or received" + } } } diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py new file mode 100644 index 0000000000..4c5ac44f6b --- /dev/null +++ b/homeassistant/components/knx/telegrams.py @@ -0,0 +1,116 @@ +"""KNX Telegram handler.""" +from __future__ import annotations + +from collections import deque +from collections.abc import Callable +import datetime as dt +from typing import TypedDict + +from xknx import XKNX +from xknx.exceptions import XKNXException +from xknx.telegram import Telegram +from xknx.telegram.apci import GroupValueResponse, GroupValueWrite + +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +import homeassistant.util.dt as dt_util + +from .project import KNXProject + + +class TelegramDict(TypedDict): + """Represent a Telegram as a dict.""" + + destination: str + destination_name: str + direction: str + payload: int | tuple[int, ...] | None + source: str + source_name: str + telegramtype: str + timestamp: dt.datetime + unit: str | None + value: str | int | float | bool | None + + +class Telegrams: + """Class to handle KNX telegrams.""" + + def __init__(self, hass: HomeAssistant, xknx: XKNX, project: KNXProject) -> None: + """Initialize Telegrams class.""" + self.hass = hass + self.project = project + self._jobs: list[HassJob[[TelegramDict], None]] = [] + self._xknx_telegram_cb_handle = ( + xknx.telegram_queue.register_telegram_received_cb( + telegram_received_cb=self._xknx_telegram_cb, + match_for_outgoing=True, + ) + ) + self.recent_telegrams: deque[TelegramDict] = deque(maxlen=50) + + async def _xknx_telegram_cb(self, telegram: Telegram) -> None: + """Handle incoming and outgoing telegrams from xknx.""" + telegram_dict = self.telegram_to_dict(telegram) + self.recent_telegrams.appendleft(telegram_dict) + for job in self._jobs: + self.hass.async_run_hass_job(job, telegram_dict) + + @callback + def async_listen_telegram( + self, + action: Callable[[TelegramDict], None], + name: str = "KNX telegram listener", + ) -> CALLBACK_TYPE: + """Register callback to listen for telegrams.""" + job = HassJob(action, name=name) + self._jobs.append(job) + + def remove_listener() -> None: + """Remove the listener.""" + self._jobs.remove(job) + + return remove_listener + + def telegram_to_dict(self, telegram: Telegram) -> TelegramDict: + """Convert a Telegram to a dict.""" + dst_name = "" + payload_data: int | tuple[int, ...] | None = None + src_name = "" + transcoder = None + unit = None + value: str | int | float | bool | None = None + + if ( + ga_info := self.project.group_addresses.get( + f"{telegram.destination_address}" + ) + ) is not None: + dst_name = ga_info.name + transcoder = ga_info.transcoder + + if ( + device := self.project.devices.get(f"{telegram.source_address}") + ) is not None: + src_name = f"{device['manufacturer_name']} {device['name']}" + + if isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)): + payload_data = telegram.payload.value.value + if transcoder is not None: + try: + value = transcoder.from_knx(telegram.payload.value) + unit = transcoder.unit + except XKNXException: + value = "Error decoding value" + + return TelegramDict( + destination=f"{telegram.destination_address}", + destination_name=dst_name, + direction=telegram.direction.value, + payload=payload_data, + source=f"{telegram.source_address}", + source_name=src_name, + telegramtype=telegram.payload.__class__.__name__, + timestamp=dt_util.as_local(dt_util.utcnow()), + unit=unit, + value=value, + ) diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py new file mode 100644 index 0000000000..d63ba89fbc --- /dev/null +++ b/homeassistant/components/knx/websocket.py @@ -0,0 +1,219 @@ +"""KNX Websocket API.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from knx_frontend import get_build_id, locate_dir +import voluptuous as vol +from xknx.telegram import TelegramDirection +from xknxproject.exceptions import XknxProjectException + +from homeassistant.components import panel_custom, websocket_api +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN, KNXBusMonitorMessage +from .telegrams import TelegramDict + +if TYPE_CHECKING: + from . import KNXModule + + +URL_BASE: Final = "/knx_static" + + +async def register_panel(hass: HomeAssistant) -> None: + """Register the KNX Panel and Websocket API.""" + websocket_api.async_register_command(hass, ws_info) + websocket_api.async_register_command(hass, ws_project_file_process) + websocket_api.async_register_command(hass, ws_project_file_remove) + websocket_api.async_register_command(hass, ws_group_monitor_info) + websocket_api.async_register_command(hass, ws_subscribe_telegram) + + if DOMAIN not in hass.data.get("frontend_panels", {}): + path = locate_dir() + build_id = get_build_id() + hass.http.register_static_path( + URL_BASE, path, cache_headers=(build_id != "dev") + ) + await panel_custom.async_register_panel( + hass=hass, + frontend_url_path=DOMAIN, + webcomponent_name="knx-frontend", + sidebar_title=DOMAIN.upper(), + sidebar_icon="mdi:bus-electric", + module_url=f"{URL_BASE}/entrypoint-{build_id}.js", + embed_iframe=True, + require_admin=True, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/info", + } +) +@callback +def ws_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command.""" + knx: KNXModule = hass.data[DOMAIN] + + _project_info = None + if project_info := knx.project.info: + _project_info = { + "name": project_info["name"], + "last_modified": project_info["last_modified"], + "tool_version": project_info["tool_version"], + } + + connection.send_result( + msg["id"], + { + "version": knx.xknx.version, + "connected": knx.xknx.connection_manager.connected.is_set(), + "current_address": str(knx.xknx.current_address), + "project": _project_info, + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/project_file_process", + vol.Required("file_id"): str, + vol.Required("password"): str, + } +) +@websocket_api.async_response +async def ws_project_file_process( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command.""" + knx: KNXModule = hass.data[DOMAIN] + try: + await knx.project.process_project_file( + file_id=msg["file_id"], + password=msg["password"], + ) + except (ValueError, XknxProjectException) as err: + # ValueError could raise from file_upload integration + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) + ) + return + + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/project_file_remove", + } +) +@websocket_api.async_response +async def ws_project_file_remove( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command.""" + knx: KNXModule = hass.data[DOMAIN] + await knx.project.remove_project_file() + connection.send_result(msg["id"]) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/group_monitor_info", + } +) +@callback +def ws_group_monitor_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command of group monitor.""" + knx: KNXModule = hass.data[DOMAIN] + recent_telegrams = [ + _telegram_dict_to_group_monitor(telegram) + for telegram in knx.telegrams.recent_telegrams + ] + connection.send_result( + msg["id"], + { + "project_loaded": knx.project.loaded, + "recent_telegrams": recent_telegrams, + }, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/subscribe_telegrams", + } +) +@callback +def ws_subscribe_telegram( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Subscribe to incoming and outgoing KNX telegrams.""" + knx: KNXModule = hass.data[DOMAIN] + + @callback + def forward_telegram(telegram: TelegramDict) -> None: + """Forward telegram to websocket subscription.""" + connection.send_event( + msg["id"], + _telegram_dict_to_group_monitor(telegram), + ) + + connection.subscriptions[msg["id"]] = knx.telegrams.async_listen_telegram( + action=forward_telegram, + name="KNX GroupMonitor subscription", + ) + connection.send_result(msg["id"]) + + +def _telegram_dict_to_group_monitor(telegram: TelegramDict) -> KNXBusMonitorMessage: + """Convert a TelegramDict to a KNXBusMonitorMessage object.""" + direction = ( + "group_monitor_incoming" + if telegram["direction"] == TelegramDirection.INCOMING.value + else "group_monitor_outgoing" + ) + + _payload = telegram["payload"] + if isinstance(_payload, tuple): + payload = f"0x{bytes(_payload).hex()}" + elif isinstance(_payload, int): + payload = f"{_payload:d}" + else: + payload = "" + + timestamp = telegram["timestamp"].strftime("%H:%M:%S.%f")[:-3] + + if (value := telegram["value"]) is not None: + unit = telegram["unit"] + value = f"{value}{' ' + unit if unit else ''}" + + return KNXBusMonitorMessage( + destination_address=telegram["destination"], + destination_text=telegram["destination_name"], + direction=direction, + payload=payload, + source_address=telegram["source"], + source_text=telegram["source_name"], + timestamp=timestamp, + type=telegram["telegramtype"], + value=value, + ) diff --git a/homeassistant/components/kodi/device_trigger.py b/homeassistant/components/kodi/device_trigger.py index 07fcf11c07..c15c415bd9 100644 --- a/homeassistant/components/kodi/device_trigger.py +++ b/homeassistant/components/kodi/device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -33,11 +33,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Kodi devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain == "media_player": triggers.append( { diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 1ebc5ad6b8..3272491a06 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -39,7 +39,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, - device_registry, + device_registry as dr, entity_platform, ) from homeassistant.helpers.entity import DeviceInfo @@ -407,7 +407,7 @@ class KodiEntity(MediaPlayerEntity): version = (await self._kodi.get_application_properties(["version"]))["version"] sw_version = f"{version['major']}.{version['minor']}" - dev_reg = device_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) device = dev_reg.async_get_device({(DOMAIN, self.unique_id)}) dev_reg.async_update_device(device.id, sw_version=sw_version) @@ -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/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index bd629d53fc..119c7c946a 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -84,7 +84,7 @@ def ensure_zone(value): if value is None: raise vol.Invalid("zone value is None") - if str(value) not in ZONES is None: + if str(value) not in ZONES: raise vol.Invalid("zone not valid") return str(value) diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index cb43486dbe..a91fb24aad 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -12,7 +12,7 @@ from aiohttp.client_exceptions import ClientError from pykoplenti import ApiClient, ApiException, AuthenticationException from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo @@ -171,7 +171,7 @@ class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): self._fetch: dict[str, list[str]] = defaultdict(list) self._plenticore = plenticore - def start_fetch_data(self, module_id: str, data_id: str) -> None: + def start_fetch_data(self, module_id: str, data_id: str) -> CALLBACK_TYPE: """Start fetching the given data (module-id and data-id).""" self._fetch[module_id].append(data_id) @@ -180,7 +180,7 @@ class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): async def force_refresh(event_time: datetime) -> None: await self.async_request_refresh() - async_call_later(self.hass, 2, force_refresh) + return async_call_later(self.hass, 2, force_refresh) def stop_fetch_data(self, module_id: str, data_id: str) -> None: """Stop fetching the given data (module-id and data-id).""" @@ -251,7 +251,7 @@ class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator[_DataT]): def start_fetch_data( self, module_id: str, data_id: str, all_options: list[str] - ) -> None: + ) -> CALLBACK_TYPE: """Start fetching the given data (module-id and entry-id).""" self._fetch[module_id].append(data_id) self._fetch[module_id].append(all_options) @@ -261,7 +261,7 @@ class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator[_DataT]): async def force_refresh(event_time: datetime) -> None: await self.async_request_refresh() - async_call_later(self.hass, 2, force_refresh) + return async_call_later(self.hass, 2, force_refresh) def stop_fetch_data( self, module_id: str, data_id: str, all_options: list[str] diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py index 6ea3526e36..885b19faf2 100644 --- a/homeassistant/components/kostal_plenticore/number.py +++ b/homeassistant/components/kostal_plenticore/number.py @@ -188,7 +188,9 @@ class PlenticoreDataNumber( async def async_added_to_hass(self) -> None: """Register this entity on the Update Coordinator.""" await super().async_added_to_hass() - self.coordinator.start_fetch_data(self.module_id, self.data_id) + self.async_on_remove( + self.coordinator.start_fetch_data(self.module_id, self.data_id) + ) async def async_will_remove_from_hass(self) -> None: """Unregister this entity from the Update Coordinator.""" diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py index 6a7c0b35fd..2118d4b47c 100644 --- a/homeassistant/components/kostal_plenticore/select.py +++ b/homeassistant/components/kostal_plenticore/select.py @@ -127,7 +127,11 @@ class PlenticoreDataSelect( async def async_added_to_hass(self) -> None: """Register this entity on the Update Coordinator.""" await super().async_added_to_hass() - self.coordinator.start_fetch_data(self.module_id, self.data_id, self.options) + self.async_on_remove( + self.coordinator.start_fetch_data( + self.module_id, self.data_id, self.options + ) + ) async def async_will_remove_from_hass(self) -> None: """Unregister this entity from the Update Coordinator.""" diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index f919d15d6b..036f2baf98 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -24,6 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -768,7 +769,9 @@ class PlenticoreDataSensor( async def async_added_to_hass(self) -> None: """Register this entity on the Update Coordinator.""" await super().async_added_to_hass() - self.coordinator.start_fetch_data(self.module_id, self.data_id) + self.async_on_remove( + self.coordinator.start_fetch_data(self.module_id, self.data_id) + ) async def async_will_remove_from_hass(self) -> None: """Unregister this entity from the Update Coordinator.""" @@ -791,7 +794,7 @@ class PlenticoreDataSensor( return f"{self.platform_name} {self._sensor_name}" @property - def native_value(self) -> Any | None: + def native_value(self) -> StateType: """Return the state of the sensor.""" if self.coordinator.data is None: # None is translated to STATE_UNKNOWN diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 9dc4740e6e..4427f4bd4e 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -144,7 +144,9 @@ class PlenticoreDataSwitch( async def async_added_to_hass(self) -> None: """Register this entity on the Update Coordinator.""" await super().async_added_to_hass() - self.coordinator.start_fetch_data(self.module_id, self.data_id) + self.async_on_remove( + self.coordinator.start_fetch_data(self.module_id, self.data_id) + ) async def async_will_remove_from_hass(self) -> None: """Unregister this entity from the Update Coordinator.""" diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index dc86fb73d9..0250f17052 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -6,7 +6,7 @@ import logging from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -55,11 +55,11 @@ async def async_setup_entry( @callback def async_update_sensors(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Add or remove sensors for configured tracked asset pairs.""" - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) existing_devices = { device.name: device.id - for device in device_registry.async_entries_for_config_entry( + for device in dr.async_entries_for_config_entry( dev_reg, config_entry.entry_id ) } @@ -125,7 +125,7 @@ class KrakenSensor( self._attr_device_info = DeviceInfo( configuration_url="https://www.kraken.com/", - entry_type=device_registry.DeviceEntryType.SERVICE, + entry_type=dr.DeviceEntryType.SERVICE, identifiers={(DOMAIN, "_".join(self._device_name.split(" ")))}, manufacturer="Kraken.com", name=self._device_name, diff --git a/homeassistant/components/lacrosse_view/diagnostics.py b/homeassistant/components/lacrosse_view/diagnostics.py new file mode 100644 index 0000000000..754cc39d38 --- /dev/null +++ b/homeassistant/components/lacrosse_view/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for LaCrosse View.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import LaCrosseUpdateCoordinator + +TO_REDACT = {CONF_PASSWORD, CONF_USERNAME} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: LaCrosseUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + "coordinator" + ] + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "coordinator_data": coordinator.data, + } diff --git a/homeassistant/components/lacrosse_view/manifest.json b/homeassistant/components/lacrosse_view/manifest.json index 51c15d4806..1236f63dda 100644 --- a/homeassistant/components/lacrosse_view/manifest.json +++ b/homeassistant/components/lacrosse_view/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lacrosse_view", "iot_class": "cloud_polling", - "requirements": ["lacrosse-view==0.0.9"] + "loggers": ["lacrosse_view"], + "requirements": ["lacrosse-view==1.0.1"] } diff --git a/homeassistant/components/lametric/__init__.py b/homeassistant/components/lametric/__init__.py index 5fd531234b..867b80cf40 100644 --- a/homeassistant/components/lametric/__init__.py +++ b/homeassistant/components/lametric/__init__.py @@ -1,50 +1,23 @@ """Support for LaMetric time.""" -import voluptuous as vol - from homeassistant.components import notify as hass_notify from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_NAME, Platform +from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS from .coordinator import LaMetricDataUpdateCoordinator from .services import async_setup_services -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LaMetric integration.""" async_setup_services(hass) hass.data[DOMAIN] = {"hass_config": config} - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "manual_migration", - breaks_in_ha_version="2022.9.0", - is_fixable=False, - severity=IssueSeverity.ERROR, - translation_key="manual_migration", - ) - return True 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 f20732c634..21cebe46f2 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -44,20 +44,39 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, - "issues": { - "manual_migration": { - "title": "Manual migration required for LaMetric", - "description": "The LaMetric integration has been modernized: It is now configured and set up via the user interface and the communcations are now local.\n\nUnfortunately, there is no automatic migration path possible and thus requires you to re-set up your LaMetric with Home Assistant. Please consult the Home Assistant LaMetric integration documentation on how to set it up.\n\nRemove the old LaMetric YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - }, "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 eae5e91196..0279af2e61 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -1,19 +1,17 @@ """The Landis+Gyr Heat Meter integration.""" from __future__ import annotations -from datetime import timedelta import logging import ultraheat_api -from ultraheat_api.response import HeatMeterResponse from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_registry import async_migrate_entries -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import UltraheatCoordinator _LOGGER = logging.getLogger(__name__) @@ -27,22 +25,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: reader = ultraheat_api.UltraheatReader(entry.data[CONF_DEVICE]) api = ultraheat_api.HeatMeterService(reader) - async def async_update_data() -> HeatMeterResponse: - """Fetch data from the API.""" - _LOGGER.debug("Polling on %s", entry.data[CONF_DEVICE]) - return await hass.async_add_executor_job(api.read) - - # Polling is only daily to prevent battery drain. - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="ultraheat_gateway", - update_method=async_update_data, - update_interval=timedelta(days=1), - ) + coordinator = UltraheatCoordinator(hass, api) 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 @@ -80,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 bded296f3f..079bcad253 100644 --- a/homeassistant/components/landisgyr_heat_meter/const.py +++ b/homeassistant/components/landisgyr_heat_meter/const.py @@ -1,212 +1,8 @@ """Constants for the Landis+Gyr Heat Meter integration.""" -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import ( - EntityCategory, - UnitOfEnergy, - UnitOfPower, - UnitOfTemperature, - UnitOfTime, - UnitOfVolume, - UnitOfVolumeFlowRate, -) +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 - -HEAT_METER_SENSOR_TYPES = ( - SensorEntityDescription( - key="heat_usage", - icon="mdi:fire", - name="Heat usage", - native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - ), - SensorEntityDescription( - key="volume_usage_m3", - icon="mdi:fire", - name="Volume usage", - device_class=SensorDeviceClass.VOLUME, - native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, - state_class=SensorStateClass.TOTAL, - ), - # Diagnostic entity for debugging, this will match the value in GJ indicated on the meter's display - SensorEntityDescription( - key="heat_usage_gj", - icon="mdi:fire", - name="Heat usage GJ", - native_unit_of_measurement="GJ", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="heat_previous_year", - icon="mdi:fire", - name="Heat usage previous year", - native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - entity_category=EntityCategory.DIAGNOSTIC, - ), - # Diagnostic entity for debugging, this will match the value in GJ of previous year indicated on the meter's display - SensorEntityDescription( - key="heat_previous_year_gj", - icon="mdi:fire", - name="Heat previous year GJ", - native_unit_of_measurement="GJ", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="volume_previous_year_m3", - icon="mdi:fire", - name="Volume usage previous year", - device_class=SensorDeviceClass.VOLUME, - native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="ownership_number", - name="Ownership number", - icon="mdi:identifier", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="error_number", - name="Error number", - icon="mdi:home-alert", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="device_number", - name="Device number", - icon="mdi:identifier", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="measurement_period_minutes", - name="Measurement period minutes", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.MINUTES, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="power_max_kw", - name="Power max", - native_unit_of_measurement=UnitOfPower.KILO_WATT, - device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="power_max_previous_year_kw", - name="Power max previous year", - native_unit_of_measurement=UnitOfPower.KILO_WATT, - device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="flowrate_max_m3ph", - name="Flowrate max", - native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - icon="mdi:water-outline", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="flowrate_max_previous_year_m3ph", - name="Flowrate max previous year", - native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - icon="mdi:water-outline", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="return_temperature_max_c", - name="Return temperature max", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="return_temperature_max_previous_year_c", - name="Return temperature max previous year", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="flow_temperature_max_c", - name="Flow temperature max", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="flow_temperature_max_previous_year_c", - name="Flow temperature max previous year", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="operating_hours", - name="Operating hours", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.HOURS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="flow_hours", - name="Flow hours", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.HOURS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="fault_hours", - name="Fault hours", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.HOURS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="fault_hours_previous_year", - name="Fault hours previous year", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.HOURS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="yearly_set_day", - name="Yearly set day", - icon="mdi:clock-outline", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="monthly_set_day", - name="Monthly set day", - icon="mdi:clock-outline", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="meter_date_time", - name="Meter date time", - icon="mdi:clock-outline", - device_class=SensorDeviceClass.TIMESTAMP, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="measuring_range_m3ph", - name="Measuring range", - native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - icon="mdi:water-outline", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="settings_and_firmware", - name="Settings and firmware", - entity_category=EntityCategory.DIAGNOSTIC, - ), -) +POLLING_INTERVAL = timedelta(days=1) # Polling is only daily to prevent battery drain. diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py new file mode 100644 index 0000000000..c85c661e79 --- /dev/null +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -0,0 +1,37 @@ +"""Data update coordinator for the ultraheat api.""" + +import logging + +import async_timeout +import serial +from ultraheat_api.response import HeatMeterResponse +from ultraheat_api.service import HeatMeterService + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import POLLING_INTERVAL, ULTRAHEAT_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]): + """Coordinator for getting data from the ultraheat api.""" + + def __init__(self, hass: HomeAssistant, api: HeatMeterService) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + name="ultraheat", + update_interval=POLLING_INTERVAL, + ) + self.api = api + + async def _async_update_data(self) -> HeatMeterResponse: + """Fetch data from API endpoint.""" + try: + async with async_timeout.timeout(ULTRAHEAT_TIMEOUT): + return await self.hass.async_add_executor_job(self.api.read) + except (FileNotFoundError, serial.serialutil.SerialException) as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err 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 284fb5b7f3..9669648b4c 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -1,20 +1,33 @@ """Platform for sensor integration.""" from __future__ import annotations -from dataclasses import asdict +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime import logging from ultraheat_api.response import HeatMeterResponse from homeassistant.components.sensor import ( - RestoreSensor, SensorDeviceClass, + SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + EntityCategory, + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, + UnitOfVolume, + UnitOfVolumeFlowRate, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -22,11 +35,245 @@ from homeassistant.helpers.update_coordinator import ( from homeassistant.util import dt as dt_util from . import DOMAIN -from .const import GJ_TO_MWH, HEAT_METER_SENSOR_TYPES _LOGGER = logging.getLogger(__name__) +@dataclass +class HeatMeterSensorEntityDescriptionMixin: + """Mixin for additional Heat Meter sensor description attributes .""" + + value_fn: Callable[[HeatMeterResponse], StateType | datetime] + + +@dataclass +class HeatMeterSensorEntityDescription( + SensorEntityDescription, HeatMeterSensorEntityDescriptionMixin +): + """Heat Meter sensor description.""" + + +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", + name="Volume usage", + device_class=SensorDeviceClass.VOLUME, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.TOTAL, + value_fn=lambda res: res.volume_usage_m3, + ), + HeatMeterSensorEntityDescription( + key="heat_usage_gj", + icon="mdi:fire", + name="Heat usage GJ", + native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + 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: res.heat_previous_year_gj, + ), + HeatMeterSensorEntityDescription( + key="volume_previous_year_m3", + icon="mdi:fire", + name="Volume usage previous year", + device_class=SensorDeviceClass.VOLUME, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + entity_category=EntityCategory.DIAGNOSTIC, + 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: res.ownership_number, + ), + HeatMeterSensorEntityDescription( + key="error_number", + name="Error number", + icon="mdi:home-alert", + entity_category=EntityCategory.DIAGNOSTIC, + 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: res.device_number, + ), + HeatMeterSensorEntityDescription( + key="measurement_period_minutes", + name="Measurement period minutes", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: res.measurement_period_minutes, + ), + HeatMeterSensorEntityDescription( + key="power_max_kw", + name="Power max", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: res.power_max_kw, + ), + HeatMeterSensorEntityDescription( + key="power_max_previous_year_kw", + name="Power max previous year", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: res.power_max_previous_year_kw, + ), + HeatMeterSensorEntityDescription( + key="flowrate_max_m3ph", + name="Flowrate max", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + icon="mdi:water-outline", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: res.flowrate_max_m3ph, + ), + HeatMeterSensorEntityDescription( + key="flowrate_max_previous_year_m3ph", + name="Flowrate max previous year", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + icon="mdi:water-outline", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: res.flowrate_max_previous_year_m3ph, + ), + HeatMeterSensorEntityDescription( + key="return_temperature_max_c", + name="Return temperature max", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: res.return_temperature_max_c, + ), + HeatMeterSensorEntityDescription( + key="return_temperature_max_previous_year_c", + name="Return temperature max previous year", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: res.return_temperature_max_previous_year_c, + ), + HeatMeterSensorEntityDescription( + key="flow_temperature_max_c", + name="Flow temperature max", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: res.flow_temperature_max_c, + ), + HeatMeterSensorEntityDescription( + key="flow_temperature_max_previous_year_c", + name="Flow temperature max previous year", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: res.flow_temperature_max_previous_year_c, + ), + HeatMeterSensorEntityDescription( + key="operating_hours", + name="Operating hours", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: res.operating_hours, + ), + HeatMeterSensorEntityDescription( + key="flow_hours", + name="Flow hours", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: res.flow_hours, + ), + HeatMeterSensorEntityDescription( + key="fault_hours", + name="Fault hours", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: res.fault_hours, + ), + HeatMeterSensorEntityDescription( + key="fault_hours_previous_year", + name="Fault hours previous year", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + 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: 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: res.monthly_set_day, + ), + HeatMeterSensorEntityDescription( + key="meter_date_time", + name="Meter date time", + icon="mdi:clock-outline", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: dt_util.as_utc(res.meter_date_time) + if res.meter_date_time + else None, + ), + HeatMeterSensorEntityDescription( + key="measuring_range_m3ph", + name="Measuring range", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + icon="mdi:water-outline", + entity_category=EntityCategory.DIAGNOSTIC, + 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: res.settings_and_firmware, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -46,7 +293,6 @@ async def async_setup_entry( ) sensors = [] - for description in HEAT_METER_SENSOR_TYPES: sensors.append(HeatMeterSensor(coordinator, description, device)) @@ -54,14 +300,17 @@ async def async_setup_entry( class HeatMeterSensor( - CoordinatorEntity[DataUpdateCoordinator[HeatMeterResponse]], RestoreSensor + CoordinatorEntity[DataUpdateCoordinator[HeatMeterResponse]], + SensorEntity, ): """Representation of a Sensor.""" + entity_description: HeatMeterSensorEntityDescription + def __init__( self, coordinator: DataUpdateCoordinator[HeatMeterResponse], - description: SensorEntityDescription, + description: HeatMeterSensorEntityDescription, device: DeviceInfo, ) -> None: """Set up the sensor with the initial values.""" @@ -70,40 +319,17 @@ class HeatMeterSensor( self._attr_unique_id = f"{coordinator.config_entry.data['device_number']}_{description.key}" # type: ignore[union-attr] self._attr_name = f"Heat Meter {description.name}" self.entity_description = description - self._attr_device_info = device - self._attr_should_poll = bool(self.key in ("heat_usage", "heat_previous_year")) - 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_sensor_data() - if state: - self._attr_native_value = state.native_value + 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 - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - if self.key in asdict(self.coordinator.data): - if self.device_class == SensorDeviceClass.TIMESTAMP: - self._attr_native_value = dt_util.as_utc( - asdict(self.coordinator.data)[self.key] - ) - else: - self._attr_native_value = asdict(self.coordinator.data)[self.key] - - if self.key == "heat_usage": - self._attr_native_value = convert_gj_to_mwh( - self.coordinator.data.heat_usage_gj - ) - - if self.key == "heat_previous_year": - self._attr_native_value = convert_gj_to_mwh( - self.coordinator.data.heat_previous_year_gj - ) - - self.async_write_ha_state() - - -def convert_gj_to_mwh(gigajoule) -> float: - """Convert GJ to MWh using the conversion value.""" - return round(gigajoule * GJ_TO_MWH, 5) + @property + def native_value(self) -> StateType | datetime: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/lastfm/const.py b/homeassistant/components/lastfm/const.py new file mode 100644 index 0000000000..2a7f40b99e --- /dev/null +++ b/homeassistant/components/lastfm/const.py @@ -0,0 +1,15 @@ +"""Constants for LastFM.""" +import logging +from typing import Final + +LOGGER = logging.getLogger(__package__) +DOMAIN: Final = "lastfm" +DEFAULT_NAME = "LastFM" + +CONF_USERS = "users" + +ATTR_LAST_PLAYED = "last_played" +ATTR_PLAY_COUNT = "play_count" +ATTR_TOP_PLAYED = "top_played" + +STATE_NOT_SCROBBLING = "Not Scrobbling" 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..20c51f8a8c 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -2,11 +2,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,17 +13,14 @@ 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__) - -ATTR_LAST_PLAYED = "last_played" -ATTR_PLAY_COUNT = "play_count" -ATTR_TOP_PLAYED = "top_played" - -STATE_NOT_SCROBBLING = "Not Scrobbling" - -CONF_USERS = "users" - -ICON = "mdi:radio-fm" +from .const import ( + ATTR_LAST_PLAYED, + ATTR_PLAY_COUNT, + ATTR_TOP_PLAYED, + CONF_USERS, + LOGGER, + STATE_NOT_SCROBBLING, +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -36,6 +30,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 +42,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/launch_library/__init__.py b/homeassistant/components/launch_library/__init__.py index 34ee744135..e85c9c8156 100644 --- a/homeassistant/components/launch_library/__init__.py +++ b/homeassistant/components/launch_library/__init__.py @@ -40,7 +40,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update() -> LaunchLibraryData: try: return LaunchLibraryData( - upcoming_launches=await launches.upcoming_launches(), + upcoming_launches=await launches.upcoming_launches( + filters={"limit": 1, "hide_recent_previous": "True"}, + ), starship_events=await launches.starship_events(), ) except PyLaunchesException as ex: diff --git a/homeassistant/components/launch_library/manifest.json b/homeassistant/components/launch_library/manifest.json index 3816027a0c..778e5634b8 100644 --- a/homeassistant/components/launch_library/manifest.json +++ b/homeassistant/components/launch_library/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/launch_library", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["pylaunches==1.3.0"] + "requirements": ["pylaunches==1.4.0"] } diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index dcc65010e7..6153ecf454 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.7.16"] + "requirements": ["pypck==0.7.17"] } 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/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index 1af16a904d..2074966e1e 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -260,7 +260,9 @@ class LgTVDevice(MediaPlayerEntity): """Send the previous track command.""" self.send_command(LG_COMMAND.REWIND) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Tune to channel.""" if media_type != MediaType.CHANNEL: raise ValueError(f"Invalid media type: {media_type}") 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 b2265d81da..41aa58fb96 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -17,10 +17,9 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STARTED, - EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later, async_track_time_interval @@ -126,7 +125,7 @@ class LIFXDiscoveryManager: self.migrating, ) self._cancel_discovery = async_track_time_interval( - self.hass, self.async_discovery, discovery_interval + self.hass, self.async_discovery, discovery_interval, cancel_on_shutdown=True ) async def async_discovery(self, *_: Any) -> None: @@ -167,21 +166,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: We do not want the discovery task to block startup. """ - task = asyncio.create_task(discovery_manager.async_discovery()) - - @callback - def _async_stop(_: Event) -> None: - if not task.done(): - task.cancel() - - # Task must be shut down when home assistant is closing - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) + hass.async_create_background_task( + discovery_manager.async_discovery(), "lifx-discovery" + ) # Let the system settle a bit before starting discovery # to reduce the risk we miss devices because the event # loop is blocked at startup. discovery_manager.async_setup_discovery_interval() - async_call_later(hass, DISCOVERY_COOLDOWN, _async_delayed_discovery) + async_call_later( + hass, + DISCOVERY_COOLDOWN, + HassJob(_async_delayed_discovery, cancel_on_shutdown=True), + ) hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, discovery_manager.async_discovery ) @@ -217,7 +214,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..e668a7ad79 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -11,6 +11,7 @@ from typing import Any, cast from aiolifx.aiolifx import ( Light, + Message, MultiZoneDirection, MultiZoneEffectType, TileEffectType, @@ -28,20 +29,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,9 +55,10 @@ from .util import ( ) LIGHT_UPDATE_INTERVAL = 10 -SENSOR_UPDATE_INTERVAL = 30 REQUEST_REFRESH_DELAY = 0.35 LIFX_IDENTIFY_DELAY = 3.0 +ZONES_PER_COLOR_UPDATE_REQUEST = 8 + RSSI_DBM_FW = AwesomeVersion("2.77") @@ -79,7 +86,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 +109,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 +193,123 @@ 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 + ) + + def get_number_of_zones(self) -> int: + """Return the number of zones. + + If the number of zones is not yet populated, return 1 since + the device will have a least one zone. + """ + return len(self.device.color_zones) if self.device.color_zones else 1 + + @callback + def _async_build_color_zones_update_requests(self) -> list[Callable]: + """Build a color zones update request.""" + device = self.device + calls: list[Callable] = [] + for zone in range( + 0, self.get_number_of_zones(), ZONES_PER_COLOR_UPDATE_REQUEST + ): + + def _wrap_get_color_zones( + callb: Callable[[Message, dict[str, Any] | None], None], + get_color_zones_args: dict[str, Any], + ) -> None: + """Capture the callback and make sure resp_set_multizonemultizone is called before.""" + + def _wrapped_callback( + bulb: Light, + response: Message, + **kwargs: Any, + ) -> None: + # We need to call resp_set_multizonemultizone to populate + # the color_zones attribute before calling the callback + device.resp_set_multizonemultizone(response) + # Now call the original callback + callb(bulb, response, **kwargs) + + device.get_color_zones(**get_color_zones_args, callb=_wrapped_callback) + + calls.append( + partial( + _wrap_get_color_zones, + get_color_zones_args={ + "start_index": zone, + "end_index": zone + ZONES_PER_COLOR_UPDATE_REQUEST - 1, + }, + ) + ) + + return calls + 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 = self.get_number_of_zones() + 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 != self.get_number_of_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 +393,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 +451,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 +461,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 +480,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 +497,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..cb901dcbe4 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -170,9 +170,7 @@ class LIFXLight(LIFXEntity, LightEntity): async def update_during_transition(self, when: int) -> None: """Update state at the start and end of a transition.""" - if self.postponed_update: - self.postponed_update() - self.postponed_update = None + self._cancel_postponed_update() # Transition has started self.async_write_ha_state() @@ -206,61 +204,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 +271,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( @@ -330,6 +325,17 @@ class LIFXLight(LIFXEntity, LightEntity): ) return await super().async_added_to_hass() + def _cancel_postponed_update(self) -> None: + """Cancel postponed update, if applicable.""" + if self.postponed_update: + self.postponed_update() + self.postponed_update = None + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + self._cancel_postponed_update() + return await super().async_will_remove_from_hass() + class LIFXWhite(LIFXLight): """Representation of a white-only LIFX light.""" @@ -385,7 +391,7 @@ class LIFXMultiZone(LIFXColor): """Send a color change to the bulb.""" bulb = self.bulb color_zones = bulb.color_zones - num_zones = len(color_zones) + num_zones = self.coordinator.get_number_of_zones() # Zone brightness is not reported when powered off if not self.is_on and hsbk[HSBK_BRIGHTNESS] is None: 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 d8543946df..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. @@ -276,7 +280,7 @@ LIGHT_TURN_ON_SCHEMA = { vol.Exclusive(ATTR_XY_COLOR, COLOR_GROUP): vol.All( vol.Coerce(tuple), vol.ExactSequence((cv.small_float, cv.small_float)) ), - vol.Exclusive(ATTR_WHITE, COLOR_GROUP): VALID_BRIGHTNESS, + vol.Exclusive(ATTR_WHITE, COLOR_GROUP): vol.Any(True, VALID_BRIGHTNESS), ATTR_FLASH: VALID_FLASH, ATTR_EFFECT: cv.string, } @@ -405,7 +409,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: base["params"] = data return base - async def async_handle_light_on_service( + async def async_handle_light_on_service( # noqa: C901 light: LightEntity, call: ServiceCall ) -> None: """Handle turning a light on. @@ -557,6 +561,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + # If white is set to True, set it to the light's brightness + # Add a warning in Home Assistant Core 2023.5 if the brightness is set to an + # integer. + if params.get(ATTR_WHITE) is True: + params[ATTR_WHITE] = light.brightness + # If both white and brightness are specified, override white if ( supported_color_modes diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index 7b75821ab4..605434af91 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import area_registry, config_validation as cv, intent +from homeassistant.helpers import area_registry as ar, config_validation as cv, intent import homeassistant.util.color as color_util from . import ( @@ -56,9 +56,9 @@ class SetIntentHandler(intent.IntentHandler): # Look up area first to fail early area_name = slots.get("area", {}).get("value") - area: area_registry.AreaEntry | None = None + area: ar.AreaEntry | None = None if area_name is not None: - areas = area_registry.async_get(hass) + areas = ar.async_get(hass) area = areas.async_get_area(area_name) or areas.async_get_area_by_name( area_name ) diff --git a/homeassistant/components/light/recorder.py b/homeassistant/components/light/recorder.py index 9febb98fa4..e38ba888e7 100644 --- a/homeassistant/components/light/recorder.py +++ b/homeassistant/components/light/recorder.py @@ -5,7 +5,9 @@ from homeassistant.core import HomeAssistant, callback from . import ( ATTR_EFFECT_LIST, + ATTR_MAX_COLOR_TEMP_KELVIN, ATTR_MAX_MIREDS, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_MIN_MIREDS, ATTR_SUPPORTED_COLOR_MODES, ) @@ -19,4 +21,6 @@ def exclude_attributes(hass: HomeAssistant) -> set[str]: ATTR_EFFECT_LIST, ATTR_MIN_MIREDS, ATTR_MAX_MIREDS, + ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_MAX_COLOR_TEMP_KELVIN, } diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index b7843a2f0e..d1221dd121 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -12,6 +12,9 @@ turn_on: transition: name: Transition description: Duration it takes to get to next state. + filter: + supported_features: + - light.LightEntityFeature.TRANSITION selector: number: min: 0 @@ -20,11 +23,27 @@ turn_on: rgb_color: name: Color description: The color for the light (based on RGB - red, green, blue). + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: color_rgb: rgbw_color: name: RGBW-color description: A list containing four integers between 0 and 255 representing the RGBW (red, green, blue, white) color for the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[255, 100, 100, 50]" selector: @@ -32,6 +51,14 @@ turn_on: rgbww_color: name: RGBWW-color description: A list containing five integers between 0 and 255 representing the RGBWW (red, green, blue, cold white, warm white) color for the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[255, 100, 100, 50, 70]" selector: @@ -39,6 +66,14 @@ turn_on: color_name: name: Color name description: A human readable color name. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: select: @@ -195,6 +230,14 @@ turn_on: hs_color: name: Hue/Sat color description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[300, 70]" selector: @@ -202,6 +245,14 @@ turn_on: xy_color: name: XY-color description: Color for the light in XY-format. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[0.52, 0.43]" selector: @@ -209,6 +260,15 @@ turn_on: color_temp: name: Color temperature description: Color temperature for the light in mireds. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: color_temp: min_mireds: 153 @@ -216,6 +276,15 @@ turn_on: kelvin: name: Color temperature (Kelvin) description: Color temperature for the light in Kelvin. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -228,6 +297,16 @@ turn_on: description: Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -238,6 +317,16 @@ turn_on: description: Number indicating percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness and 100 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: number: min: 0 @@ -246,6 +335,16 @@ turn_on: brightness_step: name: Brightness step value description: Change brightness by an amount. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -254,6 +353,16 @@ turn_on: brightness_step_pct: name: Brightness step description: Change brightness by a percentage. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: number: min: -100 @@ -261,15 +370,16 @@ turn_on: unit_of_measurement: "%" white: name: White - description: - Set the light to white mode and change its brightness, where 0 turns - the light off, 1 is the minimum brightness and 255 is the maximum - brightness supported by the light. + description: Set the light to white mode. + filter: + attribute: + supported_color_modes: + - light.ColorMode.WHITE advanced: true selector: - number: - min: 0 - max: 255 + constant: + value: true + label: Enabled profile: name: Profile description: Name of a light profile to use. @@ -280,6 +390,9 @@ turn_on: flash: name: Flash description: If the light should flash. + filter: + supported_features: + - light.LightEntityFeature.FLASH advanced: true selector: select: @@ -291,6 +404,9 @@ turn_on: effect: name: Effect description: Light effect. + filter: + supported_features: + - light.LightEntityFeature.EFFECT selector: text: @@ -304,6 +420,9 @@ turn_off: transition: name: Transition description: Duration it takes to get to next state. + filter: + supported_features: + - light.LightEntityFeature.TRANSITION selector: number: min: 0 @@ -312,6 +431,9 @@ turn_off: flash: name: Flash description: If the light should flash. + filter: + supported_features: + - light.LightEntityFeature.FLASH advanced: true selector: select: @@ -333,6 +455,9 @@ toggle: transition: name: Transition description: Duration it takes to get to next state. + filter: + supported_features: + - light.LightEntityFeature.TRANSITION selector: number: min: 0 @@ -341,6 +466,14 @@ toggle: rgb_color: name: RGB-color description: Color for the light in RGB-format. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[255, 100, 100]" selector: @@ -348,6 +481,14 @@ toggle: color_name: name: Color name description: A human readable color name. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: select: @@ -504,6 +645,14 @@ toggle: hs_color: name: Hue/Sat color description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[300, 70]" selector: @@ -511,6 +660,14 @@ toggle: xy_color: name: XY-color description: Color for the light in XY-format. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[0.52, 0.43]" selector: @@ -518,12 +675,30 @@ toggle: color_temp: name: Color temperature (mireds) description: Color temperature for the light in mireds. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: color_temp: kelvin: name: Color temperature (Kelvin) description: Color temperature for the light in Kelvin. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -536,6 +711,16 @@ toggle: description: Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -546,11 +731,33 @@ toggle: description: Number indicating percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness and 100 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: number: min: 0 max: 100 unit_of_measurement: "%" + white: + name: White + description: Set the light to white mode. + filter: + attribute: + supported_color_modes: + - light.ColorMode.WHITE + advanced: true + selector: + constant: + value: true + label: Enabled profile: name: Profile description: Name of a light profile to use. @@ -561,6 +768,9 @@ toggle: flash: name: Flash description: If the light should flash. + filter: + supported_features: + - light.LightEntityFeature.FLASH advanced: true selector: select: @@ -572,5 +782,8 @@ toggle: effect: name: Effect description: Light effect. + filter: + supported_features: + - light.LightEntityFeature.EFFECT selector: text: diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index ee1f8e13b6..935e38d33d 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -19,10 +19,72 @@ "turned_off": "{entity_name} turned off" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "name": "[%key:component::light::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "brightness": { + "name": "Brightness" + }, + "color_mode": { + "name": "Color mode", + "state": { + "brightness": "Brightness only", + "color_temp": "Color temperature", + "hs": "HS", + "onoff": "On/Off", + "rgb": "RGB", + "rgbw": "RGBW", + "rgbww": "RGBWW", + "unknown": "Unknown", + "white": "White", + "xy": "XY" + } + }, + "color_temp": { + "name": "Color temperature (mireds)" + }, + "color_temp_kelvin": { + "name": "Color temperature (Kelvin)" + }, + "effect": { + "name": "Effect" + }, + "effect_list": { + "name": "Available effects" + }, + "max_color_temp_kelvin": { + "name": "Maximum color temperature (Kelvin)" + }, + "min_color_temp_kelvin": { + "name": "Minimum color temperature (Kelvin)" + }, + "max_mireds": { + "name": "Maximum color temperature (mireds)" + }, + "min_mireds": { + "name": "Minimum color temperature (mireds)" + }, + "supported_color_modes": { + "name": "Available color modes", + "state": { + "brightness": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::brightness%]", + "color_temp": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::color_temp%]", + "hs": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::hs%]", + "onoff": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::onoff%]", + "rgb": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgb%]", + "rgbw": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgbw%]", + "rgbww": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgbww%]", + "unknown": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::unknown%]", + "white": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::white%]", + "xy": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::xy%]" + } + } + } } } } diff --git a/homeassistant/components/linksys_smart/device_tracker.py b/homeassistant/components/linksys_smart/device_tracker.py index 3b0aeffaa6..d0440c832c 100644 --- a/homeassistant/components/linksys_smart/device_tracker.py +++ b/homeassistant/components/linksys_smart/device_tracker.py @@ -35,7 +35,7 @@ def get_scanner( class LinksysSmartWifiDeviceScanner(DeviceScanner): - """This class queries a Linksys Access Point.""" + """Class which queries a Linksys Access Point.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/lirc/__init__.py b/homeassistant/components/lirc/__init__.py index c5ebf87468..cf76213a88 100644 --- a/homeassistant/components/lirc/__init__.py +++ b/homeassistant/components/lirc/__init__.py @@ -42,7 +42,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class LircInterface(threading.Thread): - """This interfaces with the lirc daemon to read IR commands. + """Interfaces with the lirc daemon to read IR commands. When using lirc in blocking mode, sometimes repeated commands get produced in the next read of a command so we use a thread here to just wait diff --git a/homeassistant/components/litejet/__init__.py b/homeassistant/components/litejet/__init__.py index 040b8688a4..291333d0b7 100644 --- a/homeassistant/components/litejet/__init__.py +++ b/homeassistant/components/litejet/__init__.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -63,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: system.on_connected_changed(handle_connected_changed) - async def handle_stop(event) -> None: + async def handle_stop(event: Event) -> None: await system.close() entry.async_on_unload( diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index 25d454071c..c469d480ca 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -76,7 +76,7 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_data): + async def async_step_import(self, import_data: dict[str, Any]) -> FlowResult: """Import litejet config from configuration.yaml.""" return self.async_create_entry(title=import_data[CONF_PORT], data=import_data) diff --git a/homeassistant/components/litejet/light.py b/homeassistant/components/litejet/light.py index 09855a4d0d..9b771bdc03 100644 --- a/homeassistant/components/litejet/light.py +++ b/homeassistant/components/litejet/light.py @@ -15,6 +15,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_DEFAULT_TRANSITION, DOMAIN @@ -46,19 +47,25 @@ class LiteJetLight(LightEntity): _attr_should_poll = False _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_supported_features = LightEntityFeature.TRANSITION + _attr_has_entity_name = True + _attr_name = None def __init__( - self, config_entry: ConfigEntry, litejet: LiteJet, index: int, name: str + self, config_entry: ConfigEntry, system: LiteJet, index: int, name: str ) -> None: """Initialize a LiteJet light.""" self._config_entry = config_entry - self._lj = litejet + self._lj = system self._index = index self._attr_brightness = 0 self._attr_is_on = False - self._attr_name = name self._attr_unique_id = f"{config_entry.entry_id}_{index}" self._attr_extra_state_attributes = {ATTR_NUMBER: self._index} + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{config_entry.entry_id}_light_{index}")}, + name=name, + via_device=(DOMAIN, f"{config_entry.entry_id}_mcp"), + ) async def async_added_to_hass(self) -> None: """Run when this Entity has been added to HA.""" @@ -71,7 +78,7 @@ class LiteJetLight(LightEntity): self._lj.unsubscribe(self._on_load_changed) self._lj.unsubscribe(self._on_connected_changed) - def _on_load_changed(self, level) -> None: + def _on_load_changed(self, level: int | None) -> None: """Handle state changes.""" self.schedule_update_ha_state(True) diff --git a/homeassistant/components/litejet/manifest.json b/homeassistant/components/litejet/manifest.json index b2b213d06f..136880257c 100644 --- a/homeassistant/components/litejet/manifest.json +++ b/homeassistant/components/litejet/manifest.json @@ -4,7 +4,9 @@ "codeowners": ["@joncar"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/litejet", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["pylitejet"], + "quality_scale": "platinum", "requirements": ["pylitejet==0.5.0"] } diff --git a/homeassistant/components/litejet/scene.py b/homeassistant/components/litejet/scene.py index c6d4817cca..83eb2cc5f0 100644 --- a/homeassistant/components/litejet/scene.py +++ b/homeassistant/components/litejet/scene.py @@ -8,6 +8,7 @@ from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -37,14 +38,21 @@ async def async_setup_entry( class LiteJetScene(Scene): """Representation of a single LiteJet scene.""" - def __init__( - self, entry_id, lj: LiteJet, i, name # pylint: disable=invalid-name - ) -> None: + _attr_has_entity_name = True + _attr_entity_registry_enabled_default = False + + def __init__(self, entry_id: str, system: LiteJet, i: int, name: str) -> None: """Initialize the scene.""" - self._lj = lj + self._lj = system self._index = i self._attr_unique_id = f"{entry_id}_{i}" self._attr_name = name + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry_id}_mcp")}, + name="LiteJet", + manufacturer="Centralite", + model="CL24", + ) async def async_added_to_hass(self) -> None: """Run when this Entity has been added to HA.""" @@ -59,7 +67,7 @@ class LiteJetScene(Scene): self.async_write_ha_state() @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device-specific state attributes.""" return {ATTR_NUMBER: self._index} @@ -69,8 +77,3 @@ class LiteJetScene(Scene): await self._lj.activate_scene(self._index) except LiteJetError as exc: raise HomeAssistantError() from exc - - @property - def entity_registry_enabled_default(self) -> bool: - """Scenes are only enabled by explicit user choice.""" - return False diff --git a/homeassistant/components/litejet/switch.py b/homeassistant/components/litejet/switch.py index c2dcebd326..97a5122342 100644 --- a/homeassistant/components/litejet/switch.py +++ b/homeassistant/components/litejet/switch.py @@ -3,10 +3,11 @@ from typing import Any from pylitejet import LiteJet, LiteJetError -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.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -35,15 +36,27 @@ class LiteJetSwitch(SwitchEntity): """Representation of a single LiteJet switch.""" _attr_should_poll = False + _attr_has_entity_name = True + _attr_entity_registry_enabled_default = False + _attr_device_class = SwitchDeviceClass.SWITCH - def __init__(self, entry_id, lj, i, name): # pylint: disable=invalid-name + def __init__(self, entry_id: str, system: LiteJet, i: int, name: str) -> None: """Initialize a LiteJet switch.""" - self._entry_id = entry_id - self._lj = lj + self._lj = system self._index = i self._attr_is_on = False + self._attr_unique_id = f"{entry_id}_{i}" self._attr_name = name + # Keypad #1 has switches 1-6, #2 has 7-12, ... + keypad_number = int((i - 1) / 6) + 1 + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry_id}_keypad_{keypad_number}")}, + name=f"Keypad #{keypad_number}", + manufacturer="Centralite", + via_device=(DOMAIN, f"{entry_id}_mcp"), + ) + async def async_added_to_hass(self) -> None: """Run when this Entity has been added to HA.""" self._lj.on_switch_pressed(self._index, self._on_switch_pressed) @@ -69,12 +82,7 @@ class LiteJetSwitch(SwitchEntity): self.async_write_ha_state() @property - def unique_id(self): - """Return a unique identifier for this switch.""" - return f"{self._entry_id}_{self._index}" - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device-specific state attributes.""" return {ATTR_NUMBER: self._index} @@ -91,8 +99,3 @@ class LiteJetSwitch(SwitchEntity): await self._lj.release_switch(self._index) except LiteJetError as exc: raise HomeAssistantError() from exc - - @property - def entity_registry_enabled_default(self) -> bool: - """Switches are only enabled by explicit user choice.""" - return False diff --git a/homeassistant/components/litejet/trigger.py b/homeassistant/components/litejet/trigger.py index 926ed69637..df5ffac9b9 100644 --- a/homeassistant/components/litejet/trigger.py +++ b/homeassistant/components/litejet/trigger.py @@ -2,6 +2,8 @@ from __future__ import annotations from collections.abc import Callable +from datetime import datetime +from typing import cast from pylitejet import LiteJet import voluptuous as vol @@ -42,7 +44,7 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Listen for events based on configuration.""" trigger_data = trigger_info["trigger_data"] - number = config.get(CONF_NUMBER) + number = cast(int, config[CONF_NUMBER]) held_more_than = config.get(CONF_HELD_MORE_THAN) held_less_than = config.get(CONF_HELD_LESS_THAN) pressed_time = None @@ -50,7 +52,7 @@ async def async_attach_trigger( job = HassJob(action) @callback - def call_action(): + def call_action() -> None: """Call action with right context.""" hass.async_run_hass_job( job, @@ -72,11 +74,11 @@ async def async_attach_trigger( # neither: trigger on pressed @callback - def pressed_more_than_satisfied(now): + def pressed_more_than_satisfied(now: datetime) -> None: """Handle the LiteJet's switch's button pressed >= held_more_than.""" call_action() - def pressed(): + def pressed() -> None: """Handle the press of the LiteJet switch's button.""" nonlocal cancel_pressed_more_than, pressed_time nonlocal held_less_than, held_more_than @@ -88,10 +90,12 @@ async def async_attach_trigger( hass, pressed_more_than_satisfied, dt_util.utcnow() + held_more_than ) - def released(): + def released() -> None: """Handle the release of the LiteJet switch's button.""" nonlocal cancel_pressed_more_than, pressed_time nonlocal held_less_than, held_more_than + if pressed_time is None: + return if cancel_pressed_more_than is not None: cancel_pressed_more_than() cancel_pressed_more_than = None @@ -110,7 +114,7 @@ async def async_attach_trigger( system.on_switch_released(number, released) @callback - def async_remove(): + def async_remove() -> None: """Remove all subscriptions used for this trigger.""" system.unsubscribe(pressed) system.unsubscribe(released) 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/sensor.py b/homeassistant/components/litterrobot/sensor.py index 4c63f1c3fa..e7aed366fa 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -140,7 +140,7 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { name="Pet weight", native_unit_of_measurement=UnitOfMass.POUNDS, device_class=SensorDeviceClass.WEIGHT, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, ), ], FeederRobot: [ 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 e71c6bca66..b0387c6dcc 100644 --- a/homeassistant/components/livisi/__init__.py +++ b/homeassistant/components/livisi/__init__.py @@ -8,14 +8,15 @@ from aiolivisi import AioLivisi from homeassistant import core from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, device_registry as dr -from .const import DOMAIN, SWITCH_PLATFORM +from .const import DOMAIN from .coordinator import LivisiDataUpdateCoordinator -PLATFORMS: Final = [SWITCH_PLATFORM] +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 new file mode 100644 index 0000000000..952363650d --- /dev/null +++ b/homeassistant/components/livisi/climate.py @@ -0,0 +1,164 @@ +"""Code to handle a Livisi Virtual Climate Control.""" +from __future__ import annotations + +from typing import Any + +from aiolivisi.const import CAPABILITY_CONFIG + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DOMAIN, + LIVISI_STATE_CHANGE, + LOGGER, + MAX_TEMPERATURE, + MIN_TEMPERATURE, + VRCC_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 climate device.""" + coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + @callback + def handle_coordinator_update() -> None: + """Add climate device.""" + shc_devices: list[dict[str, Any]] = coordinator.data + entities: list[ClimateEntity] = [] + for device in shc_devices: + if ( + device["type"] == VRCC_DEVICE_TYPE + and device["id"] not in coordinator.devices + ): + livisi_climate: ClimateEntity = LivisiClimate( + config_entry, coordinator, device + ) + LOGGER.debug("Include device type: %s", device.get("type")) + coordinator.devices.add(device["id"]) + entities.append(livisi_climate) + async_add_entities(entities) + + config_entry.async_on_unload( + coordinator.async_add_listener(handle_coordinator_update) + ) + + +class LivisiClimate(LivisiEntity, ClimateEntity): + """Represents the Livisi Climate.""" + + _attr_hvac_modes = [HVACMode.HEAT] + _attr_hvac_mode = HVACMode.HEAT + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: LivisiDataUpdateCoordinator, + device: dict[str, Any], + ) -> None: + """Initialize the Livisi Climate.""" + super().__init__( + config_entry, coordinator, device, use_room_as_device_name=True + ) + + self._target_temperature_capability = self.capabilities["RoomSetpoint"] + 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( + self._target_temperature_capability, + kwargs.get(ATTR_TEMPERATURE), + self.coordinator.is_avatar, + ) + if response is None: + self._attr_available = False + raise HomeAssistantError(f"Failed to turn off {self._attr_name}") + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + await super().async_added_to_hass() + + 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_device_state( + self._temperature_capability, "temperature" + ) + humidity = await self.coordinator.async_get_device_state( + self._humidity_capability, "humidity" + ) + if temperature is None: + self._attr_current_temperature = None + self._attr_available = False + else: + self._attr_target_temperature = target_temperature + self._attr_current_temperature = temperature + self._attr_current_humidity = humidity + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._target_temperature_capability}", + self.update_target_temperature, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._temperature_capability}", + self.update_temperature, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._humidity_capability}", + self.update_humidity, + ) + ) + + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Do nothing as LIVISI devices do not support changing the hvac mode.""" + + @callback + def update_target_temperature(self, target_temperature: float) -> None: + """Update the target temperature of the climate device.""" + self._attr_target_temperature = target_temperature + self.async_write_ha_state() + + @callback + def update_temperature(self, current_temperature: float) -> None: + """Update the current temperature of the climate device.""" + self._attr_current_temperature = current_temperature + self.async_write_ha_state() + + @callback + def update_humidity(self, humidity: int) -> None: + """Update the humidity of the climate device.""" + self._attr_current_humidity = humidity + self.async_write_ha_state() diff --git a/homeassistant/components/livisi/const.py b/homeassistant/components/livisi/const.py index e6abc5118d..f6435298f1 100644 --- a/homeassistant/components/livisi/const.py +++ b/homeassistant/components/livisi/const.py @@ -7,12 +7,17 @@ DOMAIN = "livisi" CONF_HOST = "host" CONF_PASSWORD: Final = "password" +AVATAR = "Avatar" AVATAR_PORT: Final = 9090 CLASSIC_PORT: Final = 8080 DEVICE_POLLING_DELAY: Final = 60 LIVISI_STATE_CHANGE: Final = "livisi_state_change" LIVISI_REACHABILITY_CHANGE: Final = "livisi_reachability_change" -SWITCH_PLATFORM: Final = "switch" +SWITCH_DEVICE_TYPES: Final = ["ISS", "ISS2", "PSS", "PSSO"] +VRCC_DEVICE_TYPE: Final = "VRCC" +WDS_DEVICE_TYPE: Final = "WDS" -PSS_DEVICE_TYPE: Final = "PSS" + +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 47a612274a..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 @@ -13,6 +14,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( + AVATAR, AVATAR_PORT, CLASSIC_PORT, CONF_HOST, @@ -54,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.""" @@ -69,28 +78,27 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): livisi_connection_data=livisi_connection_data ) controller_data = await self.aiolivisi.async_get_controller() - if controller_data["controllerType"] == "Avatar": + if (controller_type := controller_data["controllerType"]) == AVATAR: self.port = AVATAR_PORT self.is_avatar = True else: self.port = CLASSIC_PORT self.is_avatar = False + self.controller_type = controller_type self.serial_number = controller_data["serialNumber"] - self.controller_type = controller_data["controllerType"] async def async_get_devices(self) -> 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.""" + 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"] + return response.get(key, {}).get("value") async def async_set_all_rooms(self) -> None: """Set the room list.""" @@ -102,28 +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.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/entity.py b/homeassistant/components/livisi/entity.py new file mode 100644 index 0000000000..ebd2b81385 --- /dev/null +++ b/homeassistant/components/livisi/entity.py @@ -0,0 +1,81 @@ +"""Code to handle a Livisi switches.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiolivisi.const import CAPABILITY_MAP + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, LIVISI_REACHABILITY_CHANGE +from .coordinator import LivisiDataUpdateCoordinator + + +class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]): + """Represents a base livisi entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: LivisiDataUpdateCoordinator, + device: dict[str, Any], + *, + use_room_as_device_name: bool = False, + ) -> None: + """Initialize the common properties of a Livisi device.""" + self.aio_livisi = coordinator.aiolivisi + self.capabilities: Mapping[str, Any] = device[CAPABILITY_MAP] + + name = device["config"]["name"] + unique_id = device["id"] + + room_id: str | None = device.get("location") + room_name: str | None = None + if room_id is not None: + room_name = coordinator.rooms.get(room_id) + + self._attr_available = False + self._attr_unique_id = unique_id + + device_name = name + + # For livisi climate entities, the device should have the room name from + # the livisi setup, as each livisi room gets exactly one VRCC device. The entity + # name will always be some localized value of "Climate", so the full element name + # in homeassistent will be in the form of "Bedroom Climate" + if use_room_as_device_name and room_name is not None: + self._attr_name = name + device_name = room_name + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=device["manufacturer"], + model=device["type"], + name=device_name, + suggested_area=room_name, + via_device=(DOMAIN, config_entry.entry_id), + ) + super().__init__(coordinator) + + async def async_added_to_hass(self) -> None: + """Register callback for reachability.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_REACHABILITY_CHANGE}_{self.unique_id}", + self.update_reachability, + ) + ) + + @callback + def update_reachability(self, is_reachable: bool) -> None: + """Update the reachability of the device.""" + self._attr_available = is_reachable + self.async_write_ha_state() diff --git a/homeassistant/components/livisi/manifest.json b/homeassistant/components/livisi/manifest.json index 849cfdad5c..e6f46324ed 100644 --- a/homeassistant/components/livisi/manifest.json +++ b/homeassistant/components/livisi/manifest.json @@ -1,9 +1,9 @@ { "domain": "livisi", "name": "LIVISI Smart Home", - "codeowners": ["@StefanIacobLivisi"], + "codeowners": ["@StefanIacobLivisi", "@planbnet"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/livisi", "iot_class": "local_polling", - "requirements": ["aiolivisi==0.0.15"] + "requirements": ["aiolivisi==0.0.19"] } diff --git a/homeassistant/components/livisi/switch.py b/homeassistant/components/livisi/switch.py index bcb9a20441..2c5a2b5137 100644 --- a/homeassistant/components/livisi/switch.py +++ b/homeassistant/components/livisi/switch.py @@ -8,18 +8,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - DOMAIN, - LIVISI_REACHABILITY_CHANGE, - LIVISI_STATE_CHANGE, - LOGGER, - PSS_DEVICE_TYPE, -) +from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, SWITCH_DEVICE_TYPES from .coordinator import LivisiDataUpdateCoordinator +from .entity import LivisiEntity async def async_setup_entry( @@ -37,11 +30,11 @@ async def async_setup_entry( entities: list[SwitchEntity] = [] for device in shc_devices: if ( - device["type"] == PSS_DEVICE_TYPE + device["type"] in SWITCH_DEVICE_TYPES and device["id"] not in coordinator.devices ): - livisi_switch: SwitchEntity = create_entity( - config_entry, device, coordinator + livisi_switch: SwitchEntity = LivisiSwitch( + config_entry, coordinator, device ) LOGGER.debug("Include device type: %s", device["type"]) coordinator.devices.add(device["id"]) @@ -53,59 +46,18 @@ async def async_setup_entry( ) -def create_entity( - config_entry: ConfigEntry, - device: dict[str, Any], - coordinator: LivisiDataUpdateCoordinator, -) -> SwitchEntity: - """Create Switch Entity.""" - config_details: dict[str, Any] = device["config"] - capabilities: list = device["capabilities"] - room_id: str = device["location"] - room_name: str = coordinator.rooms[room_id] - livisi_switch = LivisiSwitch( - config_entry, - coordinator, - unique_id=device["id"], - manufacturer=device["manufacturer"], - device_type=device["type"], - name=config_details["name"], - capability_id=capabilities[0], - room=room_name, - ) - return livisi_switch - - -class LivisiSwitch(CoordinatorEntity[LivisiDataUpdateCoordinator], SwitchEntity): +class LivisiSwitch(LivisiEntity, SwitchEntity): """Represents the Livisi Switch.""" def __init__( self, config_entry: ConfigEntry, coordinator: LivisiDataUpdateCoordinator, - unique_id: str, - manufacturer: str, - device_type: str, - name: str, - capability_id: str, - room: str, + device: dict[str, Any], ) -> None: - """Initialize the Livisi Switch.""" - self.config_entry = config_entry - self._attr_unique_id = unique_id - self._attr_name = name - self._capability_id = capability_id - self.aio_livisi = coordinator.aiolivisi - self._attr_available = False - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - manufacturer=manufacturer, - model=device_type, - name=name, - suggested_area=room, - via_device=(DOMAIN, config_entry.entry_id), - ) - super().__init__(coordinator) + """Initialize the Livisi switch.""" + super().__init__(config_entry, coordinator, device) + self._capability_id = self.capabilities["SwitchActuator"] async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" @@ -127,7 +79,11 @@ class LivisiSwitch(CoordinatorEntity[LivisiDataUpdateCoordinator], SwitchEntity) async def async_added_to_hass(self) -> None: """Register callbacks.""" - response = await self.coordinator.async_get_pss_state(self._capability_id) + await super().async_added_to_hass() + + 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 @@ -140,22 +96,9 @@ class LivisiSwitch(CoordinatorEntity[LivisiDataUpdateCoordinator], SwitchEntity) self.update_states, ) ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{LIVISI_REACHABILITY_CHANGE}_{self.unique_id}", - self.update_reachability, - ) - ) @callback def update_states(self, state: bool) -> None: - """Update the states of the switch device.""" + """Update the state of the switch device.""" self._attr_is_on = state self.async_write_ha_state() - - @callback - def update_reachability(self, is_reachable: bool) -> None: - """Update the reachability of the switch device.""" - self._attr_available = is_reachable - self.async_write_ha_state() diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index be6fb4a17b..c8807d40cc 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -2,26 +2,29 @@ from __future__ import annotations -from datetime import datetime +from datetime import date, datetime, timedelta import logging from typing import Any from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream from ical.event import Event -from ical.store import EventStore +from ical.store import EventStore, EventStoreError from ical.types import Range, Recur from pydantic import ValidationError import voluptuous as vol from homeassistant.components.calendar import ( + EVENT_END, EVENT_RRULE, + EVENT_START, CalendarEntity, CalendarEntityFeature, CalendarEvent, ) 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 dt as dt_util @@ -30,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, @@ -40,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) @@ -79,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: @@ -117,11 +122,14 @@ class LocalCalendarEntity(CalendarEntity): range_value: Range = Range.NONE if recurrence_range == Range.THIS_AND_FUTURE: range_value = Range.THIS_AND_FUTURE - EventStore(self._calendar).delete( - uid, - recurrence_id=recurrence_id, - recurrence_range=range_value, - ) + try: + EventStore(self._calendar).delete( + uid, + recurrence_id=recurrence_id, + recurrence_range=range_value, + ) + except EventStoreError as err: + raise HomeAssistantError(f"Error while deleting event: {err}") from err await self._async_store() await self.async_update_ha_state(force_refresh=True) @@ -137,12 +145,15 @@ class LocalCalendarEntity(CalendarEntity): range_value: Range = Range.NONE if recurrence_range == Range.THIS_AND_FUTURE: range_value = Range.THIS_AND_FUTURE - EventStore(self._calendar).edit( - uid, - new_event, - recurrence_id=recurrence_id, - recurrence_range=range_value, - ) + try: + EventStore(self._calendar).edit( + uid, + new_event, + recurrence_id=recurrence_id, + recurrence_range=range_value, + ) + except EventStoreError as err: + raise HomeAssistantError(f"Error while updating event: {err}") from err await self._async_store() await self.async_update_ha_state(force_refresh=True) @@ -151,6 +162,21 @@ def _parse_event(event: dict[str, Any]) -> Event: """Parse an ical event from a home assistant event dictionary.""" if rrule := event.get(EVENT_RRULE): event[EVENT_RRULE] = Recur.from_rrule(rrule) + + # This function is called with new events created in the local timezone, + # however ical library does not properly return recurrence_ids for + # start dates with a timezone. For now, ensure any datetime is stored as a + # floating local time to ensure we still apply proper local timezone rules. + # This can be removed when ical is updated with a new recurrence_id format + # https://github.com/home-assistant/core/issues/87759 + for key in (EVENT_START, EVENT_END): + if ( + (value := event[key]) + and isinstance(value, datetime) + and value.tzinfo is not None + ): + event[key] = dt_util.as_local(value).replace(tzinfo=None) + try: return Event.parse_obj(event) except ValidationError as err: @@ -160,12 +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=event.start, - end=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/diagnostics.py b/homeassistant/components/local_calendar/diagnostics.py new file mode 100644 index 0000000000..51b53ff007 --- /dev/null +++ b/homeassistant/components/local_calendar/diagnostics.py @@ -0,0 +1,27 @@ +"""Provides diagnostics for local calendar.""" + +import datetime +from typing import Any + +from ical.diagnostics import redact_ics + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + payload: dict[str, Any] = { + "now": dt_util.now().isoformat(), + "timezone": str(dt_util.DEFAULT_TIME_ZONE), + "system_timezone": str(datetime.datetime.utcnow().astimezone().tzinfo), + } + store = hass.data[DOMAIN][config_entry.entry_id] + ics = await store.async_load() + payload["ics"] = "\n".join(redact_ics(ics)) + return payload diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index a1659a9ba3..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.2.9"] + "requirements": ["ical==4.5.1"] } diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 86a63538a6..8cbce69dc7 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -33,11 +33,13 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.service import remove_entity_service_fields from homeassistant.helpers.typing import ConfigType, StateType _LOGGER = logging.getLogger(__name__) ATTR_CHANGED_BY = "changed_by" +CONF_DEFAULT_CODE = "default_code" DOMAIN = "lock" SCAN_INTERVAL = timedelta(seconds=30) @@ -87,32 +89,38 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: """Lock the lock.""" - code: str = service_call.data.get(ATTR_CODE, "") + code: str = service_call.data.get( + ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access + ) if entity.code_format_cmp and not entity.code_format_cmp.match(code): raise ValueError( f"Code '{code}' for locking {entity.entity_id} doesn't match pattern {entity.code_format}" ) - await entity.async_lock(**service_call.data) + await entity.async_lock(**remove_entity_service_fields(service_call)) async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None: """Unlock the lock.""" - code: str = service_call.data.get(ATTR_CODE, "") + code: str = service_call.data.get( + ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access + ) if entity.code_format_cmp and not entity.code_format_cmp.match(code): raise ValueError( f"Code '{code}' for unlocking {entity.entity_id} doesn't match pattern {entity.code_format}" ) - await entity.async_unlock(**service_call.data) + await entity.async_unlock(**remove_entity_service_fields(service_call)) async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None: """Open the door latch.""" - code: str = service_call.data.get(ATTR_CODE, "") + code: str = service_call.data.get( + ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access + ) if entity.code_format_cmp and not entity.code_format_cmp.match(code): raise ValueError( f"Code '{code}' for opening {entity.entity_id} doesn't match pattern {entity.code_format}" ) - await entity.async_open(**service_call.data) + await entity.async_open(**remove_entity_service_fields(service_call)) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -144,6 +152,7 @@ class LockEntity(Entity): _attr_is_jammed: bool | None = None _attr_state: None = None _attr_supported_features: LockEntityFeature = LockEntityFeature(0) + _lock_option_default_code: str = "" __code_format_cmp: re.Pattern[str] | None = None @property @@ -242,3 +251,34 @@ class LockEntity(Entity): def supported_features(self) -> LockEntityFeature: """Return the list of supported features.""" return self._attr_supported_features + + async def async_internal_added_to_hass(self) -> None: + """Call when the sensor entity is added to hass.""" + await super().async_internal_added_to_hass() + if not self.registry_entry: + return + self._async_read_entity_options() + + @callback + def async_registry_entry_updated(self) -> None: + """Run when the entity registry entry has been updated.""" + self._async_read_entity_options() + + @callback + def _async_read_entity_options(self) -> None: + """Read entity options from entity registry. + + Called when the entity registry entry has been updated and before the lock is + added to the state machine. + """ + assert self.registry_entry + if (lock_options := self.registry_entry.options.get(DOMAIN)) and ( + custom_default_lock_code := lock_options.get(CONF_DEFAULT_CODE) + ): + if self.code_format_cmp and self.code_format_cmp.match( + custom_default_lock_code + ): + self._lock_option_default_code = custom_default_lock_code + return + + self._lock_option_default_code = "" diff --git a/homeassistant/components/lock/device_action.py b/homeassistant/components/lock/device_action.py index 3ff8d10c7a..01e7b21d4b 100644 --- a/homeassistant/components/lock/device_action.py +++ b/homeassistant/components/lock/device_action.py @@ -14,7 +14,7 @@ from homeassistant.const import ( SERVICE_UNLOCK, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -35,11 +35,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Lock devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index cdaa02de61..c439fe99d1 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -17,7 +17,11 @@ from homeassistant.const import ( STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -45,11 +49,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Lock devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 9fc35fb135..ec996d4f0b 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -19,7 +19,7 @@ from homeassistant.const import ( STATE_UNLOCKING, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -40,11 +40,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Lock devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue 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/lock/strings.json b/homeassistant/components/lock/strings.json index 9e4c4ea726..da4b5217b8 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -15,10 +15,24 @@ "unlocked": "{entity_name} unlocked" } }, - "state": { + "entity_component": { "_": { - "locked": "[%key:common::state::locked%]", - "unlocked": "[%key:common::state::unlocked%]" + "name": "[%key:component::lock::title%]", + "state": { + "jammed": "Jammed", + "locked": "[%key:common::state::locked%]", + "locking": "Locking", + "unlocked": "[%key:common::state::unlocked%]", + "unlocking": "Unlocking" + }, + "state_attributes": { + "code_format": { + "name": "[%key:component::alarm_control_panel::entity_component::_::state_attributes::code_format::name%]" + }, + "changed_by": { + "name": "[%key:component::alarm_control_panel::entity_component::_::state_attributes::changed_by::name%]" + } + } } } } diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index fb1b9d78b8..ee2ae3da4d 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ATTR_NAME, EVENT_LOGBOOK_ENTRY, ) -from homeassistant.core import Context, Event, HomeAssistant, ServiceCall, callback +from homeassistant.core import Context, HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, @@ -35,7 +35,6 @@ from . import rest_api, websocket_api from .const import ( # noqa: F401 ATTR_MESSAGE, DOMAIN, - LOGBOOK_ENTITIES_FILTER, LOGBOOK_ENTRY_CONTEXT_ID, LOGBOOK_ENTRY_DOMAIN, LOGBOOK_ENTRY_ENTITY_ID, @@ -43,9 +42,8 @@ from .const import ( # noqa: F401 LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME, LOGBOOK_ENTRY_SOURCE, - LOGBOOK_FILTERS, ) -from .models import LazyEventPartialState # noqa: F401 +from .models import LazyEventPartialState, LogbookConfig CONFIG_SCHEMA = vol.Schema( {DOMAIN: INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA}, extra=vol.ALLOW_EXTRA @@ -97,7 +95,6 @@ def async_log_entry( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Logbook setup.""" - hass.data[DOMAIN] = {} @callback def log_message(service: ServiceCall) -> None: @@ -134,8 +131,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else: filters = None entities_filter = None - hass.data[LOGBOOK_FILTERS] = filters - hass.data[LOGBOOK_ENTITIES_FILTER] = entities_filter + + external_events: dict[ + str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] + ] = {} + hass.data[DOMAIN] = LogbookConfig(external_events, filters, entities_filter) websocket_api.async_setup(hass) rest_api.async_setup(hass, config, filters, entities_filter) hass.services.async_register(DOMAIN, "log", log_message, schema=LOG_MESSAGE_SCHEMA) @@ -149,14 +149,16 @@ async def _process_logbook_platform( hass: HomeAssistant, domain: str, platform: Any ) -> None: """Process a logbook platform.""" + logbook_config: LogbookConfig = hass.data[DOMAIN] + external_events = logbook_config.external_events @callback def _async_describe_event( domain: str, event_name: str, - describe_callback: Callable[[Event], dict[str, Any]], + describe_callback: Callable[[LazyEventPartialState], dict[str, Any]], ) -> None: """Teach logbook how to describe a new event.""" - hass.data[DOMAIN][event_name] = (domain, describe_callback) + external_events[event_name] = (domain, describe_callback) platform.async_describe_events(hass, _async_describe_event) diff --git a/homeassistant/components/logbook/const.py b/homeassistant/components/logbook/const.py index e1abd98765..2d9911117f 100644 --- a/homeassistant/components/logbook/const.py +++ b/homeassistant/components/logbook/const.py @@ -44,6 +44,3 @@ AUTOMATION_EVENTS = {EVENT_AUTOMATION_TRIGGERED, EVENT_SCRIPT_STARTED} # Events that are built-in to the logbook or core BUILT_IN_EVENTS = {EVENT_LOGBOOK_ENTRY, EVENT_CALL_SERVICE} - -LOGBOOK_FILTERS = "logbook_filters" -LOGBOOK_ENTITIES_FILTER = "entities_filter" diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index 221612e1e9..c8f55331de 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -27,7 +27,7 @@ from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_track_state_change_event from .const import ALWAYS_CONTINUOUS_DOMAINS, AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN -from .models import LazyEventPartialState +from .models import LogbookConfig def async_filter_entities(hass: HomeAssistant, entity_ids: list[str]) -> list[str]: @@ -63,9 +63,8 @@ def async_determine_event_types( hass: HomeAssistant, entity_ids: list[str] | None, device_ids: list[str] | None ) -> tuple[str, ...]: """Reduce the event types based on the entity ids and device ids.""" - external_events: dict[ - str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] - ] = hass.data.get(DOMAIN, {}) + logbook_config: LogbookConfig = hass.data[DOMAIN] + external_events = logbook_config.external_events if not entity_ids and not device_ids: return (*BUILT_IN_EVENTS, *external_events) diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 3fc4b5dac8..86dcfdf82c 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -1,15 +1,36 @@ """Event parser and human readable log generator.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -import json from typing import Any, cast from sqlalchemy.engine.row import Row +from homeassistant.components.recorder.filters import Filters +from homeassistant.components.recorder.models import ( + bytes_to_ulid_or_none, + bytes_to_uuid_hex_or_none, + ulid_to_bytes_or_none, + uuid_hex_to_bytes_or_none, +) from homeassistant.const import ATTR_ICON, EVENT_STATE_CHANGED from homeassistant.core import Context, Event, State, callback +from homeassistant.helpers.entityfilter import EntityFilter import homeassistant.util.dt as dt_util +from homeassistant.util.json import json_loads +from homeassistant.util.ulid import ulid_to_bytes + + +@dataclass(slots=True) +class LogbookConfig: + """Configuration for the logbook integration.""" + + external_events: dict[ + str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] + ] + sqlalchemy_filter: Filters | None = None + entity_filter: EntityFilter | None = None class LazyEventPartialState: @@ -22,9 +43,9 @@ class LazyEventPartialState: "event_type", "entity_id", "state", - "context_id", - "context_user_id", - "context_parent_id", + "context_id_bin", + "context_user_id_bin", + "context_parent_id_bin", "data", ] @@ -40,44 +61,58 @@ class LazyEventPartialState: self.event_type: str | None = self.row.event_type self.entity_id: str | None = self.row.entity_id self.state = self.row.state - self.context_id: str | None = self.row.context_id - self.context_user_id: str | None = self.row.context_user_id - self.context_parent_id: str | None = self.row.context_parent_id - if data := getattr(row, "data", None): + 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 + # 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): self.data = event_data else: self.data = self._event_data_cache[source] = cast( - dict[str, Any], json.loads(source) + dict[str, Any], json_loads(source) ) + @property + def context_id(self) -> str | None: + """Return the context id.""" + return bytes_to_ulid_or_none(self.context_id_bin) -@dataclass(frozen=True) + @property + def context_user_id(self) -> str | None: + """Return the context user id.""" + return bytes_to_uuid_hex_or_none(self.context_user_id_bin) + + @property + def context_parent_id(self) -> str | None: + """Return the context parent id.""" + return bytes_to_ulid_or_none(self.context_parent_id_bin) + + +@dataclass(slots=True, frozen=True) class EventAsRow: """Convert an event to a row.""" data: dict[str, Any] context: Context - context_id: str + 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: str | None = None - context_parent_id: 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 @@ -85,29 +120,31 @@ class EventAsRow: def async_event_to_row(event: Event) -> EventAsRow: """Convert an event to a row.""" if event.event_type != EVENT_STATE_CHANGED: + context = event.context return EventAsRow( data=event.data, context=event.context, event_type=event.event_type, - context_id=event.context.id, - context_user_id=event.context.user_id, - context_parent_id=event.context.parent_id, + context_id_bin=ulid_to_bytes(context.id), + 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 # since the logbook does not show these new_state: State = event.data["new_state"] + context = new_state.context return EventAsRow( data=event.data, context=event.context, entity_id=new_state.entity_id, state=new_state.state, - context_id=new_state.context.id, - context_user_id=new_state.context.user_id, - context_parent_id=new_state.context.parent_id, + context_id_bin=ulid_to_bytes(context.id), + 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 289ee677a2..671f8f8f1c 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -5,17 +5,25 @@ 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 from sqlalchemy.engine.row import Row +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, @@ -51,19 +59,20 @@ from .const import ( LOGBOOK_ENTRY_SOURCE, LOGBOOK_ENTRY_STATE, LOGBOOK_ENTRY_WHEN, - LOGBOOK_FILTERS, ) from .helpers import is_sensor_continuous -from .models import EventAsRow, LazyEventPartialState, async_event_to_row +from .models import EventAsRow, LazyEventPartialState, LogbookConfig, async_event_to_row 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]]] ] @@ -71,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: @@ -96,16 +106,14 @@ class EventProcessor: self.entity_ids = entity_ids self.device_ids = device_ids self.context_id = context_id - self.filters: Filters | None = hass.data[LOGBOOK_FILTERS] + logbook_config: LogbookConfig = hass.data[DOMAIN] + self.filters: Filters | None = logbook_config.sqlalchemy_filter format_time = ( _row_time_fired_timestamp if timestamp else _row_time_fired_isoformat ) - external_events: dict[ - str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] - ] = hass.data.get(DOMAIN, {}) self.logbook_run = LogbookRun( - context_lookup=ContextLookup(hass), - external_events=external_events, + context_lookup={None: None}, + external_events=logbook_config.external_events, event_cache=EventCache({}), entity_name_cache=EntityNameCache(self.hass), include_entity_name=include_entity_name, @@ -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,36 +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) - - stmt = statement_for_request( - start_day, - end_day, - self.event_types, - self.entity_ids, - self.device_ids, - self.filters, - self.context_id, - ) - with session_scope(hass=self.hass) as session: - return self.humanify(yield_rows(session.execute(stmt))) + 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: + 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, + event_type_ids, + self.entity_ids, + metadata_ids, + self.device_ids, + self.filters, + self.context_id, + ) + return self.humanify( + execute_stmt_lambda_element(session, stmt, orm_rows=False) + ) def humanify( self, rows: Generator[EventAsRow, None, None] | Sequence[Row] | Result @@ -192,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: @@ -220,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: @@ -250,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[str | None, Row | EventAsRow | None] = {None: None} - - def memorize(self, row: Row | EventAsRow) -> str | None: - """Memorize a context from the database.""" - if self._memorize_new: - context_id: str = row.context_id - self._lookup.setdefault(context_id, row) - return context_id - 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: str) -> Row | EventAsRow | None: - """Get the context origin.""" - return self._lookup.get(context_id) - - class ContextAugmenter: """Augment data with context trace.""" @@ -293,11 +283,13 @@ class ContextAugmenter: self.include_entity_name = logbook_run.include_entity_name def _get_context_row( - self, context_id: str | 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: @@ -305,23 +297,24 @@ class ContextAugmenter: return None def augment( - self, data: dict[str, Any], row: Row | EventAsRow, context_id: str | 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 := row.context_user_id: - data[CONTEXT_USER_ID] = context_user_id + 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): # This is the first event with the given ID. Was it directly caused by # a parent event? + context_parent_id_bin = row.context_parent_id_bin if ( - not row.context_parent_id + not context_parent_id_bin or ( context_row := self._get_context_row( - row.context_parent_id, context_row + context_parent_id_bin, context_row ) ) is None @@ -358,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): @@ -375,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 8a2ee40de4..29d89a4c22 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -1,11 +1,13 @@ """Queries for logbook.""" from __future__ import annotations +from collections.abc import Collection from datetime import datetime as dt from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components.recorder.filters import Filters +from homeassistant.components.recorder.models import ulid_to_bytes_or_none from homeassistant.helpers.json import json_dumps from homeassistant.util import dt as dt_util @@ -18,8 +20,9 @@ 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, filters: Filters | None = None, context_id: str | None = None, @@ -30,15 +33,13 @@ def statement_for_request( # 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_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, - context_id, + event_type_ids, + filters, + context_id_bin, ) # sqlalchemy caches object quoting, the @@ -48,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, - entity_ids, - json_quoted_entity_ids, - json_quoted_device_ids, + event_type_ids, + states_metadata_ids or [], + [json_dumps(entity_id) for entity_id in entity_ids], + [json_dumps(device_id) for device_id in device_ids], ) # 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, - entity_ids, - json_quoted_entity_ids, + event_type_ids, + states_metadata_ids or [], + [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 729a4d2195..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,58 +10,42 @@ from homeassistant.components.recorder.db_schema import ( Events, States, ) +from homeassistant.components.recorder.filters import Filters -from .common import ( - apply_states_filters, - legacy_select_events_context_id, - select_events_without_states, - select_states, -) +from .common import apply_states_filters, select_events_without_states, select_states 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, - context_id: str | 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 is not None: - # Once all the old `state_changed` events - # are gone from the database remove the - # _legacy_select_events_context_id() - stmt += lambda s: s.where(Events.context_id == context_id).union_all( + if context_id_bin is not None: + stmt += lambda s: s.where(Events.context_id_bin == context_id_bin).union_all( _states_query_for_context_id( start_day, end_day, # https://github.com/python/mypy/issues/2608 - context_id, # type:ignore[arg-type] - ), - legacy_select_events_context_id( - start_day, - end_day, - # https://github.com/python/mypy/issues/2608 - context_id, # type:ignore[arg-type] + 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 @@ -76,12 +59,14 @@ def _apply_all_hints(sel: Select) -> Select: """Force mysql to use the right index on large selects.""" return sel.with_hint( States, f"FORCE INDEX ({LAST_UPDATED_INDEX_TS})", dialect_name="mysql" + ).with_hint( + States, f"FORCE INDEX ({LAST_UPDATED_INDEX_TS})", dialect_name="mariadb" ) def _states_query_for_context_id( - start_day: float, end_day: float, context_id: str + start_day: float, end_day: float, context_id_bin: bytes ) -> Select: return apply_states_filters(select_states(), start_day, end_day).where( - States.context_id == context_id + States.context_id_bin == context_id_bin ) diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index ca00f31615..cbbe8724ec 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -10,15 +10,18 @@ from sqlalchemy.sql.expression import literal from sqlalchemy.sql.selectable import Select from homeassistant.components.recorder.db_schema import ( - EVENTS_CONTEXT_ID_INDEX, + EVENTS_CONTEXT_ID_BIN_INDEX, OLD_FORMAT_ATTRS_JSON, OLD_STATE, SHARED_ATTRS_JSON, - STATES_CONTEXT_ID_INDEX, + SHARED_DATA_OR_LEGACY_EVENT_DATA, + STATES_CONTEXT_ID_BIN_INDEX, EventData, Events, + EventTypes, StateAttributes, States, + StatesMeta, ) from homeassistant.components.recorder.filters import like_domain_matchers @@ -34,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 @@ -43,33 +51,29 @@ PSEUDO_EVENT_STATE_CHANGED: Final = None # in the payload EVENT_COLUMNS = ( - Events.event_id.label("event_id"), - Events.event_type.label("event_type"), - Events.event_data.label("event_data"), + Events.event_id.label("row_id"), + EventTypes.event_type.label("event_type"), + SHARED_DATA_OR_LEGACY_EVENT_DATA, Events.time_fired_ts.label("time_fired_ts"), - Events.context_id.label("context_id"), - Events.context_user_id.label("context_user_id"), - Events.context_parent_id.label("context_parent_id"), + Events.context_id_bin.label("context_id_bin"), + Events.context_user_id_bin.label("context_user_id_bin"), + Events.context_parent_id_bin.label("context_parent_id_bin"), ) STATE_COLUMNS = ( - States.state_id.label("state_id"), States.state.label("state"), - States.entity_id.label("entity_id"), - SHARED_ATTRS_JSON["icon"].as_string().label("icon"), - OLD_FORMAT_ATTRS_JSON["icon"].as_string().label("old_format_icon"), + StatesMeta.entity_id.label("entity_id"), + ICON_OR_OLD_FORMAT_ICON_JSON, ) STATE_CONTEXT_ONLY_COLUMNS = ( - States.state_id.label("state_id"), States.state.label("state"), - States.entity_id.label("entity_id"), + 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 @@ -79,24 +83,20 @@ EVENT_COLUMNS_FOR_STATE_SELECT = ( ), literal(value=None, type_=sqlalchemy.Text).label("event_data"), States.last_updated_ts.label("time_fired_ts"), - States.context_id.label("context_id"), - States.context_user_id.label("context_user_id"), - States.context_parent_id.label("context_parent_id"), - literal(value=None, type_=sqlalchemy.Text).label("shared_data"), + 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"), ) 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, ) @@ -109,13 +109,14 @@ 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) + select(Events.context_id_bin) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) - .where(Events.event_type.in_(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)) ) @@ -141,13 +142,14 @@ 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.in_(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)) ) @@ -161,32 +163,6 @@ def select_states() -> Select: ) -def legacy_select_events_context_id( - start_day: float, end_day: float, context_id: str -) -> Select: - """Generate a legacy events context id select that also joins states.""" - # This can be removed once we no longer have event_ids in the states table - return ( - select( - *EVENT_COLUMNS, - literal(value=None, type_=sqlalchemy.String).label("shared_data"), - *STATE_COLUMNS, - NOT_CONTEXT_ONLY, - ) - .outerjoin(States, (Events.event_id == States.event_id)) - .where( - (States.last_updated_ts == States.last_changed_ts) - | States.last_changed_ts.is_(None) - ) - .where(_not_continuous_entity_matcher()) - .outerjoin( - StateAttributes, (States.attributes_id == StateAttributes.attributes_id) - ) - .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) - .where(Events.context_id == context_id) - ) - - def apply_states_filters(sel: Select, start_day: float, end_day: float) -> Select: """Filter states by time range. @@ -208,6 +184,7 @@ def apply_states_filters(sel: Select, start_day: float, end_day: float) -> Selec .outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) + .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) ) @@ -244,7 +221,7 @@ def _not_possible_continuous_domain_matcher() -> ColumnElement[bool]: """ return sqlalchemy.and_( *[ - ~States.entity_id.like(entity_domain) + ~StatesMeta.entity_id.like(entity_domain) for entity_domain in ( *ALWAYS_CONTINUOUS_ENTITY_ID_LIKE, *CONDITIONALLY_CONTINUOUS_ENTITY_ID_LIKE, @@ -261,7 +238,7 @@ def _conditionally_continuous_domain_matcher() -> ColumnElement[bool]: """ return sqlalchemy.or_( *[ - States.entity_id.like(entity_domain) + StatesMeta.entity_id.like(entity_domain) for entity_domain in CONDITIONALLY_CONTINUOUS_ENTITY_ID_LIKE ], ).self_group() @@ -277,12 +254,16 @@ def _not_uom_attributes_matcher() -> BooleanClauseList: def apply_states_context_hints(sel: Select) -> Select: """Force mysql to use the right index on large context_id selects.""" return sel.with_hint( - States, f"FORCE INDEX ({STATES_CONTEXT_ID_INDEX})", dialect_name="mysql" + States, f"FORCE INDEX ({STATES_CONTEXT_ID_BIN_INDEX})", dialect_name="mysql" + ).with_hint( + States, f"FORCE INDEX ({STATES_CONTEXT_ID_BIN_INDEX})", dialect_name="mariadb" ) def apply_events_context_hints(sel: Select) -> Select: """Force mysql to use the right index on large context_id selects.""" return sel.with_hint( - Events, f"FORCE INDEX ({EVENTS_CONTEXT_ID_INDEX})", dialect_name="mysql" + Events, f"FORCE INDEX ({EVENTS_CONTEXT_ID_BIN_INDEX})", dialect_name="mysql" + ).with_hint( + Events, f"FORCE INDEX ({EVENTS_CONTEXT_ID_BIN_INDEX})", dialect_name="mariadb" ) diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index fa2deaf4c0..75604de610 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -13,7 +13,9 @@ from homeassistant.components.recorder.db_schema import ( DEVICE_ID_IN_EVENT, EventData, Events, + EventTypes, States, + StatesMeta, ) from .common import ( @@ -29,40 +31,45 @@ 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).where( - apply_event_device_id_matchers(json_quotable_device_ids) + inner = ( + select_events_context_id_subquery(start_day, end_day, event_type_ids) + .where(apply_event_device_id_matchers(json_quotable_device_ids)) + .subquery() ) - return select(inner.c.context_id).group_by(inner.c.context_id) + return select(inner.c.context_id_bin).group_by(inner.c.context_id_bin) 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( apply_events_context_hints( select_events_context_only() .select_from(devices_cte) - .outerjoin(Events, devices_cte.c.context_id == Events.context_id) - ).outerjoin(EventData, (Events.data_id == EventData.data_id)), + .outerjoin(Events, devices_cte.c.context_id_bin == Events.context_id_bin) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) + .outerjoin(EventData, (Events.data_id == EventData.data_id)), + ), apply_states_context_hints( select_states_context_only() .select_from(devices_cte) - .outerjoin(States, devices_cte.c.context_id == States.context_id) + .outerjoin(States, devices_cte.c.context_id_bin == States.context_id_bin) + .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) ), ) @@ -70,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 3d26443ce9..95c1d56526 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -1,7 +1,7 @@ """Entities queries for logbook.""" from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Collection, Iterable import sqlalchemy from sqlalchemy import lambda_stmt, select, union_all @@ -11,11 +11,13 @@ from sqlalchemy.sql.selectable import CTE, CompoundSelect, Select from homeassistant.components.recorder.db_schema import ( ENTITY_ID_IN_EVENT, - ENTITY_ID_LAST_UPDATED_INDEX_TS, + METADATA_ID_LAST_UPDATED_INDEX_TS, OLD_ENTITY_ID_IN_EVENT, EventData, Events, + EventTypes, States, + StatesMeta, ) from .common import ( @@ -33,56 +35,59 @@ from .common import ( def _select_entities_context_ids_sub_query( start_day: float, end_day: float, - event_types: tuple[str, ...], - entity_ids: list[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)) + apply_entities_hints(select(States.context_id_bin)) .filter( (States.last_updated_ts > start_day) & (States.last_updated_ts < end_day) ) - .where(States.entity_id.in_(entity_ids)), - ) - return select(union.c.context_id).group_by(union.c.context_id) + .where(States.metadata_id.in_(states_metadata_ids)), + ).subquery() + return select(union.c.context_id_bin).group_by(union.c.context_id_bin) def _apply_entities_context_union( sel: Select, start_day: float, end_day: float, - event_types: tuple[str, ...], - entity_ids: list[str], + event_type_ids: tuple[int, ...], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> CompoundSelect: """Generate a CTE to find the entity and device context ids and a query to find linked row.""" entities_cte: CTE = _select_entities_context_ids_sub_query( start_day, end_day, - event_types, - entity_ids, + event_type_ids, + states_metadata_ids, json_quoted_entity_ids, ).cte() # We used to optimize this to exclude rows we already in the union with - # a States.entity_id.not_in(entity_ids) but that made the + # a StatesMeta.metadata_ids.not_in(states_metadata_ids) but that made the # query much slower on MySQL, and since we already filter them away # in the python code anyways since they will have context_only # set on them the impact is minimal. return sel.union_all( - states_select_for_entity_ids(start_day, end_day, entity_ids), + states_select_for_entity_ids(start_day, end_day, states_metadata_ids), apply_events_context_hints( select_events_context_only() .select_from(entities_cte) - .outerjoin(Events, entities_cte.c.context_id == Events.context_id) - ).outerjoin(EventData, (Events.data_id == EventData.data_id)), + .outerjoin(Events, entities_cte.c.context_id_bin == Events.context_id_bin) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) + .outerjoin(EventData, (Events.data_id == EventData.data_id)) + ), apply_states_context_hints( select_states_context_only() .select_from(entities_cte) - .outerjoin(States, entities_cte.c.context_id == States.context_id) + .outerjoin(States, entities_cte.c.context_id_bin == States.context_id_bin) + .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) ), ) @@ -90,32 +95,32 @@ def _apply_entities_context_union( def entities_stmt( start_day: float, end_day: float, - event_types: tuple[str, ...], - entity_ids: list[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, - entity_ids, + event_type_ids, + states_metadata_ids, json_quoted_entity_ids, ).order_by(Events.time_fired_ts) ) def states_select_for_entity_ids( - start_day: float, end_day: float, entity_ids: list[str] + start_day: float, end_day: float, states_metadata_ids: Collection[int] ) -> Select: """Generate a select for states from the States table for specific entities.""" return apply_states_filters( apply_entities_hints(select_states()), start_day, end_day - ).where(States.entity_id.in_(entity_ids)) + ).where(States.metadata_id.in_(states_metadata_ids)) def apply_event_entity_id_matchers( @@ -137,5 +142,11 @@ def apply_event_entity_id_matchers( def apply_entities_hints(sel: Select) -> Select: """Force mysql to use the right index on large selects.""" return sel.with_hint( - States, f"FORCE INDEX ({ENTITY_ID_LAST_UPDATED_INDEX_TS})", dialect_name="mysql" + States, + f"FORCE INDEX ({METADATA_ID_LAST_UPDATED_INDEX_TS})", + dialect_name="mysql", + ).with_hint( + States, + f"FORCE INDEX ({METADATA_ID_LAST_UPDATED_INDEX_TS})", + dialect_name="mariadb", ) diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index 43d11d0bdf..c465a343d6 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -1,14 +1,20 @@ """Entities and Devices queries for logbook.""" from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Collection, Iterable from sqlalchemy import lambda_stmt, select, union_all from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import CTE, CompoundSelect, Select -from homeassistant.components.recorder.db_schema import EventData, Events, States +from homeassistant.components.recorder.db_schema import ( + EventData, + Events, + EventTypes, + States, + StatesMeta, +) from .common import ( apply_events_context_hints, @@ -29,60 +35,67 @@ from .entities import ( def _select_entities_device_id_context_ids_sub_query( start_day: float, end_day: float, - event_types: tuple[str, ...], - entity_ids: list[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 ) ), - apply_entities_hints(select(States.context_id)) + apply_entities_hints(select(States.context_id_bin)) .filter( (States.last_updated_ts > start_day) & (States.last_updated_ts < end_day) ) - .where(States.entity_id.in_(entity_ids)), - ) - return select(union.c.context_id).group_by(union.c.context_id) + .where(States.metadata_id.in_(states_metadata_ids)), + ).subquery() + return select(union.c.context_id_bin).group_by(union.c.context_id_bin) def _apply_entities_devices_context_union( sel: Select, start_day: float, end_day: float, - event_types: tuple[str, ...], - entity_ids: list[str], + event_type_ids: tuple[int, ...], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], ) -> CompoundSelect: devices_entities_cte: CTE = _select_entities_device_id_context_ids_sub_query( start_day, end_day, - event_types, - entity_ids, + event_type_ids, + states_metadata_ids, json_quoted_entity_ids, json_quoted_device_ids, ).cte() # We used to optimize this to exclude rows we already in the union with - # a States.entity_id.not_in(entity_ids) but that made the + # a States.metadata_id.not_in(states_metadata_ids) but that made the # query much slower on MySQL, and since we already filter them away # in the python code anyways since they will have context_only # set on them the impact is minimal. return sel.union_all( - states_select_for_entity_ids(start_day, end_day, entity_ids), + states_select_for_entity_ids(start_day, end_day, states_metadata_ids), apply_events_context_hints( select_events_context_only() .select_from(devices_entities_cte) - .outerjoin(Events, devices_entities_cte.c.context_id == Events.context_id) - ).outerjoin(EventData, (Events.data_id == EventData.data_id)), + .outerjoin( + Events, devices_entities_cte.c.context_id_bin == Events.context_id_bin + ) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) + .outerjoin(EventData, (Events.data_id == EventData.data_id)), + ), apply_states_context_hints( select_states_context_only() .select_from(devices_entities_cte) - .outerjoin(States, devices_entities_cte.c.context_id == States.context_id) + .outerjoin( + States, devices_entities_cte.c.context_id_bin == States.context_id_bin + ) + .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) ), ) @@ -90,23 +103,23 @@ def _apply_entities_devices_context_union( def entities_devices_stmt( start_day: float, end_day: float, - event_types: tuple[str, ...], - entity_ids: list[str], + event_type_ids: tuple[int, ...], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], ) -> StatementLambdaElement: """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, - entity_ids, + event_type_ids, + states_metadata_ids, json_quoted_entity_ids, json_quoted_device_ids, ).order_by(Events.time_fired_ts) diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index dac0da83c3..c4e6b9814f 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -20,13 +20,13 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.json import JSON_DUMP import homeassistant.util.dt as dt_util -from .const import LOGBOOK_ENTITIES_FILTER +from .const import DOMAIN from .helpers import ( async_determine_event_types, async_filter_entities, async_subscribe_events, ) -from .models import async_event_to_row +from .models import LogbookConfig, async_event_to_row from .processor import EventProcessor MAX_PENDING_LOGBOOK_EVENTS = 2048 @@ -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 @@ -361,7 +359,8 @@ async def ws_event_stream( entities_filter: EntityFilter | None = None if not event_processor.limited_select: - entities_filter = hass.data[LOGBOOK_ENTITIES_FILTER] + logbook_config: LogbookConfig = hass.data[DOMAIN] + entities_filter = logbook_config.entity_filter async_subscribe_events( hass, @@ -429,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/__init__.py b/homeassistant/components/logger/__init__.py index 0d087ef23b..fe29447aeb 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -22,6 +22,7 @@ from .const import ( SERVICE_SET_DEFAULT_LEVEL, SERVICE_SET_LEVEL, ) +from .const import EVENT_LOGGING_CHANGED # noqa: F401 from .helpers import ( LoggerDomainConfig, LoggerSettings, diff --git a/homeassistant/components/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/device_tracker.py b/homeassistant/components/luci/device_tracker.py index d18ecf8bd4..f4ebe4376f 100644 --- a/homeassistant/components/luci/device_tracker.py +++ b/homeassistant/components/luci/device_tracker.py @@ -46,7 +46,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> LuciDeviceScanner | class LuciDeviceScanner(DeviceScanner): - """This class scans for devices connected to an OpenWrt router.""" + """Scanner for devices connected to an OpenWrt router.""" def __init__(self, config): """Initialize the scanner.""" 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/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json index ee5760a233..6fa6c55de2 100644 --- a/homeassistant/components/lupusec/manifest.json +++ b/homeassistant/components/lupusec/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/lupusec", "iot_class": "local_polling", "loggers": ["lupupy"], - "requirements": ["lupupy==0.2.8"] + "requirements": ["lupupy==0.3.0"] } 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/lutron_caseta/logbook.py b/homeassistant/components/lutron_caseta/logbook.py index 18c46405ed..ec612ded37 100644 --- a/homeassistant/components/lutron_caseta/logbook.py +++ b/homeassistant/components/lutron_caseta/logbook.py @@ -38,11 +38,15 @@ def async_describe_events( device_type = data[ATTR_TYPE] leap_button_number = data[ATTR_LEAP_BUTTON_NUMBER] dr_device_id = data[ATTR_DEVICE_ID] - lutron_data = get_lutron_data_by_dr_id(hass, dr_device_id) - keypad = lutron_data.keypad_data.dr_device_id_to_keypad.get(dr_device_id) - keypad_id = keypad["lutron_device_id"] + rev_button_map: dict[int, str] | None = None + keypad_button_names_to_leap: dict[int, dict[str, int]] = {} + keypad_id: int = -1 - keypad_button_names_to_leap = lutron_data.keypad_data.button_names_to_leap + if lutron_data := get_lutron_data_by_dr_id(hass, dr_device_id): + keypad_data = lutron_data.keypad_data + keypad = keypad_data.dr_device_id_to_keypad.get(dr_device_id) + keypad_id = keypad["lutron_device_id"] + keypad_button_names_to_leap = keypad_data.button_names_to_leap if not (rev_button_map := LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP.get(device_type)): if fwd_button_map := keypad_button_names_to_leap.get(keypad_id): diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index dd9a89f294..3c9cd6043d 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -17,11 +17,5 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } - }, - "issues": { - "removed_yaml": { - "title": "The Honeywell Lyric YAML configuration has been removed", - "description": "Configuring Honeywell Lyric using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/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 d35f9b73ef..da77aea6c4 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -1,7 +1,6 @@ """Support for manual alarms.""" from __future__ import annotations -import copy import datetime import logging import re @@ -30,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 @@ -74,15 +74,16 @@ ATTR_NEXT_STATE = "next_state" def _state_validator(config): """Validate the state.""" - config = copy.deepcopy(config) for state in SUPPORTED_PRETRIGGER_STATES: if CONF_DELAY_TIME not in config[state]: - config[state][CONF_DELAY_TIME] = config[CONF_DELAY_TIME] + config[state] = config[state] | {CONF_DELAY_TIME: config[CONF_DELAY_TIME]} if CONF_TRIGGER_TIME not in config[state]: - config[state][CONF_TRIGGER_TIME] = config[CONF_TRIGGER_TIME] + config[state] = config[state] | { + CONF_TRIGGER_TIME: config[CONF_TRIGGER_TIME] + } for state in SUPPORTED_ARMING_STATES: if CONF_ARMING_TIME not in config[state]: - config[state][CONF_ARMING_TIME] = config[CONF_ARMING_TIME] + config[state] = config[state] | {CONF_ARMING_TIME: config[CONF_ARMING_TIME]} return config @@ -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 3857dd1954..adb251bd71 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -1,7 +1,6 @@ """Support for manual alarms controllable via MQTT.""" from __future__ import annotations -import copy import datetime import logging import re @@ -30,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 ( @@ -87,15 +87,18 @@ ATTR_POST_PENDING_STATE = "post_pending_state" def _state_validator(config): """Validate the state.""" - config = copy.deepcopy(config) for state in SUPPORTED_PRETRIGGER_STATES: if CONF_DELAY_TIME not in config[state]: - config[state][CONF_DELAY_TIME] = config[CONF_DELAY_TIME] + config[state] = config[state] | {CONF_DELAY_TIME: config[CONF_DELAY_TIME]} if CONF_TRIGGER_TIME not in config[state]: - config[state][CONF_TRIGGER_TIME] = config[CONF_TRIGGER_TIME] + config[state] = config[state] | { + CONF_TRIGGER_TIME: config[CONF_TRIGGER_TIME] + } for state in SUPPORTED_PENDING_STATES: if CONF_PENDING_TIME not in config[state]: - config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] + config[state] = config[state] | { + CONF_PENDING_TIME: config[CONF_PENDING_TIME] + } return config @@ -184,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( @@ -343,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: @@ -434,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/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index 84fafd3f06..febafc367f 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType -from homeassistant.util.json import load_json +from homeassistant.util.json import JsonObjectType, load_json_object from .const import DOMAIN, FORMAT_HTML, FORMAT_TEXT, SERVICE_SEND_MESSAGE @@ -276,15 +276,13 @@ class MatrixBot: except MatrixRequestError as ex: _LOGGER.error("Could not join room %s: %s", room_id, ex) - def _get_auth_tokens(self): + def _get_auth_tokens(self) -> JsonObjectType: """Read sorted authentication tokens from disk. Returns the auth_tokens dictionary. """ try: - auth_tokens = load_json(self._session_filepath) - - return auth_tokens + return load_json_object(self._session_filepath) except HomeAssistantError as ex: _LOGGER.warning( "Loading authentication tokens from file '%s' failed: %s", diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index 6a11e5ded4..4c47cd4d23 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -5,12 +5,8 @@ import asyncio import async_timeout from matter_server.client import MatterClient -from matter_server.client.exceptions import ( - CannotConnect, - FailedCommand, - InvalidServerVersion, -) -from matter_server.common.models.error import MatterError +from matter_server.client.exceptions import CannotConnect, InvalidServerVersion +from matter_server.common.errors import MatterError, NodeCommissionFailed import voluptuous as vol from homeassistant.components.hassio import AddonError, AddonManager, AddonState @@ -31,7 +27,7 @@ from .adapter import MatterAdapter from .addon import get_addon_manager from .api import async_register_api from .const import CONF_INTEGRATION_CREATED_ADDON, CONF_USE_ADDON, DOMAIN, LOGGER -from .device_platform import DEVICE_PLATFORM +from .discovery import SUPPORTED_PLATFORMS from .helpers import MatterEntryData, get_matter, get_node_from_device_entry CONNECT_TIMEOUT = 10 @@ -105,12 +101,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: matter = MatterAdapter(hass, matter_client, entry) hass.data[DOMAIN][entry.entry_id] = MatterEntryData(matter, listen_task) - await hass.config_entries.async_forward_entry_setups(entry, DEVICE_PLATFORM) + await hass.config_entries.async_forward_entry_setups(entry, SUPPORTED_PLATFORMS) await matter.setup_nodes() # If the listen task is already failed, we need to raise ConfigEntryNotReady if listen_task.done() and (listen_error := listen_task.exception()) is not None: - await hass.config_entries.async_unload_platforms(entry, DEVICE_PLATFORM) + await hass.config_entries.async_unload_platforms(entry, SUPPORTED_PLATFORMS) hass.data[DOMAIN].pop(entry.entry_id) try: await matter_client.disconnect() @@ -146,7 +142,9 @@ async def _client_listen( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, DEVICE_PLATFORM) + unload_ok = await hass.config_entries.async_unload_platforms( + entry, SUPPORTED_PLATFORMS + ) if unload_ok: matter_entry_data: MatterEntryData = hass.data[DOMAIN].pop(entry.entry_id) @@ -197,6 +195,17 @@ async def async_remove_config_entry_device( if node is None: return True + if node.is_bridge_device: + device_registry = dr.async_get(hass) + devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + for device in devices: + if device.via_device_id == device_entry.id: + device_registry.async_update_device( + device.id, remove_config_entry_id=config_entry.entry_id + ) + matter = get_matter(hass) await matter.matter_client.remove_node(node.node_id) @@ -211,31 +220,10 @@ def _async_init_services(hass: HomeAssistant) -> None: """Get node id from ha device id.""" dev_reg = dr.async_get(hass) device = dev_reg.async_get(ha_device_id) - if device is None: return None - - matter_id = next( - ( - identifier - for identifier in device.identifiers - if identifier[0] == DOMAIN - ), - None, - ) - - if not matter_id: - return None - - unique_id = matter_id[1] - - matter_client = get_matter(hass).matter_client - - # This could be more efficient - for node in await matter_client.get_nodes(): - if node.unique_id == unique_id: - return node.node_id - + if node := await get_node_from_device_entry(hass, device): + return node.node_id return None async def open_commissioning_window(call: ServiceCall) -> None: @@ -251,7 +239,7 @@ def _async_init_services(hass: HomeAssistant) -> None: try: await matter_client.open_commissioning_window(node_id) - except FailedCommand as err: + except NodeCommissionFailed as err: raise HomeAssistantError(str(err)) from err async_register_admin_service( diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 9f8d1fe6c5..fbc027091b 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -3,13 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, cast -from chip.clusters import Objects as all_clusters -from matter_server.common.models.events import EventType -from matter_server.common.models.node_device import ( - AbstractMatterNodeDevice, - MatterBridgedNodeDevice, -) -from matter_server.common.models.server_information import ServerInfo +from matter_server.common.models import EventType, ServerInfoMessage from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -18,12 +12,12 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, ID_TYPE_DEVICE_ID, ID_TYPE_SERIAL, LOGGER -from .device_platform import DEVICE_PLATFORM +from .discovery import async_discover_entities from .helpers import get_device_id if TYPE_CHECKING: from matter_server.client import MatterClient - from matter_server.common.models.node import MatterNode + from matter_server.client.models.node import MatterEndpoint, MatterNode class MatterAdapter: @@ -52,12 +46,8 @@ class MatterAdapter: for node in await self.matter_client.get_nodes(): self._setup_node(node) - def node_added_callback(event: EventType, node: MatterNode | None) -> None: + def node_added_callback(event: EventType, node: MatterNode) -> None: """Handle node added event.""" - if node is None: - # We can clean this up when we've improved the typing in the library. - # https://github.com/home-assistant-libs/python-matter-server/pull/153 - raise RuntimeError("Node added event without node") self._setup_node(node) self.config_entry.async_on_unload( @@ -68,44 +58,32 @@ class MatterAdapter: """Set up an node.""" LOGGER.debug("Setting up entities for node %s", node.node_id) - bridge_unique_id: str | None = None - - if node.aggregator_device_type_instance is not None and ( - node.root_device_type_instance.get_cluster(all_clusters.BasicInformation) - ): - # create virtual (parent) device for bridge node device - bridge_device = MatterBridgedNodeDevice( - node.aggregator_device_type_instance - ) - self._create_device_registry(bridge_device) - server_info = cast(ServerInfo, self.matter_client.server_info) - bridge_unique_id = get_device_id(server_info, bridge_device) - - for node_device in node.node_devices: - self._setup_node_device(node_device, bridge_unique_id) + for endpoint in node.endpoints.values(): + # Node endpoints are translated into HA devices + self._setup_endpoint(endpoint) def _create_device_registry( self, - node_device: AbstractMatterNodeDevice, - bridge_unique_id: str | None = None, + endpoint: MatterEndpoint, ) -> None: - """Create a device registry entry.""" - server_info = cast(ServerInfo, self.matter_client.server_info) + """Create a device registry entry for a MatterNode.""" + server_info = cast(ServerInfoMessage, self.matter_client.server_info) - basic_info = node_device.device_info() - device_type_instances = node_device.device_type_instances() + basic_info = endpoint.device_info + name = basic_info.nodeLabel or basic_info.productLabel or basic_info.productName - name = basic_info.nodeLabel - if not name and isinstance(node_device, MatterBridgedNodeDevice): - # fallback name for Bridge - name = "Hub device" - elif not name and device_type_instances: - # use the productName if no node label is present - name = basic_info.productName + # handle bridged devices + bridge_device_id = None + if endpoint.is_bridged_device: + bridge_device_id = get_device_id( + server_info, + endpoint.node.endpoints[0], + ) + bridge_device_id = f"{ID_TYPE_DEVICE_ID}_{bridge_device_id}" node_device_id = get_device_id( server_info, - node_device, + endpoint, ) identifiers = {(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")} # if available, we also add the serialnumber as identifier @@ -121,50 +99,21 @@ class MatterAdapter: sw_version=basic_info.softwareVersionString, manufacturer=basic_info.vendorName, model=basic_info.productName, - via_device=(DOMAIN, bridge_unique_id) if bridge_unique_id else None, + via_device=(DOMAIN, bridge_device_id) if bridge_device_id else None, ) - def _setup_node_device( - self, node_device: AbstractMatterNodeDevice, bridge_unique_id: str | None - ) -> None: - """Set up a node device.""" - self._create_device_registry(node_device, bridge_unique_id) + def _setup_endpoint(self, endpoint: MatterEndpoint) -> None: + """Set up a MatterEndpoint as HA Device.""" + # pre-create device registry entry + self._create_device_registry(endpoint) # run platform discovery from device type instances - for instance in node_device.device_type_instances(): - created = False - - for platform, devices in DEVICE_PLATFORM.items(): - entity_descriptions = devices.get(instance.device_type) - - if entity_descriptions is None: - continue - - if not isinstance(entity_descriptions, list): - entity_descriptions = [entity_descriptions] - - entities = [] - for entity_description in entity_descriptions: - LOGGER.debug( - "Creating %s entity for %s (%s)", - platform, - instance.device_type.__name__, - hex(instance.device_type.device_type), - ) - entities.append( - entity_description.entity_cls( - self.matter_client, - node_device, - instance, - entity_description, - ) - ) - - self.platform_handlers[platform](entities) - created = True - - if not created: - LOGGER.warning( - "Found unsupported device %s (%s)", - type(instance).__name__, - hex(instance.device_type.device_type), - ) + for entity_info in async_discover_entities(endpoint): + LOGGER.debug( + "Creating %s entity for %s", + entity_info.platform, + entity_info.primary_attribute, + ) + new_entity = entity_info.entity_class( + self.matter_client, endpoint, entity_info + ) + self.platform_handlers[entity_info.platform]([new_entity]) diff --git a/homeassistant/components/matter/api.py b/homeassistant/components/matter/api.py index b1c2d9effb..7b4b7d35b7 100644 --- a/homeassistant/components/matter/api.py +++ b/homeassistant/components/matter/api.py @@ -5,7 +5,7 @@ from collections.abc import Callable from functools import wraps from typing import Any -from matter_server.client.exceptions import FailedCommand +from matter_server.common.errors import MatterError import voluptuous as vol from homeassistant.components import websocket_api @@ -44,7 +44,7 @@ def async_get_matter_adapter(func: Callable) -> Callable: def async_handle_failed_command(func: Callable) -> Callable: - """Decorate function to handle FailedCommand and send relevant error.""" + """Decorate function to handle MatterError and send relevant error.""" @wraps(func) async def async_handle_failed_command_func( @@ -54,11 +54,11 @@ def async_handle_failed_command(func: Callable) -> Callable: *args: Any, **kwargs: Any, ) -> None: - """Handle FailedCommand within function and send relevant error.""" + """Handle MatterError within function and send relevant error.""" try: await func(hass, connection, msg, *args, **kwargs) - except FailedCommand as err: - connection.send_error(msg[ID], err.error_code, err.args[0]) + except MatterError as err: + connection.send_error(msg[ID], str(err.error_code), err.args[0]) return async_handle_failed_command_func diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 15ad13d25a..a82614cbcc 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -2,10 +2,10 @@ from __future__ import annotations from dataclasses import dataclass -from functools import partial from chip.clusters import Objects as clusters -from matter_server.common.models import device_types +from chip.clusters.Objects import uint +from chip.clusters.Types import Nullable, NullValue from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -17,8 +17,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity, MatterEntityDescriptionBaseClass +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter +from .models import MatterDiscoverySchema async def async_setup_entry( @@ -31,6 +32,13 @@ 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.""" @@ -39,50 +47,68 @@ class MatterBinarySensor(MatterEntity, BinarySensorEntity): @callback def _update_from_device(self) -> None: """Update from device.""" - cluster = self._device_type_instance.get_cluster(clusters.BooleanState) - self._attr_is_on = cluster.stateValue if cluster else None + value: bool | uint | int | Nullable | None + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value in (None, NullValue): + value = None + elif value_convert := self.entity_description.measurement_to_ha: + value = value_convert(value) + self._attr_is_on = value -class MatterOccupancySensor(MatterBinarySensor): - """Representation of a Matter occupancy sensor.""" - - _attr_device_class = BinarySensorDeviceClass.OCCUPANCY - - @callback - def _update_from_device(self) -> None: - """Update from device.""" - cluster = self._device_type_instance.get_cluster(clusters.OccupancySensing) - # The first bit = if occupied - self._attr_is_on = cluster.occupancy & 1 == 1 if cluster else None - - -@dataclass -class MatterBinarySensorEntityDescription( - BinarySensorEntityDescription, - MatterEntityDescriptionBaseClass, -): - """Matter Binary Sensor entity description.""" - - -# You can't set default values on inherited data classes -MatterSensorEntityDescriptionFactory = partial( - MatterBinarySensorEntityDescription, entity_cls=MatterBinarySensor -) - -DEVICE_ENTITY: dict[ - type[device_types.DeviceType], - MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass], -] = { - device_types.ContactSensor: MatterSensorEntityDescriptionFactory( - key=device_types.ContactSensor, - name="Contact", - subscribe_attributes=(clusters.BooleanState.Attributes.StateValue,), - device_class=BinarySensorDeviceClass.DOOR, +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + # device specific: translate Hue motion to sensor to HA Motion sensor + # instead of generic occupancy sensor + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + 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",), ), - device_types.OccupancySensor: MatterSensorEntityDescriptionFactory( - key=device_types.OccupancySensor, - name="Occupancy", - entity_cls=MatterOccupancySensor, - subscribe_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + 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,), ), -} + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + 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,), + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + 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,), + ), +] diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py new file mode 100644 index 0000000000..4e227d83b4 --- /dev/null +++ b/homeassistant/components/matter/cover.py @@ -0,0 +1,157 @@ +"""Matter cover.""" +from __future__ import annotations + +from enum import IntEnum +from typing import Any + +from chip.clusters import Objects as clusters + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityDescription, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import LOGGER +from .entity import MatterEntity +from .helpers import get_matter +from .models import MatterDiscoverySchema + +# The MASK used for extracting bits 0 to 1 of the byte. +OPERATIONAL_STATUS_MASK = 0b11 + +# map Matter window cover types to HA device class +TYPE_MAP = { + clusters.WindowCovering.Enums.Type.kAwning: CoverDeviceClass.AWNING, + clusters.WindowCovering.Enums.Type.kDrapery: CoverDeviceClass.CURTAIN, +} + + +class OperationalStatus(IntEnum): + """Currently ongoing operations enumeration for coverings, as defined in the Matter spec.""" + + COVERING_IS_CURRENTLY_NOT_MOVING = 0b00 + COVERING_IS_CURRENTLY_OPENING = 0b01 + COVERING_IS_CURRENTLY_CLOSING = 0b10 + RESERVED = 0b11 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter Cover from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.COVER, async_add_entities) + + +class MatterCover(MatterEntity, CoverEntity): + """Representation of a Matter Cover.""" + + entity_description: CoverEntityDescription + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + @property + def is_closed(self) -> bool: + """Return true if cover is closed, else False.""" + return self.current_cover_position == 0 + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover movement.""" + await self.send_device_command(clusters.WindowCovering.Commands.StopMotion()) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_device_command(clusters.WindowCovering.Commands.UpOrOpen()) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.send_device_command(clusters.WindowCovering.Commands.DownOrClose()) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Set the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + await self.send_device_command( + # value needs to be inverted and is sent in 100ths + clusters.WindowCovering.Commands.GoToLiftPercentage((100 - position) * 100) + ) + + async def send_device_command(self, command: Any) -> None: + """Send device command.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + operational_status = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.OperationalStatus + ) + + assert operational_status is not None + + LOGGER.debug( + "Operational status %s for %s", + f"{operational_status:#010b}", + self.entity_id, + ) + + state = operational_status & OPERATIONAL_STATUS_MASK + match state: + case OperationalStatus.COVERING_IS_CURRENTLY_OPENING: + self._attr_is_opening = True + self._attr_is_closing = False + case OperationalStatus.COVERING_IS_CURRENTLY_CLOSING: + self._attr_is_opening = False + self._attr_is_closing = True + case _: + self._attr_is_opening = False + self._attr_is_closing = False + + # current position is inverted in matter (100 is closed, 0 is open) + current_cover_position = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage + ) + self._attr_current_cover_position = 100 - current_cover_position + + LOGGER.debug( + "Current position for %s - raw: %s - corrected: %s", + self.entity_id, + current_cover_position, + self.current_cover_position, + ) + + # map matter type to HA deviceclass + device_type: clusters.WindowCovering.Enums.Type = ( + self.get_matter_attribute_value(clusters.WindowCovering.Attributes.Type) + ) + self._attr_device_class = TYPE_MAP.get(device_type, CoverDeviceClass.AWNING) + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.COVER, + entity_description=CoverEntityDescription(key="MatterCover"), + entity_class=MatterCover, + required_attributes=( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, + clusters.WindowCovering.Attributes.OperationalStatus, + ), + ) +] diff --git a/homeassistant/components/matter/device_platform.py b/homeassistant/components/matter/device_platform.py deleted file mode 100644 index 24e7f8b5dc..0000000000 --- a/homeassistant/components/matter/device_platform.py +++ /dev/null @@ -1,30 +0,0 @@ -"""All mappings of Matter devices to Home Assistant platforms.""" -from __future__ import annotations - -from typing import TYPE_CHECKING - -from homeassistant.const import Platform - -from .binary_sensor import DEVICE_ENTITY as BINARY_SENSOR_DEVICE_ENTITY -from .light import DEVICE_ENTITY as LIGHT_DEVICE_ENTITY -from .sensor import DEVICE_ENTITY as SENSOR_DEVICE_ENTITY -from .switch import DEVICE_ENTITY as SWITCH_DEVICE_ENTITY - -if TYPE_CHECKING: - from matter_server.common.models.device_types import DeviceType - - from .entity import MatterEntityDescriptionBaseClass - - -DEVICE_PLATFORM: dict[ - Platform, - dict[ - type[DeviceType], - MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass], - ], -] = { - Platform.BINARY_SENSOR: BINARY_SENSOR_DEVICE_ENTITY, - Platform.LIGHT: LIGHT_DEVICE_ENTITY, - Platform.SENSOR: SENSOR_DEVICE_ENTITY, - Platform.SWITCH: SWITCH_DEVICE_ENTITY, -} diff --git a/homeassistant/components/matter/diagnostics.py b/homeassistant/components/matter/diagnostics.py index 571523f7f0..bcb41cc046 100644 --- a/homeassistant/components/matter/diagnostics.py +++ b/homeassistant/components/matter/diagnostics.py @@ -4,7 +4,8 @@ from __future__ import annotations from copy import deepcopy from typing import Any -from matter_server.common.helpers.util import dataclass_to_dict +from chip.clusters import Objects +from matter_server.common.helpers.util import dataclass_to_dict, parse_attribute_path from homeassistant.components.diagnostics import REDACTED from homeassistant.config_entries import ConfigEntry @@ -13,16 +14,20 @@ from homeassistant.helpers import device_registry as dr from .helpers import get_matter, get_node_from_device_entry -ATTRIBUTES_TO_REDACT = {"chip.clusters.Objects.BasicInformation.Attributes.Location"} +ATTRIBUTES_TO_REDACT = {Objects.BasicInformation.Attributes.Location} def redact_matter_attributes(node_data: dict[str, Any]) -> dict[str, Any]: """Redact Matter cluster attribute.""" redacted = deepcopy(node_data) for attribute_to_redact in ATTRIBUTES_TO_REDACT: - for value in redacted["attributes"].values(): - if value["attribute_type"] == attribute_to_redact: - value["value"] = REDACTED + for attribute_path, _value in redacted["attributes"].items(): + _, cluster_id, attribute_id = parse_attribute_path(attribute_path) + if cluster_id != attribute_to_redact.cluster_id: + continue + if attribute_id != attribute_to_redact.attribute_id: + continue + redacted["attributes"][attribute_path] = REDACTED return redacted @@ -40,7 +45,7 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" matter = get_matter(hass) server_diagnostics = await matter.matter_client.get_diagnostics() - data = remove_serialization_type(dataclass_to_dict(server_diagnostics)) + data = dataclass_to_dict(server_diagnostics) nodes = [redact_matter_attributes(node_data) for node_data in data["nodes"]] data["nodes"] = nodes @@ -56,10 +61,8 @@ async def async_get_device_diagnostics( node = await get_node_from_device_entry(hass, device) return { - "server_info": remove_serialization_type( - dataclass_to_dict(server_diagnostics.info) - ), + "server_info": dataclass_to_dict(server_diagnostics.info), "node": redact_matter_attributes( - remove_serialization_type(dataclass_to_dict(node) if node else {}) + remove_serialization_type(dataclass_to_dict(node.node_data) if node else {}) ), } diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py new file mode 100644 index 0000000000..28f5b6b7f9 --- /dev/null +++ b/homeassistant/components/matter/discovery.py @@ -0,0 +1,118 @@ +"""Map Matter Nodes and Attributes to Home Assistant entities.""" +from __future__ import annotations + +from collections.abc import Generator + +from chip.clusters.Objects import ClusterAttributeDescriptor +from matter_server.client.models.node import MatterEndpoint + +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 +from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS +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) + + +@callback +def iter_schemas() -> Generator[MatterDiscoverySchema, None, None]: + """Iterate over all available discovery schemas.""" + for platform_schemas in DISCOVERY_SCHEMAS.values(): + yield from platform_schemas + + +@callback +def async_discover_entities( + endpoint: MatterEndpoint, +) -> Generator[MatterEntityInfo, None, None]: + """Run discovery on MatterEndpoint and return matching MatterEntityInfo(s).""" + discovered_attributes: set[type[ClusterAttributeDescriptor]] = set() + device_info = endpoint.device_info + for schema in iter_schemas(): + # abort if attribute(s) already discovered + if any(x in schema.required_attributes for x in discovered_attributes): + continue + + # check vendor_id + if ( + schema.vendor_id is not None + and device_info.vendorID not in schema.vendor_id + ): + continue + + # check product_name + if ( + schema.product_name is not None + and device_info.productName not in schema.product_name + ): + continue + + # check required device_type + if schema.device_type is not None and not any( + x in schema.device_type for x in endpoint.device_types + ): + continue + + # check absent device_type + if schema.not_device_type is not None and any( + x in schema.not_device_type for x in endpoint.device_types + ): + continue + + # check endpoint_id + if ( + schema.endpoint_id is not None + and endpoint.endpoint_id not in schema.endpoint_id + ): + continue + + # check required attributes + if schema.required_attributes is not None and not all( + endpoint.has_attribute(None, val_schema) + for val_schema in schema.required_attributes + ): + continue + + # check for values that may not be present + if schema.absent_attributes is not None and any( + endpoint.has_attribute(None, val_schema) + for val_schema in schema.absent_attributes + ): + continue + + # all checks passed, this value belongs to an entity + + attributes_to_watch = list(schema.required_attributes) + if schema.optional_attributes: + # check optional attributes + for optional_attribute in schema.optional_attributes: + if optional_attribute in attributes_to_watch: + continue + if endpoint.has_attribute(None, optional_attribute): + attributes_to_watch.append(optional_attribute) + + yield MatterEntityInfo( + endpoint=endpoint, + platform=schema.platform, + attributes_to_watch=attributes_to_watch, + entity_description=schema.entity_description, + entity_class=schema.entity_class, + ) + + # prevent re-discovery of the same attributes + if not schema.allow_multi: + discovered_attributes.update(attributes_to_watch) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 820d0f7284..bf0a74ef84 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -7,91 +7,93 @@ from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast -from matter_server.common.models.device_type_instance import MatterDeviceTypeInstance -from matter_server.common.models.events import EventType -from matter_server.common.models.node_device import AbstractMatterNodeDevice -from matter_server.common.models.server_information import ServerInfo +from chip.clusters.Objects import ClusterAttributeDescriptor +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, EntityDescription from .const import DOMAIN, ID_TYPE_DEVICE_ID -from .helpers import get_device_id, get_operational_instance_id +from .helpers import get_device_id if TYPE_CHECKING: from matter_server.client import MatterClient - from matter_server.common.models.node import MatterAttribute + from matter_server.client.models.node import MatterEndpoint + + from .discovery import MatterEntityInfo LOGGER = logging.getLogger(__name__) @dataclass -class MatterEntityDescription: - """Mixin to map a matter device to a Home Assistant entity.""" +class MatterEntityDescription(EntityDescription): + """Describe the Matter entity.""" - entity_cls: type[MatterEntity] - subscribe_attributes: tuple - - -@dataclass -class MatterEntityDescriptionBaseClass(EntityDescription, MatterEntityDescription): - """For typing a base class that inherits from both entity descriptions.""" + # 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.""" - entity_description: MatterEntityDescriptionBaseClass _attr_should_poll = False _attr_has_entity_name = True def __init__( self, matter_client: MatterClient, - node_device: AbstractMatterNodeDevice, - device_type_instance: MatterDeviceTypeInstance, - entity_description: MatterEntityDescriptionBaseClass, + endpoint: MatterEndpoint, + entity_info: MatterEntityInfo, ) -> None: """Initialize the entity.""" self.matter_client = matter_client - self._node_device = node_device - self._device_type_instance = device_type_instance - self.entity_description = entity_description + self._endpoint = endpoint + self._entity_info = entity_info + self.entity_description = entity_info.entity_description self._unsubscribes: list[Callable] = [] # for fast lookups we create a mapping to the attribute paths - # The server info is set when the client connects to the server. self._attributes_map: dict[type, str] = {} - server_info = cast(ServerInfo, self.matter_client.server_info) + # The server info is set when the client connects to the server. + server_info = cast(ServerInfoMessage, self.matter_client.server_info) # create unique_id based on "Operational Instance Name" and endpoint/device type + node_device_id = get_device_id(server_info, endpoint) self._attr_unique_id = ( - f"{get_operational_instance_id(server_info, self._node_device.node())}-" - f"{device_type_instance.endpoint}-" - f"{device_type_instance.device_type.device_type}" + f"{node_device_id}-" + f"{endpoint.endpoint_id}-" + f"{entity_info.entity_description.key}-" + f"{entity_info.primary_attribute.cluster_id}-" + f"{entity_info.primary_attribute.attribute_id}" ) - node_device_id = get_device_id(server_info, node_device) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")} ) + self._attr_available = self._endpoint.node.available async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" await super().async_added_to_hass() # Subscribe to attribute updates. - for attr_cls in self.entity_description.subscribe_attributes: - if matter_attr := self.get_matter_attribute(attr_cls): - self._attributes_map[attr_cls] = matter_attr.path - self._unsubscribes.append( - self.matter_client.subscribe( - self._on_matter_event, - EventType.ATTRIBUTE_UPDATED, - self._device_type_instance.node.node_id, - matter_attr.path, - ) + for attr_cls in self._entity_info.attributes_to_watch: + attr_path = self.get_matter_attribute_path(attr_cls) + self._attributes_map[attr_cls] = attr_path + self._unsubscribes.append( + self.matter_client.subscribe( + callback=self._on_matter_event, + event_filter=EventType.ATTRIBUTE_UPDATED, + node_filter=self._endpoint.node.node_id, + attr_path_filter=attr_path, ) - continue - # not sure if this can happen, but just in case log it. - LOGGER.warning("Attribute not found on device: %s", attr_cls) + ) + # subscribe to node (availability changes) + self._unsubscribes.append( + self.matter_client.subscribe( + callback=self._on_matter_event, + event_filter=EventType.NODE_UPDATED, + node_filter=self._endpoint.node.node_id, + ) + ) # make sure to update the attributes once self._update_from_device() @@ -104,6 +106,7 @@ class MatterEntity(Entity): @callback def _on_matter_event(self, event: EventType, data: Any = None) -> None: """Call on update.""" + self._attr_available = self._endpoint.node.available self._update_from_device() self.async_write_ha_state() @@ -113,13 +116,17 @@ class MatterEntity(Entity): """Update data from Matter device.""" @callback - def get_matter_attribute(self, attribute: type) -> MatterAttribute | None: - """Lookup MatterAttribute on device by providing the attribute class.""" - return next( - ( - x - for x in self._device_type_instance.attributes - if x.attribute_type == attribute - ), - None, + def get_matter_attribute_value( + self, attribute: type[ClusterAttributeDescriptor] + ) -> Any: + """Get current value for given attribute.""" + return self._endpoint.get_attribute_value(None, attribute) + + @callback + def get_matter_attribute_path( + self, attribute: type[ClusterAttributeDescriptor] + ) -> str: + """Return AttributePath by providing the endpoint and Attribute class.""" + return create_attribute_path( + self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id ) diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index ef42f9354c..4b60995025 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -11,9 +11,8 @@ from homeassistant.helpers import device_registry as dr from .const import DOMAIN, ID_TYPE_DEVICE_ID if TYPE_CHECKING: - from matter_server.common.models.node import MatterNode - from matter_server.common.models.node_device import AbstractMatterNodeDevice - from matter_server.common.models.server_information import ServerInfo + from matter_server.client.models.node import MatterEndpoint, MatterNode + from matter_server.common.models import ServerInfoMessage from .adapter import MatterAdapter @@ -37,7 +36,7 @@ def get_matter(hass: HomeAssistant) -> MatterAdapter: def get_operational_instance_id( - server_info: ServerInfo, + server_info: ServerInfoMessage, node: MatterNode, ) -> str: """Return `Operational Instance Name` for given MatterNode.""" @@ -49,16 +48,22 @@ def get_operational_instance_id( def get_device_id( - server_info: ServerInfo, - node_device: AbstractMatterNodeDevice, + server_info: ServerInfoMessage, + endpoint: MatterEndpoint, ) -> str: - """Return HA device_id for the given MatterNodeDevice.""" - operational_instance_id = get_operational_instance_id( - server_info, node_device.node() - ) - # Append nodedevice(type) to differentiate between a root node - # and bridge within Home Assistant devices. - return f"{operational_instance_id}-{node_device.__class__.__name__}" + """Return HA device_id for the given MatterEndpoint.""" + operational_instance_id = get_operational_instance_id(server_info, endpoint.node) + # Append endpoint ID if this endpoint is a bridged or composed device + if endpoint.is_composed_device: + compose_parent = endpoint.node.get_compose_parent(endpoint.endpoint_id) + assert compose_parent is not None + postfix = str(compose_parent.endpoint_id) + elif endpoint.is_bridged_device: + postfix = str(endpoint.endpoint_id) + else: + # this should be compatible with previous versions + postfix = "MatterNodeDevice" + return f"{operational_instance_id}-{postfix}" async def get_node_from_device_entry( @@ -91,8 +96,8 @@ async def get_node_from_device_entry( ( node for node in await matter_client.get_nodes() - for node_device in node.node_devices - if get_device_id(server_info, node_device) == device_id + for endpoint in node.endpoints.values() + if get_device_id(server_info, endpoint) == device_id ), None, ) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 5761d57125..10a52eb880 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -1,13 +1,11 @@ """Matter light.""" from __future__ import annotations -from dataclasses import dataclass -from enum import Enum -from functools import partial +from enum import IntFlag from typing import Any from chip.clusters import Objects as clusters -from matter_server.common.models import device_types +from matter_server.client.models import device_types from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -24,8 +22,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import LOGGER -from .entity import MatterEntity, MatterEntityDescriptionBaseClass +from .entity import MatterEntity from .helpers import get_matter +from .models import MatterDiscoverySchema from .util import ( convert_to_hass_hs, convert_to_hass_xy, @@ -34,32 +33,13 @@ from .util import ( renormalize, ) - -class MatterColorMode(Enum): - """Matter color mode.""" - - HS = 0 - XY = 1 - COLOR_TEMP = 2 - - COLOR_MODE_MAP = { - MatterColorMode.HS: ColorMode.HS, - MatterColorMode.XY: ColorMode.XY, - MatterColorMode.COLOR_TEMP: ColorMode.COLOR_TEMP, + clusters.ColorControl.Enums.ColorMode.kCurrentHueAndCurrentSaturation: ColorMode.HS, + clusters.ColorControl.Enums.ColorMode.kCurrentXAndCurrentY: ColorMode.XY, + clusters.ColorControl.Enums.ColorMode.kColorTemperature: ColorMode.COLOR_TEMP, } -class MatterColorControlFeatures(Enum): - """Matter color control features.""" - - HS = 0 # Hue and saturation (Optional if device is color capable) - EHUE = 1 # Enhanced hue and saturation (Optional if device is color capable) - COLOR_LOOP = 2 # Color loop (Optional if device is color capable) - XY = 3 # XY (Mandatory if device is color capable) - COLOR_TEMP = 4 # Color temperature (Mandatory if device is color capable) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -73,65 +53,37 @@ async def async_setup_entry( class MatterLight(MatterEntity, LightEntity): """Representation of a Matter light.""" - entity_description: MatterLightEntityDescription - - def _supports_feature( - self, feature_map: int, feature: MatterColorControlFeatures - ) -> bool: - """Return if device supports given feature.""" - - return (feature_map & (1 << feature.value)) != 0 - - def _supports_color_mode(self, color_feature: MatterColorControlFeatures) -> bool: - """Return if device supports given color mode.""" - - feature_map = self._device_type_instance.node.get_attribute( - self._device_type_instance.endpoint, - clusters.ColorControl, - clusters.ColorControl.Attributes.FeatureMap, - ) - - assert isinstance(feature_map.value, int) - - return self._supports_feature(feature_map.value, color_feature) - - def _supports_hs_color(self) -> bool: - """Return if device supports hs color.""" - - return self._supports_color_mode(MatterColorControlFeatures.HS) - - def _supports_xy_color(self) -> bool: - """Return if device supports xy color.""" - - return self._supports_color_mode(MatterColorControlFeatures.XY) - - def _supports_color_temperature(self) -> bool: - """Return if device supports color temperature.""" - - return self._supports_color_mode(MatterColorControlFeatures.COLOR_TEMP) - - def _supports_brightness(self) -> bool: - """Return if device supports brightness.""" + entity_description: LightEntityDescription + @property + def supports_color(self) -> bool: + """Return if the device supports color control.""" + if not self._attr_supported_color_modes: + return False return ( - clusters.LevelControl.Attributes.CurrentLevel - in self.entity_description.subscribe_attributes + ColorMode.HS in self._attr_supported_color_modes + or ColorMode.XY in self._attr_supported_color_modes ) - def _supports_color(self) -> bool: - """Return if device supports color.""" + @property + def supports_color_temperature(self) -> bool: + """Return if the device supports color temperature control.""" + if not self._attr_supported_color_modes: + return False + return ColorMode.COLOR_TEMP in self._attr_supported_color_modes - return ( - clusters.ColorControl.Attributes.ColorMode - in self.entity_description.subscribe_attributes - ) + @property + def supports_brightness(self) -> bool: + """Return if the device supports bridghtness control.""" + if not self._attr_supported_color_modes: + return False + return ColorMode.BRIGHTNESS in self._attr_supported_color_modes async def _set_xy_color(self, xy_color: tuple[float, float]) -> None: """Set xy color.""" matter_xy = convert_to_matter_xy(xy_color) - LOGGER.debug("Setting xy color to %s", matter_xy) await self.send_device_command( clusters.ColorControl.Commands.MoveToColor( colorX=int(matter_xy[0]), @@ -146,7 +98,6 @@ class MatterLight(MatterEntity, LightEntity): matter_hs = convert_to_matter_hs(hs_color) - LOGGER.debug("Setting hs color to %s", matter_hs) await self.send_device_command( clusters.ColorControl.Commands.MoveToHueAndSaturation( hue=int(matter_hs[0]), @@ -159,7 +110,6 @@ class MatterLight(MatterEntity, LightEntity): async def _set_color_temp(self, color_temp: int) -> None: """Set color temperature.""" - LOGGER.debug("Setting color temperature to %s", color_temp) await self.send_device_command( clusters.ColorControl.Commands.MoveToColorTemperature( colorTemperature=color_temp, @@ -171,12 +121,11 @@ class MatterLight(MatterEntity, LightEntity): async def _set_brightness(self, brightness: int) -> None: """Set brightness.""" - LOGGER.debug("Setting brightness to %s", brightness) - level_control = self._device_type_instance.get_cluster(clusters.LevelControl) + level_control = self._endpoint.get_cluster(clusters.LevelControl) assert level_control is not None - level = round( + level = round( # type: ignore[unreachable] renormalize( brightness, (0, 255), @@ -195,17 +144,21 @@ class MatterLight(MatterEntity, LightEntity): def _get_xy_color(self) -> tuple[float, float]: """Get xy color from matter.""" - x_color = self.get_matter_attribute(clusters.ColorControl.Attributes.CurrentX) - y_color = self.get_matter_attribute(clusters.ColorControl.Attributes.CurrentY) + x_color = self.get_matter_attribute_value( + clusters.ColorControl.Attributes.CurrentX + ) + y_color = self.get_matter_attribute_value( + clusters.ColorControl.Attributes.CurrentY + ) assert x_color is not None assert y_color is not None - xy_color = convert_to_hass_xy((x_color.value, y_color.value)) + xy_color = convert_to_hass_xy((x_color, y_color)) LOGGER.debug( "Got xy color %s for %s", xy_color, - self._device_type_instance, + self.entity_id, ) return xy_color @@ -213,21 +166,23 @@ class MatterLight(MatterEntity, LightEntity): def _get_hs_color(self) -> tuple[float, float]: """Get hs color from matter.""" - hue = self.get_matter_attribute(clusters.ColorControl.Attributes.CurrentHue) + hue = self.get_matter_attribute_value( + clusters.ColorControl.Attributes.CurrentHue + ) - saturation = self.get_matter_attribute( + saturation = self.get_matter_attribute_value( clusters.ColorControl.Attributes.CurrentSaturation ) assert hue is not None assert saturation is not None - hs_color = convert_to_hass_hs((hue.value, saturation.value)) + hs_color = convert_to_hass_hs((hue, saturation)) LOGGER.debug( "Got hs color %s for %s", hs_color, - self._device_type_instance, + self.entity_id, ) return hs_color @@ -235,7 +190,7 @@ class MatterLight(MatterEntity, LightEntity): def _get_color_temperature(self) -> int: """Get color temperature from matter.""" - color_temp = self.get_matter_attribute( + color_temp = self.get_matter_attribute_value( clusters.ColorControl.Attributes.ColorTemperatureMireds ) @@ -243,24 +198,24 @@ class MatterLight(MatterEntity, LightEntity): LOGGER.debug( "Got color temperature %s for %s", - color_temp.value, - self._device_type_instance, + color_temp, + self.entity_id, ) - return int(color_temp.value) + return int(color_temp) def _get_brightness(self) -> int: """Get brightness from matter.""" - level_control = self._device_type_instance.get_cluster(clusters.LevelControl) + level_control = self._endpoint.get_cluster(clusters.LevelControl) # We should not get here if brightness is not supported. assert level_control is not None - LOGGER.debug( + LOGGER.debug( # type: ignore[unreachable] "Got brightness %s for %s", level_control.currentLevel, - self._device_type_instance, + self.entity_id, ) return round( @@ -274,16 +229,18 @@ class MatterLight(MatterEntity, LightEntity): def _get_color_mode(self) -> ColorMode: """Get color mode from matter.""" - color_mode = self.get_matter_attribute( + color_mode = self.get_matter_attribute_value( clusters.ColorControl.Attributes.ColorMode ) assert color_mode is not None - ha_color_mode = COLOR_MODE_MAP[MatterColorMode(color_mode.value)] + ha_color_mode = COLOR_MODE_MAP[color_mode] LOGGER.debug( - "Got color mode (%s) for %s", ha_color_mode, self._device_type_instance + "Got color mode (%s) for %s", + ha_color_mode, + self.entity_id, ) return ha_color_mode @@ -291,8 +248,8 @@ class MatterLight(MatterEntity, LightEntity): async def send_device_command(self, command: Any) -> None: """Send device command.""" await self.matter_client.send_device_command( - node_id=self._device_type_instance.node.node_id, - endpoint=self._device_type_instance.endpoint, + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, command=command, ) @@ -304,15 +261,18 @@ class MatterLight(MatterEntity, LightEntity): color_temp = kwargs.get(ATTR_COLOR_TEMP) brightness = kwargs.get(ATTR_BRIGHTNESS) - if self._supports_color(): - if hs_color is not None and self._supports_hs_color(): + if self.supported_color_modes is not None: + if hs_color is not None and ColorMode.HS in self.supported_color_modes: await self._set_hs_color(hs_color) - elif xy_color is not None and self._supports_xy_color(): + elif xy_color is not None and ColorMode.XY in self.supported_color_modes: await self._set_xy_color(xy_color) - elif color_temp is not None and self._supports_color_temperature(): + elif ( + color_temp is not None + and ColorMode.COLOR_TEMP in self.supported_color_modes + ): await self._set_color_temp(color_temp) - if brightness is not None and self._supports_brightness(): + if brightness is not None and self.supports_brightness: await self._set_brightness(brightness) return @@ -329,106 +289,81 @@ class MatterLight(MatterEntity, LightEntity): @callback def _update_from_device(self) -> None: """Update from device.""" - - supports_color = self._supports_color() - supports_color_temperature = ( - self._supports_color_temperature() if supports_color else False - ) - supports_brightness = self._supports_brightness() - if self._attr_supported_color_modes is None: - supported_color_modes = set() - if supports_color: - supported_color_modes.add(ColorMode.XY) - if self._supports_hs_color(): + # work out what (color)features are supported + supported_color_modes: set[ColorMode] = set() + # brightness support + if self._entity_info.endpoint.has_attribute( + None, clusters.LevelControl.Attributes.CurrentLevel + ): + supported_color_modes.add(ColorMode.BRIGHTNESS) + # colormode(s) + if self._entity_info.endpoint.has_attribute( + None, clusters.ColorControl.Attributes.ColorMode + ): + capabilities = self.get_matter_attribute_value( + clusters.ColorControl.Attributes.ColorCapabilities + ) + + assert capabilities is not None + + if capabilities & ColorCapabilities.kHueSaturationSupported: supported_color_modes.add(ColorMode.HS) - if supports_color_temperature: - supported_color_modes.add(ColorMode.COLOR_TEMP) + if capabilities & ColorCapabilities.kXYAttributesSupported: + supported_color_modes.add(ColorMode.XY) - if supports_brightness: - supported_color_modes.add(ColorMode.BRIGHTNESS) + if capabilities & ColorCapabilities.kColorTemperatureSupported: + supported_color_modes.add(ColorMode.COLOR_TEMP) - self._attr_supported_color_modes = ( - supported_color_modes if supported_color_modes else None + self._attr_supported_color_modes = supported_color_modes + + LOGGER.debug( + "Supported color modes: %s for %s", + self._attr_supported_color_modes, + self.entity_id, ) - LOGGER.debug( - "Supported color modes: %s for %s", - self._attr_supported_color_modes, - self._device_type_instance, - ) + # set current values - if supports_color: + if self.supports_color: self._attr_color_mode = self._get_color_mode() if self._attr_color_mode == ColorMode.HS: self._attr_hs_color = self._get_hs_color() else: self._attr_xy_color = self._get_xy_color() - if supports_color_temperature: + if self.supports_color_temperature: self._attr_color_temp = self._get_color_temperature() - if attr := self.get_matter_attribute(clusters.OnOff.Attributes.OnOff): - self._attr_is_on = attr.value + self._attr_is_on = self.get_matter_attribute_value( + clusters.OnOff.Attributes.OnOff + ) - if supports_brightness: + if self.supports_brightness: self._attr_brightness = self._get_brightness() -@dataclass -class MatterLightEntityDescription( - LightEntityDescription, - MatterEntityDescriptionBaseClass, -): - """Matter light entity description.""" +# This enum should be removed once the ColorControlCapabilities enum is added to the CHIP (Matter) library +# clusters.ColorControl.Bitmap.ColorCapabilities +class ColorCapabilities(IntFlag): + """Color control capabilities bitmap.""" + + kHueSaturationSupported = 0x1 + kEnhancedHueSupported = 0x2 + kColorLoopSupported = 0x4 + kXYAttributesSupported = 0x8 + kColorTemperatureSupported = 0x10 -# You can't set default values on inherited data classes -MatterLightEntityDescriptionFactory = partial( - MatterLightEntityDescription, entity_cls=MatterLight -) - -# Mapping of a Matter Device type to Light Entity Description. -# A Matter device type (instance) can consist of multiple attributes. -# For example a Color Light which has an attribute to control brightness -# but also for color. - -DEVICE_ENTITY: dict[ - type[device_types.DeviceType], - MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass], -] = { - device_types.OnOffLight: MatterLightEntityDescriptionFactory( - key=device_types.OnOffLight, - subscribe_attributes=(clusters.OnOff.Attributes.OnOff,), - ), - device_types.DimmableLight: MatterLightEntityDescriptionFactory( - key=device_types.DimmableLight, - subscribe_attributes=( - clusters.OnOff.Attributes.OnOff, - clusters.LevelControl.Attributes.CurrentLevel, - ), - ), - device_types.DimmablePlugInUnit: MatterLightEntityDescriptionFactory( - key=device_types.DimmablePlugInUnit, - subscribe_attributes=( - clusters.OnOff.Attributes.OnOff, - clusters.LevelControl.Attributes.CurrentLevel, - ), - ), - device_types.ColorTemperatureLight: MatterLightEntityDescriptionFactory( - key=device_types.ColorTemperatureLight, - subscribe_attributes=( - clusters.OnOff.Attributes.OnOff, - clusters.LevelControl.Attributes.CurrentLevel, - clusters.ColorControl.Attributes.ColorMode, - clusters.ColorControl.Attributes.ColorTemperatureMireds, - ), - ), - device_types.ExtendedColorLight: MatterLightEntityDescriptionFactory( - key=device_types.ExtendedColorLight, - subscribe_attributes=( - clusters.OnOff.Attributes.OnOff, +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.LIGHT, + entity_description=LightEntityDescription(key="MatterLight"), + entity_class=MatterLight, + required_attributes=(clusters.OnOff.Attributes.OnOff,), + optional_attributes=( clusters.LevelControl.Attributes.CurrentLevel, clusters.ColorControl.Attributes.ColorMode, clusters.ColorControl.Attributes.CurrentHue, @@ -437,5 +372,10 @@ DEVICE_ENTITY: dict[ clusters.ColorControl.Attributes.CurrentY, clusters.ColorControl.Attributes.ColorTemperatureMireds, ), + # restrict device type to prevent discovery by the wrong platform + not_device_type=( + device_types.OnOffPlugInUnit, + device_types.DoorLock, + ), ), -} +] diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py new file mode 100644 index 0000000000..f90d8eb485 --- /dev/null +++ b/homeassistant/components/matter/lock.py @@ -0,0 +1,141 @@ +"""Matter lock.""" +from __future__ import annotations + +from enum import IntFlag +from typing import Any + +from chip.clusters import Objects as clusters + +from homeassistant.components.lock import LockEntity, LockEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.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 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter lock from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.LOCK, async_add_entities) + + +class MatterLock(MatterEntity, LockEntity): + """Representation of a Matter lock.""" + + features: int | None = None + + @property + def supports_door_position_sensor(self) -> bool: + """Return True if the lock supports door position sensor.""" + if self.features is None: + return False + + return bool(self.features & DoorLockFeature.kDoorPositionSensor) + + async def send_device_command( + self, + command: clusters.ClusterCommand, + timed_request_timeout_ms: int = 1000, + ) -> None: + """Send a command to the device.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + timed_request_timeout_ms=timed_request_timeout_ms, + ) + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the lock with pin if needed.""" + await self.send_device_command(command=clusters.DoorLock.Commands.LockDoor()) + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the lock with pin if needed.""" + await self.send_device_command(command=clusters.DoorLock.Commands.UnlockDoor()) + + @callback + def _update_from_device(self) -> None: + """Update the entity from the device.""" + + if self.features is None: + self.features = int( + self.get_matter_attribute_value(clusters.DoorLock.Attributes.FeatureMap) + ) + + lock_state = self.get_matter_attribute_value( + clusters.DoorLock.Attributes.LockState + ) + + LOGGER.debug("Lock state: %s for %s", lock_state, self.entity_id) + + if lock_state is clusters.DoorLock.Enums.DlLockState.kLocked: + self._attr_is_locked = True + self._attr_is_locking = False + self._attr_is_unlocking = False + elif lock_state is clusters.DoorLock.Enums.DlLockState.kUnlocked: + self._attr_is_locked = False + self._attr_is_locking = False + self._attr_is_unlocking = False + elif lock_state is clusters.DoorLock.Enums.DlLockState.kNotFullyLocked: + if self.is_locked is True: + self._attr_is_unlocking = True + elif self.is_locked is False: + self._attr_is_locking = True + else: + # According to the matter docs a null state can happen during device startup. + self._attr_is_locked = None + self._attr_is_locking = None + self._attr_is_unlocking = None + + if self.supports_door_position_sensor: + door_state = self.get_matter_attribute_value( + clusters.DoorLock.Attributes.DoorState + ) + + assert door_state is not None + + LOGGER.debug("Door state: %s for %s", door_state, self.entity_id) + + self._attr_is_jammed = ( + door_state is clusters.DoorLock.Enums.DlDoorState.kDoorJammed + ) + + +class DoorLockFeature(IntFlag): + """Temp enum that represents the features of a door lock. + + Should be replaced by the library provided one once that is released. + """ + + kPinCredential = 0x1 + kRfidCredential = 0x2 + kFingerCredentials = 0x4 + kLogging = 0x8 + kWeekDayAccessSchedules = 0x10 + kDoorPositionSensor = 0x20 + kFaceCredentials = 0x40 + kCredentialsOverTheAirAccess = 0x80 + kUser = 0x100 + kNotification = 0x200 + kYearDayAccessSchedules = 0x400 + kHolidaySchedules = 0x800 + + +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.LOCK, + entity_description=LockEntityDescription(key="MatterLock"), + entity_class=MatterLock, + required_attributes=(clusters.DoorLock.Attributes.LockState,), + optional_attributes=(clusters.DoorLock.Attributes.DoorState,), + ), +] diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index bf31b7eb68..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==2.1.1"] + "requirements": ["python-matter-server==3.2.0"] } diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py new file mode 100644 index 0000000000..3ac7f66b83 --- /dev/null +++ b/homeassistant/components/matter/models.py @@ -0,0 +1,93 @@ +"""Models used for the Matter integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from chip.clusters import Objects as clusters +from chip.clusters.Objects import ClusterAttributeDescriptor +from matter_server.client.models.device_types import DeviceType +from matter_server.client.models.node import MatterEndpoint + +from homeassistant.const import Platform +from homeassistant.helpers.entity import EntityDescription + +SensorValueTypes = type[ + clusters.uint | int | clusters.Nullable | clusters.float32 | float +] + + +@dataclass +class MatterEntityInfo: + """Info discovered from (primary) Matter Attribute to create entity.""" + + # MatterEndpoint to which the value(s) belongs + endpoint: MatterEndpoint + + # the home assistant platform for which an entity should be created + platform: Platform + + # All attributes that need to be watched by entity (incl. primary) + attributes_to_watch: list[type[ClusterAttributeDescriptor]] + + # the entity description to use + entity_description: EntityDescription + + # entity class to use to instantiate the entity + entity_class: type + + @property + def primary_attribute(self) -> type[ClusterAttributeDescriptor]: + """Return Primary Attribute belonging to the entity.""" + return self.attributes_to_watch[0] + + +@dataclass +class MatterDiscoverySchema: + """Matter discovery schema. + + 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) + platform: Platform + + # platform-specific entity description + entity_description: EntityDescription + + # entity class to use to instantiate the entity + entity_class: type + + # DISCOVERY OPTIONS + + # [required] attributes that ALL need to be present + # on the node for this scheme to pass (minimal one == primary) + required_attributes: tuple[type[ClusterAttributeDescriptor], ...] + + # [optional] the value's endpoint must contain this devicetype(s) + device_type: tuple[type[DeviceType] | DeviceType, ...] | None = None + + # [optional] the value's endpoint must NOT contain this devicetype(s) + not_device_type: tuple[type[DeviceType] | DeviceType, ...] | None = None + + # [optional] the endpoint's vendor_id must match ANY of these values + vendor_id: tuple[int, ...] | None = None + + # [optional] the endpoint's product_name must match ANY of these values + product_name: tuple[str, ...] | None = None + + # [optional] the attribute's endpoint_id must match ANY of these values + endpoint_id: tuple[int, ...] | None = None + + # [optional] additional attributes that MAY NOT be present + # on the node for this scheme to pass + absent_attributes: tuple[type[ClusterAttributeDescriptor], ...] | None = None + + # [optional] additional attributes that may be present + # these attributes are copied over to attributes_to_watch and + # are not discovered by other entities + optional_attributes: tuple[type[ClusterAttributeDescriptor], ...] | None = None + + # [optional] bool to specify if this primary value may be discovered + # by multiple platforms + allow_multi: bool = False diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 38a701e779..84e68695d6 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -1,15 +1,10 @@ """Matter sensors.""" from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass -from functools import partial -from typing import Any from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue -from matter_server.common.models import device_types -from matter_server.common.models.device_type_instance import MatterDeviceTypeInstance from homeassistant.components.sensor import ( SensorDeviceClass, @@ -29,8 +24,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity, MatterEntityDescriptionBaseClass +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter +from .models import MatterDiscoverySchema async def async_setup_entry( @@ -43,6 +39,11 @@ 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.""" @@ -52,113 +53,90 @@ class MatterSensor(MatterEntity, SensorEntity): @callback def _update_from_device(self) -> None: """Update from device.""" - measurement: Nullable | float | None - measurement = _get_attribute_value( - self._device_type_instance, - # We always subscribe to a single value - self.entity_description.subscribe_attributes[0], - ) - - if measurement is NullValue or measurement is None: - measurement = None - else: - measurement = self.entity_description.measurement_to_ha(measurement) - - self._attr_native_value = measurement + value: Nullable | float | None + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value in (None, NullValue): + value = None + elif value_convert := self.entity_description.measurement_to_ha: + value = value_convert(value) + self._attr_native_value = value -def _get_attribute_value( - device_type_instance: MatterDeviceTypeInstance, - attribute: clusters.ClusterAttributeDescriptor, -) -> Any: - """Return the value of an attribute.""" - # Find the cluster for this attribute. We don't have a lookup table yet. - cluster_cls: clusters.Cluster = next( - cluster - for cluster in device_type_instance.device_type.clusters - if cluster.id == attribute.cluster_id - ) - - # Find the attribute descriptor so we know the instance variable to fetch - attribute_descriptor: clusters.ClusterObjectFieldDescriptor = next( - descriptor - for descriptor in cluster_cls.descriptor.Fields - if descriptor.Tag == attribute.attribute_id - ) - - cluster_data = device_type_instance.get_cluster(cluster_cls) - return getattr(cluster_data, attribute_descriptor.Label) - - -@dataclass -class MatterSensorEntityDescriptionMixin: - """Required fields for sensor device mapping.""" - - measurement_to_ha: Callable[[float], float] - - -@dataclass -class MatterSensorEntityDescription( - SensorEntityDescription, - MatterEntityDescriptionBaseClass, - MatterSensorEntityDescriptionMixin, -): - """Matter Sensor entity description.""" - - -# You can't set default values on inherited data classes -MatterSensorEntityDescriptionFactory = partial( - MatterSensorEntityDescription, entity_cls=MatterSensor -) - - -DEVICE_ENTITY: dict[ - type[device_types.DeviceType], - MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass], -] = { - device_types.TemperatureSensor: MatterSensorEntityDescriptionFactory( - key=device_types.TemperatureSensor, - name="Temperature", - measurement_to_ha=lambda x: x / 100, - subscribe_attributes=( - clusters.TemperatureMeasurement.Attributes.MeasuredValue, +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="TemperatureSensor", + name="Temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + measurement_to_ha=lambda x: x / 100, ), - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, + entity_class=MatterSensor, + required_attributes=(clusters.TemperatureMeasurement.Attributes.MeasuredValue,), ), - device_types.PressureSensor: MatterSensorEntityDescriptionFactory( - key=device_types.PressureSensor, - name="Pressure", - measurement_to_ha=lambda x: x / 10, - subscribe_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), - native_unit_of_measurement=UnitOfPressure.KPA, - device_class=SensorDeviceClass.PRESSURE, + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PressureSensor", + name="Pressure", + native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, + measurement_to_ha=lambda x: x / 10, + ), + entity_class=MatterSensor, + required_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), ), - device_types.FlowSensor: MatterSensorEntityDescriptionFactory( - key=device_types.FlowSensor, - name="Flow", - measurement_to_ha=lambda x: x / 10, - subscribe_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), - native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="FlowSensor", + name="Flow", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + device_class=SensorDeviceClass.WATER, # what is the device class here ? + measurement_to_ha=lambda x: x / 10, + ), + entity_class=MatterSensor, + required_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), ), - device_types.HumiditySensor: MatterSensorEntityDescriptionFactory( - key=device_types.HumiditySensor, - name="Humidity", - measurement_to_ha=lambda x: x / 100, - subscribe_attributes=( + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="HumiditySensor", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + measurement_to_ha=lambda x: x / 100, + ), + entity_class=MatterSensor, + required_attributes=( clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue, ), - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.HUMIDITY, ), - device_types.LightSensor: MatterSensorEntityDescriptionFactory( - key=device_types.LightSensor, - name="Light", - measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), - subscribe_attributes=( - clusters.IlluminanceMeasurement.Attributes.MeasuredValue, + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="LightSensor", + name="Illuminance", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), ), - native_unit_of_measurement=LIGHT_LUX, - device_class=SensorDeviceClass.ILLUMINANCE, + entity_class=MatterSensor, + required_attributes=(clusters.IlluminanceMeasurement.Attributes.MeasuredValue,), ), -} + MatterDiscoverySchema( + platform=Platform.SENSOR, + 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,), + ), +] diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index f86a7cbb02..809d0ad738 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -1,12 +1,10 @@ """Matter switches.""" from __future__ import annotations -from dataclasses import dataclass -from functools import partial from typing import Any from chip.clusters import Objects as clusters -from matter_server.common.models import device_types +from matter_server.client.models import device_types from homeassistant.components.switch import ( SwitchDeviceClass, @@ -18,8 +16,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity, MatterEntityDescriptionBaseClass +from .entity import MatterEntity from .helpers import get_matter +from .models import MatterDiscoverySchema async def async_setup_entry( @@ -35,52 +34,44 @@ async def async_setup_entry( class MatterSwitch(MatterEntity, SwitchEntity): """Representation of a Matter switch.""" - entity_description: MatterSwitchEntityDescription - async def async_turn_on(self, **kwargs: Any) -> None: """Turn switch on.""" await self.matter_client.send_device_command( - node_id=self._device_type_instance.node.node_id, - endpoint=self._device_type_instance.endpoint, + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, command=clusters.OnOff.Commands.On(), ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn switch off.""" await self.matter_client.send_device_command( - node_id=self._device_type_instance.node.node_id, - endpoint=self._device_type_instance.endpoint, + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, command=clusters.OnOff.Commands.Off(), ) @callback def _update_from_device(self) -> None: """Update from device.""" - cluster = self._device_type_instance.get_cluster(clusters.OnOff) - self._attr_is_on = cluster.onOff if cluster else None + self._attr_is_on = self.get_matter_attribute_value( + self._entity_info.primary_attribute + ) -@dataclass -class MatterSwitchEntityDescription( - SwitchEntityDescription, - MatterEntityDescriptionBaseClass, -): - """Matter Switch entity description.""" - - -# You can't set default values on inherited data classes -MatterSwitchEntityDescriptionFactory = partial( - MatterSwitchEntityDescription, entity_cls=MatterSwitch -) - - -DEVICE_ENTITY: dict[ - type[device_types.DeviceType], - MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass], -] = { - device_types.OnOffPlugInUnit: MatterSwitchEntityDescriptionFactory( - key=device_types.OnOffPlugInUnit, - subscribe_attributes=(clusters.OnOff.Attributes.OnOff,), - device_class=SwitchDeviceClass.OUTLET, +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=SwitchEntityDescription( + key="MatterPlug", device_class=SwitchDeviceClass.OUTLET + ), + entity_class=MatterSwitch, + required_attributes=(clusters.OnOff.Attributes.OnOff,), + # restrict device type to prevent discovery by the wrong platform + not_device_type=( + device_types.OnOffLight, + device_types.DimmableLight, + device_types.DoorLock, + ), ), -} +] diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index 403627147f..bb92496b74 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -24,8 +24,11 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import aiohttp_client, device_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + device_registry as dr, +) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -48,7 +51,7 @@ PLATFORMS = [ ] -async def with_timeout(task, timeout_seconds=10): +async def with_timeout(task, timeout_seconds=30): """Run an async task with a timeout.""" async with async_timeout.timeout(timeout_seconds): return await task @@ -81,7 +84,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_handle_service_call(service_call: ServiceCall) -> None: """Handle a service call.""" # Get device entry from device registry - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) device_id = service_call.data["device_id"] device_entry = dev_reg.async_get(device_id) if TYPE_CHECKING: @@ -121,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def validate_mazda_device_id(device_id): """Check that a device ID exists in the registry and has at least one 'mazda' identifier.""" - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) if (device_entry := dev_reg.async_get(device_id)) is None: raise vol.Invalid("Invalid device ID") diff --git a/homeassistant/components/mazda/manifest.json b/homeassistant/components/mazda/manifest.json index 64bb8bef0c..2c2aafa960 100644 --- a/homeassistant/components/mazda/manifest.json +++ b/homeassistant/components/mazda/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pymazda"], "quality_scale": "platinum", - "requirements": ["pymazda==0.3.7"] + "requirements": ["pymazda==0.3.8"] } 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_player/device_condition.py b/homeassistant/components/media_player/device_condition.py index 3bf6c5956f..9e3981ed98 100644 --- a/homeassistant/components/media_player/device_condition.py +++ b/homeassistant/components/media_player/device_condition.py @@ -18,7 +18,11 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -45,11 +49,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Media player devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions: list[dict[str, str]] = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/media_player/device_trigger.py b/homeassistant/components/media_player/device_trigger.py index 9b61c89daf..58fc0aca84 100644 --- a/homeassistant/components/media_player/device_trigger.py +++ b/homeassistant/components/media_player/device_trigger.py @@ -23,7 +23,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -52,11 +52,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Media player entities.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = await entity.async_get_triggers(hass, device_id, DOMAIN) # Get all the integration entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index bb6c7d16f5..cee0ee200f 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -19,15 +19,145 @@ "changed_states": "{entity_name} changed states" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]", - "playing": "Playing", - "paused": "[%key:common::state::paused%]", - "idle": "[%key:common::state::idle%]", - "standby": "[%key:common::state::standby%]", - "buffering": "Buffering" + "name": "[%key:component::media_player::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "playing": "Playing", + "paused": "[%key:common::state::paused%]", + "idle": "[%key:common::state::idle%]", + "standby": "[%key:common::state::standby%]", + "buffering": "Buffering" + }, + "state_attributes": { + "app_id": { + "name": "App ID" + }, + "app_name": { + "name": "App" + }, + "entity_picture_local": { + "name": "Local accessible entity picture" + }, + "groups_members": { + "name": "Group members" + }, + "is_volume_muted": { + "name": "Muted", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "media_album_artist": { + "name": "Album artist" + }, + "media_album_name": { + "name": "Album" + }, + "media_artist": { + "name": "Artist" + }, + "media_channel": { + "name": "Channel" + }, + "media_content_id": { + "name": "Content ID" + }, + "media_content_type": { + "name": "Content type", + "state": { + "album": "Album", + "app": "App", + "artist": "Artist", + "channel": "Channel", + "channels": "Channels", + "composer": "Composer", + "contributing_artist": "Contributing artist", + "episode": "Episode", + "game": "Game", + "genre": "Genre", + "image": "Image", + "movie": "Movie", + "music": "Music", + "playlist": "Playlist", + "podcast": "Podcast", + "season": "Season", + "track": "Track", + "tvshow": "TV show", + "url": "URL", + "video": "Video" + } + }, + "media_duration": { + "name": "Duration" + }, + "media_episode": { + "name": "Episode" + }, + "media_playlist": { + "name": "Playlist" + }, + "media_position": { + "name": "Position" + }, + "media_position_updated_at": { + "name": "Position updated" + }, + "media_title": { + "name": "Title" + }, + "media_track": { + "name": "Track" + }, + "media_season": { + "name": "Season" + }, + "media_series_title": { + "name": "Series" + }, + "repeat": { + "name": "Repeat", + "state": { + "all": "All", + "off": "Off", + "one": "One" + } + }, + "shuffle": { + "name": "Shuffle", + "state": { + "true": "[%key:common::state::on%]", + "false": "[%key:common::state::off%]" + } + }, + "source": { + "name": "Source" + }, + "source_list": { + "name": "Available sources" + }, + "sound_mode": { + "name": "Sound mode" + }, + "sound_mode_list": { + "name": "Available sound modes" + }, + "volume_level": { + "name": "Volume" + } + } + }, + "tv": { + "name": "TV" + }, + "speaker": { + "name": "Speaker" + }, + "receiver": { + "name": "Receiver" } } } 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 5c7738f0de..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, @@ -68,6 +65,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][config_entry.entry_id] = coordinator + config_entry.async_on_unload(config_entry.add_update_listener(async_update_entry)) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True @@ -85,6 +84,11 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok +async def async_update_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Reload Met component when options changed.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + class CannotConnect(HomeAssistantError): """Unable to connect to the web site.""" @@ -95,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)) @@ -135,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] = [] @@ -149,7 +148,7 @@ class MetWeatherData: self._coordinates: dict[str, str] | None = None def set_coordinates(self) -> bool: - """Weather data inialization - set the coordinates.""" + """Weather data initialization - set the coordinates.""" if self._config.get(CONF_TRACK_HOME, False): latitude = self.hass.config.latitude longitude = self.hass.config.longitude @@ -159,15 +158,6 @@ class MetWeatherData: 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), "lon": str(longitude), diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index baf7269a81..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, @@ -34,13 +45,58 @@ def configured_instances(hass: HomeAssistant) -> set[str]: return set(entries) -class MetFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): +def _get_data_schema( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry | None = None +) -> vol.Schema: + """Get a schema with default values.""" + # If tracking home or no config entry is passed in, default value come from Home location + if config_entry is None or config_entry.data.get(CONF_TRACK_HOME, False): + return vol.Schema( + { + vol.Required(CONF_NAME, default=HOME_LOCATION_NAME): str, + vol.Required(CONF_LATITUDE, default=hass.config.latitude): cv.latitude, + vol.Required( + CONF_LONGITUDE, default=hass.config.longitude + ): cv.longitude, + 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 + return vol.Schema( + { + vol.Required(CONF_NAME, default=config_entry.data.get(CONF_NAME)): str, + vol.Required( + CONF_LATITUDE, default=config_entry.data.get(CONF_LATITUDE) + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, default=config_entry.data.get(CONF_LONGITUDE) + ): cv.longitude, + vol.Required( + CONF_ELEVATION, default=config_entry.data.get(CONF_ELEVATION) + ): NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + unit_of_measurement=UnitOfLength.METERS, + ) + ), + } + ) + + +class MetConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for Met component.""" VERSION = 1 def __init__(self) -> None: - """Init MetFlowHandler.""" + """Init MetConfigFlowHandler.""" self._errors: dict[str, Any] = {} async def async_step_user( @@ -59,31 +115,9 @@ class MetFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) self._errors[CONF_NAME] = "already_configured" - return await self._show_config_form( - name=HOME_LOCATION_NAME, - latitude=self.hass.config.latitude, - longitude=self.hass.config.longitude, - elevation=self.hass.config.elevation, - ) - - async def _show_config_form( - self, - name: str | None = None, - latitude: float | None = None, - longitude: float | None = None, - elevation: int | None = None, - ) -> FlowResult: - """Show the configuration form to edit location data.""" return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_NAME, default=name): str, - vol.Required(CONF_LATITUDE, default=latitude): cv.latitude, - vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude, - vol.Required(CONF_ELEVATION, default=elevation): int, - } - ), + data_schema=_get_data_schema(self.hass), errors=self._errors, ) @@ -102,3 +136,40 @@ class MetFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=HOME_LOCATION_NAME, data={CONF_TRACK_HOME: True} ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Get the options flow for Met.""" + return MetOptionsFlowHandler(config_entry) + + +class MetOptionsFlowHandler(config_entries.OptionsFlow): + """Options flow for Met component.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize the Met OptionsFlow.""" + self._config_entry = config_entry + self._errors: dict[str, Any] = {} + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Configure options for Met.""" + + if user_input is not None: + # Update config entry with data from user input + self.hass.config_entries.async_update_entry( + self._config_entry, data=user_input + ) + return self.async_create_entry( + title=self._config_entry.title, data=user_input + ) + + return self.async_show_form( + step_id="init", + data_schema=_get_data_schema(self.hass, config_entry=self._config_entry), + errors=self._errors, + ) diff --git a/homeassistant/components/met/strings.json b/homeassistant/components/met/strings.json index b9d251e21d..4fa9c58e4b 100644 --- a/homeassistant/components/met/strings.json +++ b/homeassistant/components/met/strings.json @@ -18,5 +18,18 @@ "abort": { "no_home": "No home coordinates are set in the Home Assistant configuration" } + }, + "options": { + "step": { + "init": { + "title": "[%key:common::config_flow::data::location%]", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]", + "elevation": "[%key:common::config_flow::data::elevation%]" + } + } + } } } 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/manifest.json b/homeassistant/components/meteo_france/manifest.json index 9b5a1e4212..3b6bb9c351 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/meteo_france", "iot_class": "cloud_polling", "loggers": ["meteofrance_api"], - "requirements": ["meteofrance-api==1.1.0"] + "requirements": ["meteofrance-api==1.2.0"] } 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/metoffice/__init__.py b/homeassistant/components/metoffice/__init__.py index 057947d76e..695c6c8f47 100644 --- a/homeassistant/components/metoffice/__init__.py +++ b/homeassistant/components/metoffice/__init__.py @@ -18,8 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import entity_registry -from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -53,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def update_unique_id( - entity_entry: entity_registry.RegistryEntry, + entity_entry: er.RegistryEntry, ) -> dict[str, Any] | None: """Update unique ID of entity entry.""" @@ -86,7 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } return None - await entity_registry.async_migrate_entries(hass, entry.entry_id, update_unique_id) + await er.async_migrate_entries(hass, entry.entry_id, update_unique_id) connection = datapoint.connection(api_key=api_key) @@ -154,7 +153,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def get_device_info(coordinates: str, name: str) -> DeviceInfo: """Return device registry information.""" return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, + entry_type=dr.DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinates)}, manufacturer="Met Office", name=f"Met Office {name}", diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 544dabd018..3bf50525ca 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -20,6 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -207,7 +208,7 @@ class MetOfficeCurrentSensor( ) @property - def native_value(self) -> Any | None: + def native_value(self) -> StateType: """Return the state of the sensor.""" value = None 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/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 71d94a27fe..14fbb83b61 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -10,7 +10,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity import homeassistant.util.dt as dt_util @@ -31,7 +31,7 @@ async def async_setup_entry( tracked: dict[str, MikrotikDataUpdateCoordinatorTracker] = {} - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) # Restore clients that is not a part of active clients list. for entity in registry.entities.values(): 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/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index c7fc375008..90e244aaf0 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine from contextlib import suppress -from functools import wraps +from functools import lru_cache, wraps from http import HTTPStatus import logging import secrets @@ -365,6 +365,12 @@ async def webhook_stream_camera( return webhook_response(resp, registration=config_entry.data) +@lru_cache +def _cached_template(template_str: str, hass: HomeAssistant) -> template.Template: + """Return a cached template.""" + return template.Template(template_str, hass) + + @WEBHOOK_COMMANDS.register("render_template") @validate_schema( { @@ -381,7 +387,7 @@ async def webhook_render_template( resp = {} for key, item in data.items(): try: - tpl = template.Template(item[ATTR_TEMPLATE], hass) + tpl = _cached_template(item[ATTR_TEMPLATE], hass) resp[key] = tpl.async_render(item.get(ATTR_TEMPLATE_VARIABLES)) except TemplateError as ex: resp[key] = {"error": str(ex)} diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 043d7375ae..e8c5346976 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -105,6 +105,7 @@ from .const import ( # noqa: F401 CONF_SWAP_WORD_BYTE, CONF_TARGET_TEMP, CONF_VERIFY, + CONF_WRITE_REGISTERS, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, DEFAULT_HUB, @@ -232,6 +233,7 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int, + vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, vol.Optional(CONF_HVAC_MODE_REGISTER): vol.Maybe( { CONF_ADDRESS: cv.positive_int, @@ -244,6 +246,7 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_HVAC_MODE_DRY): cv.positive_int, vol.Optional(CONF_HVAC_MODE_FAN_ONLY): cv.positive_int, }, + vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, } ), } diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 4f416874f9..f78e3ef9d3 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DEVICE_CLASS_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -73,6 +74,11 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): # this ensures that idx = bit position of value in result # polling is done with the base class name = self._attr_name if self._attr_name else "modbus_sensor" + + # DataUpdateCoordinator does not support DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in _attr_name. + assert name is not DEVICE_CLASS_NAME self._coordinator = DataUpdateCoordinator( hass, _LOGGER, diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 5573ef0b7e..0a8b8dabee 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -45,6 +45,7 @@ from .const import ( CONF_MIN_TEMP, CONF_STEP, CONF_TARGET_TEMP, + CONF_WRITE_REGISTERS, DataType, ) from .modbus import ModbusHub @@ -106,6 +107,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_modes = cast(list[HVACMode], []) self._attr_hvac_mode = None self._hvac_mode_mapping: list[tuple[int, HVACMode]] = [] + self._hvac_mode_write_type = mode_config[CONF_WRITE_REGISTERS] mode_value_config = mode_config[CONF_HVAC_MODE_VALUES] for hvac_mode_kw, hvac_mode in ( @@ -131,6 +133,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): if CONF_HVAC_ONOFF_REGISTER in config: self._hvac_onoff_register = config[CONF_HVAC_ONOFF_REGISTER] + self._hvac_onoff_write_type = config[CONF_WRITE_REGISTERS] if HVACMode.OFF not in self._attr_hvac_modes: self._attr_hvac_modes.append(HVACMode.OFF) else: @@ -147,23 +150,39 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): """Set new target hvac mode.""" if self._hvac_onoff_register is not None: # Turn HVAC Off by writing 0 to the On/Off register, or 1 otherwise. - await self._hub.async_pymodbus_call( - self._slave, - self._hvac_onoff_register, - 0 if hvac_mode == HVACMode.OFF else 1, - CALL_TYPE_WRITE_REGISTER, - ) + if self._hvac_onoff_write_type: + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_onoff_register, + [0 if hvac_mode == HVACMode.OFF else 1], + CALL_TYPE_WRITE_REGISTERS, + ) + else: + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_onoff_register, + 0 if hvac_mode == HVACMode.OFF else 1, + CALL_TYPE_WRITE_REGISTER, + ) if self._hvac_mode_register is not None: # Write a value to the mode register for the desired mode. for value, mode in self._hvac_mode_mapping: if mode == hvac_mode: - await self._hub.async_pymodbus_call( - self._slave, - self._hvac_mode_register, - value, - CALL_TYPE_WRITE_REGISTER, - ) + if self._hvac_mode_write_type: + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_mode_register, + [value], + CALL_TYPE_WRITE_REGISTERS, + ) + else: + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_mode_register, + value, + CALL_TYPE_WRITE_REGISTER, + ) break await self.async_update() diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index b7fcfee905..4191e1df56 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -65,6 +65,7 @@ CONF_HVAC_MODE_HEAT_COOL = "state_heat_cool" CONF_HVAC_MODE_AUTO = "state_auto" CONF_HVAC_MODE_DRY = "state_dry" CONF_HVAC_MODE_FAN_ONLY = "state_fan_only" +CONF_WRITE_REGISTERS = "write_registers" CONF_VERIFY = "verify" CONF_VERIFY_REGISTER = "verify_register" CONF_VERIFY_STATE = "verify_state" diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index fb30d24585..cb3501f337 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -16,7 +16,7 @@ from pymodbus.client import ( from pymodbus.constants import Defaults from pymodbus.exceptions import ModbusException from pymodbus.pdu import ModbusResponse -from pymodbus.transaction import ModbusRtuFramer +from pymodbus.transaction import ModbusAsciiFramer, ModbusRtuFramer, ModbusSocketFramer import voluptuous as vol from homeassistant.const import ( @@ -137,8 +137,10 @@ async def async_modbus_setup( for name in hubs: if not await hubs[name].async_setup(): return False + hub_collect = hass.data[DOMAIN] + else: + hass.data[DOMAIN] = hub_collect = {} - hass.data[DOMAIN] = hub_collect = {} for conf_hub in config[DOMAIN]: my_hub = ModbusHub(hass, conf_hub) hub_collect[conf_hub[CONF_NAME]] = my_hub @@ -279,9 +281,12 @@ class ModbusHub: } if self._config_type == SERIAL: # serial configuration + if client_config[CONF_METHOD] == "ascii": + self._pb_params["framer"] = ModbusAsciiFramer + else: + self._pb_params["framer"] = ModbusRtuFramer self._pb_params.update( { - "method": client_config[CONF_METHOD], "baudrate": client_config[CONF_BAUDRATE], "stopbits": client_config[CONF_STOPBITS], "bytesize": client_config[CONF_BYTESIZE], @@ -293,6 +298,8 @@ class ModbusHub: self._pb_params["host"] = client_config[CONF_HOST] if self._config_type == RTUOVERTCP: self._pb_params["framer"] = ModbusRtuFramer + else: + self._pb_params["framer"] = ModbusSocketFramer Defaults.Timeout = client_config[CONF_TIMEOUT] if CONF_MSG_WAIT in client_config: @@ -310,6 +317,13 @@ class ModbusHub: _LOGGER.error(log_text) self._in_error = error_state + async def async_pymodbus_connect(self) -> None: + """Connect to device, async.""" + async with self._lock: + if not await self.hass.async_add_executor_job(self._pymodbus_connect): + err = f"{self.name} connect failed, retry in pymodbus" + self._log_error(err, error_state=False) + async def async_setup(self) -> bool: """Set up pymodbus client.""" try: @@ -322,11 +336,9 @@ class ModbusHub: func = getattr(self._client, entry.func_name) self._pb_call[entry.call_type] = RunEntry(entry.attr, func) - async with self._lock: - if not await self.hass.async_add_executor_job(self._pymodbus_connect): - err = f"{self.name} connect failed, retry in pymodbus" - self._log_error(err, error_state=False) - return False + self.hass.async_create_background_task( + self.async_pymodbus_connect(), "modbus-connect" + ) # Start counting down to allow modbus requests. if self._config_delay: @@ -378,12 +390,12 @@ class ModbusHub: def _pymodbus_call( self, unit: int | None, address: int, value: int | list[int], use_call: str - ) -> ModbusResponse: + ) -> ModbusResponse | None: """Call sync. pymodbus.""" kwargs = {"slave": unit} if unit else {} entry = self._pb_call[use_call] try: - result = entry.func(address, value, **kwargs) + result: ModbusResponse = entry.func(address, value, **kwargs) except ModbusException as exception_error: self._log_error(str(exception_error)) return None diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index ca8246577f..7c1c3b0a79 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DEVICE_CLASS_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( @@ -79,6 +80,11 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): # this ensures that idx = bit position of value in result # polling is done with the base class name = self._attr_name if self._attr_name else "modbus_sensor" + + # DataUpdateCoordinator does not support DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in _attr_name. + assert name is not DEVICE_CLASS_NAME self._coordinator = DataUpdateCoordinator( hass, _LOGGER, diff --git a/homeassistant/components/modem_callerid/button.py b/homeassistant/components/modem_callerid/button.py index 63a88a8a4e..4b149deece 100644 --- a/homeassistant/components/modem_callerid/button.py +++ b/homeassistant/components/modem_callerid/button.py @@ -7,15 +7,13 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_KEY_API, DOMAIN async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Modem Caller ID sensor.""" api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index 4f84abd453..1cb1043a5e 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -7,15 +7,13 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_IDLE from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CID, DATA_KEY_API, DOMAIN, ICON async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Modem Caller ID sensor.""" api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] 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/moon/config_flow.py b/homeassistant/components/moon/config_flow.py index abdd60c7b6..08b2a4995f 100644 --- a/homeassistant/components/moon/config_flow.py +++ b/homeassistant/components/moon/config_flow.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_NAME from homeassistant.data_entry_flow import FlowResult from .const import DEFAULT_NAME, DOMAIN @@ -23,13 +22,6 @@ class MoonConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="single_instance_allowed") if user_input is not None: - return self.async_create_entry( - title=user_input.get(CONF_NAME, DEFAULT_NAME), - data={}, - ) + return self.async_create_entry(title=DEFAULT_NAME, data={}) return self.async_show_form(step_id="user") - - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle import from configuration.yaml.""" - return await self.async_step_user(user_input) diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index c244f16147..f8e1cd24ab 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -2,25 +2,16 @@ from __future__ import annotations from astral import moon -import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry 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 import homeassistant.util.dt as dt_util -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN STATE_FIRST_QUARTER = "first_quarter" STATE_FULL_MOON = "full_moon" @@ -42,35 +33,6 @@ MOON_ICONS = { STATE_WAXING_GIBBOUS: "mdi:moon-waxing-gibbous", } -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - {vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string} -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Moon sensor.""" - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_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, diff --git a/homeassistant/components/mopeka/manifest.json b/homeassistant/components/mopeka/manifest.json index f4b82be0ac..7110419215 100644 --- a/homeassistant/components/mopeka/manifest.json +++ b/homeassistant/components/mopeka/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/mopeka", "integration_type": "device", "iot_class": "local_push", - "requirements": ["mopeka_iot_ble==0.4.0"] + "requirements": ["mopeka_iot_ble==0.4.1"] } diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index d861c989ee..d93e009136 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from motionblinds import MotionDiscovery +from motionblinds import MotionDiscovery, MotionGateway import voluptuous as vol from homeassistant import config_entries @@ -86,6 +86,16 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(mac_address) self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + gateway = MotionGateway(ip=discovery_info.ip, key="abcd1234-56ef-78") + try: + # key not needed for GetDeviceList request + await self.hass.async_add_executor_job(gateway.GetDeviceList) + except Exception: # pylint: disable=broad-except + return self.async_abort(reason="not_motionblinds") + + if not gateway.available: + return self.async_abort(reason="not_motionblinds") + short_mac = mac_address[-6:].upper() self.context["title_placeholders"] = { "short_mac": short_mac, diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index ec86d34778..0ca982ada8 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.16"] + "requirements": ["motionblinds==0.6.17"] } diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json index 0b1482883a..47c0867187 100644 --- a/homeassistant/components/motion_blinds/strings.json +++ b/homeassistant/components/motion_blinds/strings.json @@ -28,7 +28,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "connection_error": "[%key:common::config_flow::error::cannot_connect%]" + "connection_error": "[%key:common::config_flow::error::cannot_connect%]", + "not_motionblinds": "Discovered device is not a Motion gateway" } }, "options": { 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 fd783e0975..457f905824 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -435,7 +435,7 @@ class MpdDevice(MediaPlayerEntity): self._muted = mute async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the media player the command for playing a playlist.""" if media_source.is_media_source_id(media_id): @@ -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 a1b194284c..d3806044fc 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, @@ -74,14 +62,16 @@ from .const import ( # noqa: F401 CONF_QOS, CONF_STATE_TOPIC, CONF_TLS_INSECURE, - CONF_TLS_VERSION, CONF_TOPIC, CONF_TRANSPORT, CONF_WILL_MESSAGE, CONF_WS_HEADERS, CONF_WS_PATH, DATA_MQTT, + DATA_MQTT_AVAILABLE, + DEFAULT_DISCOVERY, DEFAULT_ENCODING, + DEFAULT_PREFIX, DEFAULT_QOS, DEFAULT_RETAIN, DOMAIN, @@ -92,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, @@ -112,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" @@ -147,23 +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_TLS_VERSION), # Deprecated June 2020 - 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, @@ -187,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. @@ -297,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, {})) + await async_create_certificate_temp_files(hass, conf) + client = MQTT(hass, entry, conf) + if DOMAIN in hass.data: + mqtt_data = get_mqtt_data(hass) + mqtt_data.config = mqtt_yaml + mqtt_data.client = client + else: + # Initial setup + websocket_api.async_register_command(hass, websocket_subscribe) + websocket_api.async_register_command(hass, websocket_mqtt_info) + hass.data[DATA_MQTT] = mqtt_data = MqttData(config=mqtt_yaml, client=client) + client.start(mqtt_data) + + # 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.""" @@ -432,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( @@ -482,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 = [ @@ -495,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( *( [ @@ -536,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 @@ -666,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 @@ -684,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. @@ -706,7 +563,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for component in PLATFORMS ) ) - await hass.async_block_till_done() + await asyncio.sleep(0) # Unsubscribe reload dispatchers while reload_dispatchers := mqtt_data.reload_dispatchers: reload_dispatchers.pop()() @@ -730,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/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 8651311328..b685daaf6f 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -1,4 +1,4 @@ -"""This platform enables the possibility to control a MQTT alarm.""" +"""Control a MQTT alarm.""" from __future__ import annotations import functools diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index dfc88844bd..e8eabe887f 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -3,14 +3,13 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine, Iterable -from functools import lru_cache, partial, wraps -import inspect +from functools import lru_cache from itertools import chain, groupby import logging from operator import attrgetter import ssl import time -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import uuid import async_timeout @@ -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,10 +73,10 @@ from .const import ( from .models import ( AsyncMessageCallbackType, MessageCallbackType, + MqttData, PublishMessage, PublishPayloadType, ReceiveMessage, - ReceivePayloadType, ) from .util import get_file_path, get_mqtt_data, mqtt_config_entry_enabled @@ -84,6 +88,9 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) DISCOVERY_COOLDOWN = 2 +INITIAL_SUBSCRIBE_COOLDOWN = 1.0 +SUBSCRIBE_COOLDOWN = 0.1 +UNSUBSCRIBE_COOLDOWN = 0.1 TIMEOUT_ACK = 10 SubscribePayloadType = str | bytes # Only bytes if encoding is None @@ -110,11 +117,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: @@ -148,55 +155,11 @@ async def async_publish( ) -AsyncDeprecatedMessageCallbackType = Callable[ - [str, ReceivePayloadType, int], Coroutine[Any, Any, None] -] -DeprecatedMessageCallbackType = Callable[[str, ReceivePayloadType, int], None] -DeprecatedMessageCallbackTypes = ( - AsyncDeprecatedMessageCallbackType | DeprecatedMessageCallbackType -) - - -# Support for a deprecated callback type will be removed from HA core 2023.2.0 -def wrap_msg_callback( - msg_callback: DeprecatedMessageCallbackTypes, -) -> AsyncMessageCallbackType | MessageCallbackType: - """Wrap an MQTT message callback to support deprecated signature.""" - # Check for partials to properly determine if coroutine function - check_func = msg_callback - while isinstance(check_func, partial): - check_func = check_func.func # type: ignore[unreachable] - - wrapper_func: AsyncMessageCallbackType | MessageCallbackType - if asyncio.iscoroutinefunction(check_func): - - @wraps(msg_callback) - async def async_wrapper(msg: ReceiveMessage) -> None: - """Call with deprecated signature.""" - await cast(AsyncDeprecatedMessageCallbackType, msg_callback)( - msg.topic, msg.payload, msg.qos - ) - - wrapper_func = async_wrapper - return wrapper_func - - @wraps(msg_callback) - def wrapper(msg: ReceiveMessage) -> None: - """Call with deprecated signature.""" - msg_callback(msg.topic, msg.payload, msg.qos) - - wrapper_func = wrapper - - return wrapper_func - - @bind_hass async def async_subscribe( hass: HomeAssistant, topic: str, - msg_callback: AsyncMessageCallbackType - | MessageCallbackType - | DeprecatedMessageCallbackTypes, + msg_callback: AsyncMessageCallbackType | MessageCallbackType, qos: int = DEFAULT_QOS, encoding: str | None = DEFAULT_ENCODING, ) -> CALLBACK_TYPE: @@ -204,40 +167,15 @@ 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 will be removed from HA core 2023.2.0 - # Count callback parameters which don't have a default value - non_default = 0 - if msg_callback: - non_default = sum( - p.default == inspect.Parameter.empty - for _, p in inspect.signature(msg_callback).parameters.items() - ) - - wrapped_msg_callback = msg_callback - # If we have 3 parameters with no default value, wrap the callback - if non_default == 3: - module = inspect.getmodule(msg_callback) - _LOGGER.warning( - ( - "Signature of MQTT msg_callback '%s.%s' is deprecated, " - "this will stop working with HA core 2023.2" - ), - module.__name__ if module else "", - msg_callback.__name__, - ) - wrapped_msg_callback = wrap_msg_callback( - cast(DeprecatedMessageCallbackTypes, msg_callback) - ) - + mqtt_data = get_mqtt_data(hass) async_remove = await mqtt_data.client.async_subscribe( topic, catch_log_exception( - wrapped_msg_callback, + msg_callback, lambda msg: ( f"Exception in {msg_callback.__name__} when handling msg on " f"'{msg.topic}': '{msg.payload}'" @@ -321,8 +259,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( @@ -346,33 +284,119 @@ def _is_simple_match(topic: str) -> bool: return not ("+" in topic or "#" in topic) +class EnsureJobAfterCooldown: + """Ensure a cool down period before executing a job. + + When a new execute request arrives we cancel the current request + and start a new one. + """ + + def __init__( + self, timeout: float, callback_job: Callable[[], Coroutine[Any, None, None]] + ) -> None: + """Initialize the timer.""" + self._loop = asyncio.get_running_loop() + self._timeout = timeout + self._callback = callback_job + self._task: asyncio.Future | None = None + self._timer: asyncio.TimerHandle | None = None + + def set_timeout(self, timeout: float) -> None: + """Set a new timeout period.""" + self._timeout = timeout + + async def _async_job(self) -> None: + """Execute after a cooldown period.""" + try: + await self._callback() + except HomeAssistantError as ha_error: + _LOGGER.error("%s", ha_error) + + @callback + def _async_task_done(self, task: asyncio.Future) -> None: + """Handle task done.""" + self._task = None + + @callback + def _async_execute(self) -> None: + """Execute the job.""" + if self._task: + # Task already running, + # so we schedule another run + self.async_schedule() + return + + self._async_cancel_timer() + self._task = asyncio.create_task(self._async_job()) + self._task.add_done_callback(self._async_task_done) + + @callback + def _async_cancel_timer(self) -> None: + """Cancel any pending task.""" + if self._timer: + self._timer.cancel() + self._timer = None + + @callback + def async_schedule(self) -> None: + """Ensure we execute after a cooldown period.""" + # We want to reschedule the timer in the future + # every time this is called. + self._async_cancel_timer() + self._timer = self._loop.call_later(self._timeout, self._async_execute) + + async def async_cleanup(self) -> None: + """Cleanup any pending task.""" + self._async_cancel_timer() + if not self._task: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error cleaning up task", exc_info=True) + + class MQTT: """Home Assistant MQTT client.""" _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] = [] + # _retained_topics prevents a Subscription from receiving a + # retained message more than once per topic. This prevents flooding + # already active subscribers when new subscribers subscribe to a topic + # which has subscribed messages. + self._retained_topics: dict[Subscription, set[str]] = {} self.connected = False self._ha_started = asyncio.Event() - self._last_subscribe = time.time() self._cleanup_on_unload: list[Callable[[], None]] = [] self._paho_lock = asyncio.Lock() # Prevents parallel calls to the MQTT client self._pending_operations: dict[int, asyncio.Event] = {} self._pending_operations_condition = asyncio.Condition() + self._subscribe_debouncer = EnsureJobAfterCooldown( + INITIAL_SUBSCRIBE_COOLDOWN, self._async_perform_subscriptions + ) + self._max_qos: dict[str, int] = {} # topic, max qos + self._pending_subscriptions: dict[str, int] = {} # topic, qos + self._unsubscribe_debouncer = EnsureJobAfterCooldown( + UNSUBSCRIBE_COOLDOWN, self._async_perform_unsubscribes + ) + self._pending_unsubscribes: set[str] = set() # topic if self.hass.state == CoreState.running: self._ha_started.set() @@ -384,8 +408,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() @@ -394,6 +416,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.""" @@ -417,15 +447,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, @@ -447,15 +470,15 @@ class MQTT: msg_info = await self.hass.async_add_executor_job( self._mqttc.publish, topic, payload, qos, retain ) - _LOGGER.debug( - "Transmitting%s message on %s: '%s', mid: %s, qos: %s", - " retained" if retain else "", - topic, - payload, - msg_info.mid, - qos, - ) - _raise_on_error(msg_info.rc) + _LOGGER.debug( + "Transmitting%s message on %s: '%s', mid: %s, qos: %s", + " retained" if retain else "", + topic, + payload, + msg_info.mid, + qos, + ) + _raise_on_error(msg_info.rc) await self._wait_for_mid(msg_info.mid) async def async_connect(self) -> None: @@ -468,8 +491,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) @@ -493,6 +516,15 @@ class MQTT: """Return False if there are unprocessed ACKs.""" return not any(not op.is_set() for op in self._pending_operations.values()) + # stop waiting for any pending subscriptions + await self._subscribe_debouncer.async_cleanup() + # reset timeout to initial subscribe cooldown + self._subscribe_debouncer.set_timeout(INITIAL_SUBSCRIBE_COOLDOWN) + # stop the unsubscribe debouncer + await self._unsubscribe_debouncer.async_cleanup() + # make sure the unsubscribes are processed + await self._async_perform_unsubscribes() + # wait for ACKs to be processed async with self._pending_operations_condition: await self._pending_operations_condition.wait_for(no_more_acks) @@ -545,6 +577,23 @@ class MQTT: except (KeyError, ValueError) as ex: raise HomeAssistantError("Can't remove subscription twice") from ex + @callback + def _async_queue_subscriptions( + self, subscriptions: Iterable[tuple[str, int]], queue_only: bool = False + ) -> None: + """Queue requested subscriptions.""" + for subscription in subscriptions: + topic, qos = subscription + max_qos = max(qos, self._max_qos.setdefault(topic, qos)) + self._max_qos[topic] = max_qos + self._pending_subscriptions[topic] = max_qos + # Cancel any pending unsubscribe since we are subscribing now + if topic in self._pending_unsubscribes: + self._pending_unsubscribes.remove(topic) + if queue_only: + return + self._subscribe_debouncer.async_schedule() + async def async_subscribe( self, topic: str, @@ -567,48 +616,42 @@ class MQTT: # Only subscribe if currently connected. if self.connected: - self._last_subscribe = time.time() - await self._async_perform_subscriptions(((topic, qos),)) + self._async_queue_subscriptions(((topic, qos),)) @callback def async_remove() -> None: """Remove subscription.""" self._async_untrack_subscription(subscription) self._matching_subscriptions.cache_clear() - + if subscription in self._retained_topics: + del self._retained_topics[subscription] # Only unsubscribe if currently connected if self.connected: - self.hass.async_create_task(self._async_unsubscribe(topic)) + self._async_unsubscribe(topic) return async_remove - async def _async_unsubscribe(self, topic: str) -> None: - """Unsubscribe from a topic. - - This method is a coroutine. - """ - - def _client_unsubscribe(topic: str) -> int: - result, mid = self._mqttc.unsubscribe(topic) - _LOGGER.debug("Unsubscribing from %s, mid: %s", topic, mid) - _raise_on_error(result) - return mid - - async with self._paho_lock: - if self._is_active_subscription(topic): - # Other subscriptions on topic remaining - don't unsubscribe. + @callback + def _async_unsubscribe(self, topic: str) -> None: + """Unsubscribe from a topic.""" + if self._is_active_subscription(topic): + if self._max_qos[topic] == 0: return + subs = self._matching_subscriptions(topic) + self._max_qos[topic] = max(sub.qos for sub in subs) + # Other subscriptions on topic remaining - don't unsubscribe. + return + if topic in self._max_qos: + del self._max_qos[topic] + if topic in self._pending_subscriptions: + # Avoid any pending subscription to be executed + del self._pending_subscriptions[topic] - mid = await self.hass.async_add_executor_job(_client_unsubscribe, topic) - await self._register_mid(mid) + self._pending_unsubscribes.add(topic) + self._unsubscribe_debouncer.async_schedule() - self.hass.async_create_task(self._wait_for_mid(mid)) - - async def _async_perform_subscriptions( - self, subscriptions: Iterable[tuple[str, int]] - ) -> None: + async def _async_perform_subscriptions(self) -> None: """Perform MQTT client subscriptions.""" - # Section 3.3.1.3 in the specification: # http://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html # When sending a PUBLISH Packet to a Client the Server MUST @@ -621,32 +664,44 @@ class MQTT: # Since we do not know if a published value is retained we need to # (re)subscribe, to ensure retained messages are replayed - def _process_client_subscriptions() -> list[tuple[int, int]]: - """Initiate all subscriptions on the MQTT client and return the results.""" - subscribe_result_list = [] - for topic, qos in subscriptions: - result, mid = self._mqttc.subscribe(topic, qos) - subscribe_result_list.append((result, mid)) - _LOGGER.debug("Subscribing to %s, mid: %s, qos: %s", topic, mid, qos) - return subscribe_result_list + if not self._pending_subscriptions: + return + + subscriptions: dict[str, int] = self._pending_subscriptions + self._pending_subscriptions = {} async with self._paho_lock: - results = await self.hass.async_add_executor_job( - _process_client_subscriptions + subscription_list = list(subscriptions.items()) + result, mid = await self.hass.async_add_executor_job( + self._mqttc.subscribe, subscription_list ) - tasks: list[Coroutine[Any, Any, None]] = [] - errors: list[int] = [] - for result, mid in results: - if result == 0: - tasks.append(self._wait_for_mid(mid)) - else: - errors.append(result) + for topic, qos in subscriptions.items(): + _LOGGER.debug("Subscribing to %s, mid: %s, qos: %s", topic, mid, qos) + self._last_subscribe = time.time() - if tasks: - await asyncio.gather(*tasks) - if errors: - _raise_on_errors(errors) + if result == 0: + await self._wait_for_mid(mid) + else: + _raise_on_error(result) + + async def _async_perform_unsubscribes(self) -> None: + """Perform pending MQTT client unsubscribes.""" + if not self._pending_unsubscribes: + return + + topics = list(self._pending_unsubscribes) + self._pending_unsubscribes = set() + + async with self._paho_lock: + result, mid = await self.hass.async_add_executor_job( + self._mqttc.unsubscribe, topics + ) + _raise_on_error(result) + for topic in topics: + _LOGGER.debug("Unsubscribing from %s, mid: %s", topic, mid) + + await self._wait_for_mid(mid) def _mqtt_on_connect( self, @@ -676,20 +731,19 @@ 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 await self._discovery_cooldown() # Wait for MQTT discovery to cool down + # Update subscribe cooldown period to a shorter time + self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) await self.async_publish( topic=birth_message.topic, payload=birth_message.payload, @@ -697,24 +751,31 @@ class MQTT: retain=birth_message.retain, ) - birth_message = PublishMessage(**self.conf[CONF_BIRTH_MESSAGE]) + birth_message = PublishMessage(**birth) asyncio.run_coroutine_threadsafe( publish_birth_message(birth_message), self.hass.loop ) + else: + # Update subscribe cooldown period to a shorter time + self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) async def _async_resubscribe(self) -> None: """Resubscribe on reconnect.""" + self._max_qos.clear() + self._retained_topics.clear() # Group subscriptions to only re-subscribe once for each topic. keyfunc = attrgetter("topic") - await self._async_perform_subscriptions( + self._async_queue_subscriptions( [ # Re-subscribe with the highest requested qos (topic, max(subscription.qos for subscription in subs)) for topic, subs in groupby( sorted(self.subscriptions, key=keyfunc), keyfunc ) - ] + ], + queue_only=True, ) + await self._async_perform_subscriptions() def _mqtt_on_message( self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage @@ -746,6 +807,14 @@ class MQTT: subscriptions = self._matching_subscriptions(msg.topic) for subscription in subscriptions: + if msg.retain: + retained_topics = self._retained_topics.setdefault(subscription, set()) + # Skip if the subscription already received a retained message + if msg.topic in retained_topics: + continue + # Remember the subscription had an initial retained message + self._retained_topics[subscription].add(msg.topic) + payload: SubscribePayloadType = msg.payload if subscription.encoding is not None: try: @@ -770,7 +839,7 @@ class MQTT: timestamp, ), ) - self._mqtt_data.state_write_requests.process_write_state_requests() + self._mqtt_data.state_write_requests.process_write_state_requests(msg) def _mqtt_on_callback( self, @@ -813,7 +882,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, ) @@ -836,13 +905,14 @@ class MQTT: self._pending_operations_condition.notify_all() async def _discovery_cooldown(self) -> None: + """Wait until all discovery and subscriptions are processed.""" now = time.time() # Reset discovery and subscribe cooldowns self._mqtt_data.last_discovery = now self._last_subscribe = now last_discovery = self._mqtt_data.last_discovery - last_subscribe = self._last_subscribe + last_subscribe = now if self._pending_subscriptions else self._last_subscribe wait_until = max( last_discovery + DISCOVERY_COOLDOWN, last_subscribe + DISCOVERY_COOLDOWN ) @@ -850,28 +920,21 @@ class MQTT: await asyncio.sleep(wait_until - now) now = time.time() last_discovery = self._mqtt_data.last_discovery - last_subscribe = self._last_subscribe + last_subscribe = ( + now if self._pending_subscriptions else self._last_subscribe + ) wait_until = max( last_discovery + DISCOVERY_COOLDOWN, last_subscribe + DISCOVERY_COOLDOWN ) -def _raise_on_errors(result_codes: Iterable[int]) -> None: +def _raise_on_error(result_code: int) -> None: """Raise error if error result.""" # pylint: disable-next=import-outside-toplevel import paho.mqtt.client as mqtt - if messages := [ - mqtt.error_string(result_code) - for result_code in result_codes - if result_code != 0 - ]: - raise HomeAssistantError(f"Error talking to MQTT: {', '.join(messages)}") - - -def _raise_on_error(result_code: int) -> None: - """Raise error if error result.""" - _raise_on_errors((result_code,)) + if result_code and (message := mqtt.error_string(result_code)): + raise HomeAssistantError(f"Error talking to MQTT: {message}") def _matcher_for_topic(subscription: str) -> Any: 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 bbd6861435..469f52e148 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -45,39 +45,11 @@ from .const import ( CONF_DISCOVERY_PREFIX, CONF_KEEPALIVE, CONF_TLS_INSECURE, - CONF_TLS_VERSION, - 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_TLS_VERSION: DEFAULT_TLS_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( @@ -168,63 +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_TLS_VERSION): vol.Any("auto", "1.0", "1.1", "1.2"), - 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_TLS_VERSION): vol.Any("auto", "1.0", "1.1", "1.2"), - 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, @@ -236,7 +151,6 @@ DEPRECATED_CONFIG_KEYS = [ CONF_PORT, CONF_PROTOCOL, CONF_TLS_INSECURE, - CONF_TLS_VERSION, CONF_USERNAME, CONF_WILL_MESSAGE, ] diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index f7e2cbe5b1..c91c54a79a 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -33,9 +33,9 @@ CONF_CERTIFICATE = "certificate" CONF_CLIENT_KEY = "client_key" CONF_CLIENT_CERT = "client_cert" CONF_TLS_INSECURE = "tls_insecure" -CONF_TLS_VERSION = "tls_version" DATA_MQTT = "mqtt" +DATA_MQTT_AVAILABLE = "mqtt_client_available" DEFAULT_PREFIX = "homeassistant" DEFAULT_BIRTH_WILL_TOPIC = DEFAULT_PREFIX + "/status" @@ -47,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" @@ -114,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 cf565b4239..342e7d121f 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -99,7 +99,7 @@ async def async_start( # noqa: C901 mqtt_integrations = {} @callback - def async_discovery_message_received(msg: ReceiveMessage) -> None: + def async_discovery_message_received(msg: ReceiveMessage) -> None: # noqa: C901 """Process the received message.""" mqtt_data.last_discovery = time.time() payload = msg.payload @@ -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 153726a89e..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) @@ -495,8 +499,12 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): self._attr_color_mode = color_mode if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is None: rgb = convert_color(*color) - percent_bright = float(color_util.color_RGB_to_hsv(*rgb)[2]) / 100.0 - self._attr_brightness = min(round(percent_bright * 255), 255) + brightness = max(rgb) + self._attr_brightness = brightness + # Normalize the color to 100% brightness + color = tuple( + min(round(channel / brightness * 255), 255) for channel in color + ) return color @callback diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 55b2f99d53..c40dae659b 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -260,7 +260,9 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): pass except ValueError: _LOGGER.warning( - "Invalid RGB color value received for entity %s", self.entity_id + "Invalid RGB color value '%s' received for entity %s", + values, + self.entity_id, ) return @@ -272,7 +274,9 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): pass except ValueError: _LOGGER.warning( - "Invalid XY color value received for entity %s", self.entity_id + "Invalid XY color value '%s' received for entity %s", + values, + self.entity_id, ) return @@ -284,14 +288,18 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): pass except ValueError: _LOGGER.warning( - "Invalid HS color value received for entity %s", self.entity_id + "Invalid HS color value '%s' received for entity %s", + values, + self.entity_id, ) return else: color_mode: str = values["color_mode"] if not self._supports_color_mode(color_mode): _LOGGER.warning( - "Invalid color mode received for entity %s", self.entity_id + "Invalid color mode '%s' received for entity %s", + color_mode, + self.entity_id, ) return try: @@ -333,7 +341,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_xy_color = (x, y) except (KeyError, ValueError): _LOGGER.warning( - "Invalid or incomplete color value received for entity %s", + "Invalid or incomplete color value '%s' received for entity %s", + values, self.entity_id, ) @@ -369,16 +378,24 @@ 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): _LOGGER.warning( - "Invalid brightness value received for entity %s", + "Invalid brightness value '%s' received for entity %s", + values["brightness"], self.entity_id, ) @@ -397,7 +414,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): pass except ValueError: _LOGGER.warning( - "Invalid color temp value received for entity %s", + "Invalid color temp value '%s' received for entity %s", + values["color_temp"], self.entity_id, ) 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 a88fb97b83..eac333e2a7 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -21,6 +21,8 @@ from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, TemplateVarsType if TYPE_CHECKING: + from paho.mqtt.client import MQTTMessage + from .client import MQTT, Subscription from .debug_info import TimestampedPublishMessage from .device_trigger import Trigger @@ -260,11 +262,21 @@ class EntityTopicState: self.subscribe_calls: dict[str, Entity] = {} @callback - def process_write_state_requests(self) -> None: + def process_write_state_requests(self, msg: MQTTMessage) -> None: """Process the write state requests.""" while self.subscribe_calls: _, entity = self.subscribe_calls.popitem() - entity.async_write_ha_state() + try: + entity.async_write_ha_state() + except Exception: # pylint: disable=broad-except + _LOGGER.error( + "Exception raised when updating state of %s, topic: " + "'%s' with payload: %s", + entity.entity_id, + msg.topic, + msg.payload, + exc_info=True, + ) @callback def write_state_request(self, entity: Entity) -> None: @@ -276,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 df51dd60a1..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) @@ -281,10 +281,13 @@ class MqttSensor(MqttEntity, RestoreSensor): else: self._attr_native_value = new_value return - if self.device_class is None: + if self.device_class in {None, SensorDeviceClass.ENUM}: self._attr_native_value = new_value return - if (payload_datetime := dt_util.parse_datetime(new_value)) is None: + try: + if (payload_datetime := dt_util.parse_datetime(new_value)) is None: + raise ValueError + except ValueError: _LOGGER.warning( "Invalid state message '%s' from '%s'", msg.payload, msg.topic ) 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_eventstream/__init__.py b/homeassistant/components/mqtt_eventstream/__init__.py index 62a10c1bd0..af370fe82f 100644 --- a/homeassistant/components/mqtt_eventstream/__init__.py +++ b/homeassistant/components/mqtt_eventstream/__init__.py @@ -1,5 +1,6 @@ """Connect two Home Assistant instances via MQTT.""" import json +import logging import voluptuous as vol @@ -21,6 +22,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import ConfigType +_LOGGER = logging.getLogger(__name__) + DOMAIN = "mqtt_eventstream" CONF_PUBLISH_TOPIC = "publish_topic" CONF_SUBSCRIBE_TOPIC = "subscribe_topic" @@ -54,6 +57,11 @@ BLOCKED_EVENTS = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MQTT eventstream component.""" + # Make sure MQTT integration is enabled and the client is available + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return False + conf = config.get(DOMAIN, {}) pub_topic = conf.get(CONF_PUBLISH_TOPIC) sub_topic = conf.get(CONF_SUBSCRIBE_TOPIC) 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 5213f67546..3283682587 100644 --- a/homeassistant/components/mqtt_statestream/__init__.py +++ b/homeassistant/components/mqtt_statestream/__init__.py @@ -1,19 +1,20 @@ """Publish simple item state changes via MQTT.""" import json +import logging import voluptuous as vol from homeassistant.components import mqtt from homeassistant.components.mqtt import valid_publish_topic -from homeassistant.const import MATCH_ALL -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED +from homeassistant.core import Event, HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, convert_include_exclude_filter, ) -from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.start import async_at_start from homeassistant.helpers.typing import ConfigType CONF_BASE_TOPIC = "base_topic" @@ -35,23 +36,27 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MQTT state feed.""" - conf = config[DOMAIN] + # Make sure MQTT integration is enabled and the client is available + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return False + + conf: ConfigType = config[DOMAIN] publish_filter = convert_include_exclude_filter(conf) - base_topic = conf.get(CONF_BASE_TOPIC) - publish_attributes = conf.get(CONF_PUBLISH_ATTRIBUTES) - publish_timestamps = conf.get(CONF_PUBLISH_TIMESTAMPS) + base_topic: str = conf[CONF_BASE_TOPIC] + publish_attributes: bool = conf[CONF_PUBLISH_ATTRIBUTES] + publish_timestamps: bool = conf[CONF_PUBLISH_TIMESTAMPS] if not base_topic.endswith("/"): base_topic = f"{base_topic}/" - async def _state_publisher(entity_id, old_state, new_state): - if new_state is None: - return - - if not publish_filter(entity_id): - return + async def _state_publisher(evt: Event) -> None: + entity_id: str = evt.data["entity_id"] + new_state: State = evt.data["new_state"] payload = new_state.state @@ -81,5 +86,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: encoded_val = json.dumps(val, cls=JSONEncoder) await mqtt.async_publish(hass, mybase + key, encoded_val, 1, True) - async_track_state_change(hass, MATCH_ALL, _state_publisher) + @callback + def _ha_started(hass: HomeAssistant) -> None: + @callback + def _event_filter(evt: Event) -> bool: + entity_id: str = evt.data["entity_id"] + new_state: State | None = evt.data["new_state"] + if new_state is None: + return False + if not publish_filter(entity_id): + return False + return True + + callback_handler = hass.bus.async_listen( + EVENT_STATE_CHANGED, _state_publisher, _event_filter + ) + + @callback + def _ha_stopping(_: Event) -> None: + callback_handler() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _ha_stopping) + + async_at_start(hass, _ha_started) + return True diff --git a/homeassistant/components/mycroft/manifest.json b/homeassistant/components/mycroft/manifest.json index 86adf0d48b..9b8731f070 100644 --- a/homeassistant/components/mycroft/manifest.json +++ b/homeassistant/components/mycroft/manifest.json @@ -2,6 +2,7 @@ "domain": "mycroft", "name": "Mycroft", "codeowners": [], + "disabled": "Dependencies not compatible with the new pip resolver", "documentation": "https://www.home-assistant.io/integrations/mycroft", "iot_class": "local_push", "loggers": ["mycroftapi"], diff --git a/homeassistant/components/mycroft/notify.py b/homeassistant/components/mycroft/notify.py index a9dd82caef..172a01017c 100644 --- a/homeassistant/components/mycroft/notify.py +++ b/homeassistant/components/mycroft/notify.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from mycroftapi import MycroftAPI +from mycroftapi import MycroftAPI # pylint: disable=import-error from homeassistant.components.notify import BaseNotificationService from homeassistant.core import HomeAssistant 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/__init__.py b/homeassistant/components/nam/__init__.py index c035184c37..7327601725 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -9,9 +9,9 @@ from aiohttp.client_exceptions import ClientConnectorError, ClientError import async_timeout from nettigo_air_monitor import ( ApiError, - AuthFailed, + AuthFailedError, ConnectionOptions, - InvalidSensorData, + InvalidSensorDataError, NAMSensors, NettigoAirMonitor, ) @@ -21,9 +21,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -58,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await nam.async_check_credentials() except ApiError as err: raise ConfigEntryNotReady from err - except AuthFailed as err: + except AuthFailedError as err: raise ConfigEntryAuthFailed from err coordinator = NAMDataUpdateCoordinator(hass, nam, entry.unique_id) @@ -70,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Remove air_quality entities from registry if they exist - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) for sensor_type in ("sds", ATTR_SDS011, ATTR_SPS30): unique_id = f"{coordinator.unique_id}-{sensor_type}" if entity_id := ent_reg.async_get_entity_id( @@ -116,7 +115,7 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): data = await self.nam.async_update() # We do not need to catch AuthFailed exception here because sensor data is # always available without authorization. - except (ApiError, ClientConnectorError, InvalidSensorData) as error: + except (ApiError, ClientConnectorError, InvalidSensorDataError) as error: raise UpdateFailed(error) from error return data @@ -130,7 +129,7 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, cast(str, self._unique_id))}, + connections={(dr.CONNECTION_NETWORK_MAC, cast(str, self._unique_id))}, name="Nettigo Air Monitor", sw_version=self.nam.software_version, manufacturer=MANUFACTURER, 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/config_flow.py b/homeassistant/components/nam/config_flow.py index 6cf9dd5545..eef4c33e5f 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -11,8 +11,8 @@ from aiohttp.client_exceptions import ClientConnectorError import async_timeout from nettigo_air_monitor import ( ApiError, - AuthFailed, - CannotGetMac, + AuthFailedError, + CannotGetMacError, ConnectionOptions, NettigoAirMonitor, ) @@ -95,7 +95,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): config = await async_get_config(self.hass, self.host) except (ApiError, ClientConnectorError, asyncio.TimeoutError): errors["base"] = "cannot_connect" - except CannotGetMac: + except CannotGetMacError: return self.async_abort(reason="device_unsupported") except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") @@ -127,7 +127,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: await async_check_credentials(self.hass, self.host, user_input) - except AuthFailed: + except AuthFailedError: errors["base"] = "invalid_auth" except (ApiError, ClientConnectorError, asyncio.TimeoutError): errors["base"] = "cannot_connect" @@ -158,7 +158,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._config = await async_get_config(self.hass, self.host) except (ApiError, ClientConnectorError, asyncio.TimeoutError): return self.async_abort(reason="cannot_connect") - except CannotGetMac: + except CannotGetMacError: return self.async_abort(reason="device_unsupported") await self.async_set_unique_id(format_mac(self._config.mac_address)) @@ -206,7 +206,12 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: await async_check_credentials(self.hass, self.host, user_input) - except (ApiError, AuthFailed, ClientConnectorError, asyncio.TimeoutError): + except ( + ApiError, + AuthFailedError, + ClientConnectorError, + asyncio.TimeoutError, + ): return self.async_abort(reason="reauth_unsuccessful") self.hass.config_entries.async_update_entry( diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index fe53f137ee..32f7329a0a 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], "quality_scale": "platinum", - "requirements": ["nettigo-air-monitor==2.0.0"], + "requirements": ["nettigo-air-monitor==2.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 878e9b9d96..3f9821a1e3 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -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, @@ -369,7 +366,7 @@ async def async_setup_entry( # Due to the change of the attribute name of two sensors, it is necessary to migrate # the unique_ids to the new names. - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) for old_sensor, new_sensor in MIGRATION_SENSORS: old_unique_id = f"{coordinator.unique_id}-{old_sensor}" new_unique_id = f"{coordinator.unique_id}-{new_sensor}" 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/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index be16db310b..87239f5fd8 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json -from homeassistant.util.json import load_json +from homeassistant.util.json import JsonObjectType, JsonValueType, load_json_object from .const import DOMAIN @@ -36,16 +36,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): reauth_entry: config_entries.ConfigEntry | None = None + nanoleaf: Nanoleaf + + # For discovery integration import + discovery_conf: JsonObjectType + device_id: str + VERSION = 1 - def __init__(self) -> None: - """Initialize a Nanoleaf flow.""" - self.nanoleaf: Nanoleaf - - # For discovery integration import - self.discovery_conf: dict - self.device_id: str - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -134,19 +132,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # Import from discovery integration self.device_id = device_id - self.discovery_conf = cast( - dict, - await self.hass.async_add_executor_job( - load_json, self.hass.config.path(CONFIG_FILE) - ), - ) - auth_token: str | None = self.discovery_conf.get(self.device_id, {}).get( - "token", # >= 2021.4 - self.discovery_conf.get(host, {}).get("token"), # < 2021.4 + self.discovery_conf = await self.hass.async_add_executor_job( + load_json_object, self.hass.config.path(CONFIG_FILE) ) + + auth_token: JsonValueType = None + if device_conf := self.discovery_conf.get(self.device_id): # >= 2021.4 + auth_token = cast(JsonObjectType, device_conf).get("token") + if not auth_token and (host_conf := self.discovery_conf.get(host)): # < 2021.4 + auth_token = cast(JsonObjectType, host_conf).get("token") + if auth_token is not None: self.nanoleaf = Nanoleaf( - async_get_clientsession(self.hass), host, auth_token + async_get_clientsession(self.hass), host, cast(str, auth_token) ) _LOGGER.warning( "Importing Nanoleaf %s from the discovery integration", name 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/__init__.py b/homeassistant/components/nest/__init__.py index 2c7abe6f6c..092e8ea08d 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -22,10 +22,6 @@ from google_nest_sdm.exceptions import ( import voluptuous as vol from homeassistant.auth.permissions.const import POLICY_READ -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.camera import Image, img_util from homeassistant.components.http import KEY_HASS_USER from homeassistant.components.http.view import HomeAssistantView @@ -52,11 +48,6 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.entity_registry import async_entries_for_device -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from homeassistant.helpers.typing import ConfigType from . import api, config_flow @@ -69,8 +60,6 @@ from .const import ( DATA_SDM, DATA_SUBSCRIBER, DOMAIN, - INSTALLED_AUTH_DOMAIN, - WEB_AUTH_DOMAIN, ) from .events import EVENT_NAME_MAP, NEST_EVENT from .legacy import async_setup_legacy, async_setup_legacy_entry @@ -128,9 +117,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in config: return True # ConfigMode.SDM_APPLICATION_CREDENTIALS - # Note that configuration.yaml deprecation warnings are handled in the - # config entry since we don't know what type of credentials we have and - # whether or not they can be imported. hass.data[DOMAIN][DATA_NEST_CONFIG] = config[DOMAIN] config_mode = config_flow.get_config_mode(hass) @@ -185,15 +171,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if DATA_SDM not in entry.data or config_mode == config_flow.ConfigMode.LEGACY: return await async_setup_legacy_entry(hass, entry) - if config_mode == config_flow.ConfigMode.SDM: - await async_import_config(hass, entry) - elif entry.unique_id != entry.data[CONF_PROJECT_ID]: + if entry.unique_id != entry.data[CONF_PROJECT_ID]: hass.config_entries.async_update_entry( entry, unique_id=entry.data[CONF_PROJECT_ID] ) - async_delete_issue(hass, DOMAIN, "removed_app_auth") - subscriber = await api.new_subscriber(hass, entry) if not subscriber: return False @@ -239,71 +221,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_import_config(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Attempt to import configuration.yaml settings.""" - config = hass.data[DOMAIN][DATA_NEST_CONFIG] - new_data = { - CONF_PROJECT_ID: config[CONF_PROJECT_ID], - **entry.data, - } - if CONF_SUBSCRIBER_ID not in entry.data: - if CONF_SUBSCRIBER_ID not in config: - raise ValueError("Configuration option 'subscriber_id' missing") - new_data.update( - { - CONF_SUBSCRIBER_ID: config[CONF_SUBSCRIBER_ID], - # Don't delete user managed subscriber - CONF_SUBSCRIBER_ID_IMPORTED: True, - } - ) - hass.config_entries.async_update_entry( - entry, data=new_data, unique_id=new_data[CONF_PROJECT_ID] - ) - - if entry.data["auth_implementation"] == INSTALLED_AUTH_DOMAIN: - # App Auth credentials have been deprecated and must be re-created - # by the user in the config flow - async_create_issue( - hass, - DOMAIN, - "removed_app_auth", - is_fixable=False, - severity=IssueSeverity.ERROR, - translation_key="removed_app_auth", - translation_placeholders={ - "more_info_url": ( - "https://www.home-assistant.io/more-info/nest-auth-deprecation" - ), - "documentation_url": "https://www.home-assistant.io/integrations/nest/", - }, - ) - raise ConfigEntryAuthFailed( - "Google has deprecated App Auth credentials, and the integration " - "must be reconfigured in the UI to restore access to Nest Devices." - ) - - if entry.data["auth_implementation"] == WEB_AUTH_DOMAIN: - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - config[CONF_CLIENT_ID], - config[CONF_CLIENT_SECRET], - ), - WEB_AUTH_DOMAIN, - ) - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2022.10.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if DATA_SDM not in entry.data: diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index df6bb9b333..d20057f4e2 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -33,7 +33,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util import get_random_string -from homeassistant.util.json import load_json +from homeassistant.util.json import JsonObjectType, load_json_object from . import api from .const import ( @@ -43,7 +43,6 @@ from .const import ( DATA_NEST_CONFIG, DATA_SDM, DOMAIN, - INSTALLED_AUTH_DOMAIN, OAUTH2_AUTHORIZE, SDM_SCOPES, ) @@ -64,10 +63,6 @@ PUBSUB_API_URL = "https://console.cloud.google.com/apis/library/pubsub.googleapi # URLs for Configure Device Access Project step DEVICE_ACCESS_CONSOLE_URL = "https://console.nest.google.com/device-access/" -# URLs for App Auth deprecation and upgrade -UPGRADE_MORE_INFO_URL = ( - "https://www.home-assistant.io/integrations/nest/#deprecated-app-auth-credentials" -) DEVICE_ACCESS_CONSOLE_EDIT_URL = ( "https://console.nest.google.com/device-access/project/{project_id}/information" ) @@ -161,7 +156,6 @@ class NestFlowHandler( def __init__(self) -> None: """Initialize NestFlowHandler.""" super().__init__() - self._upgrade = False self._data: dict[str, Any] = {DATA_SDM: {}} # Possible name to use for config entry based on the Google Home name self._structure_config_title: str | None = None @@ -233,38 +227,8 @@ class NestFlowHandler( assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" if user_input is None: return self.async_show_form(step_id="reauth_confirm") - if self._data["auth_implementation"] == INSTALLED_AUTH_DOMAIN: - # The config entry points to an auth mechanism that no longer works and the - # user needs to take action in the google cloud console to resolve. First - # prompt to create app creds, then later ensure they've updated the device - # access console. - self._upgrade = True - implementations = await config_entry_oauth2_flow.async_get_implementations( - self.hass, self.DOMAIN - ) - if not implementations: - return await self.async_step_auth_upgrade() return await self.async_step_user() - async def async_step_auth_upgrade( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Give instructions for upgrade of deprecated app auth.""" - assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" - if user_input is None: - return self.async_show_form( - step_id="auth_upgrade", - description_placeholders={ - "more_info_url": UPGRADE_MORE_INFO_URL, - }, - ) - # Abort this flow and ask the user for application credentials. The frontend - # will restart a new config flow after the user finishes so schedule a new - # re-auth config flow for the same entry so the user may resume. - if reauth_entry := self._async_reauth_entry(): - self.hass.async_add_job(reauth_entry.async_start_reauth, self.hass) - return self.async_abort(reason="missing_credentials") - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -358,39 +322,6 @@ class NestFlowHandler( errors=errors, ) - async def async_step_auth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Verify any last pre-requisites before sending user through OAuth flow.""" - if user_input is None and self._upgrade: - # During app auth upgrade we need the user to update their device - # access project before we redirect to the authentication flow. - return await self.async_step_device_project_upgrade() - return await super().async_step_auth(user_input) - - async def async_step_device_project_upgrade( - self, user_input: dict | None = None - ) -> FlowResult: - """Update the device access project.""" - if user_input is not None: - # Resume OAuth2 redirects - return await super().async_step_auth() - if not isinstance( - self.flow_impl, config_entry_oauth2_flow.LocalOAuth2Implementation - ): - raise TypeError(f"Unexpected OAuth implementation: {self.flow_impl}") - client_id = self.flow_impl.client_id - return self.async_show_form( - step_id="device_project_upgrade", - description_placeholders={ - "device_access_console_url": DEVICE_ACCESS_CONSOLE_EDIT_URL.format( - project_id=self._data[CONF_PROJECT_ID] - ), - "more_info_url": UPGRADE_MORE_INFO_URL, - "client_id": client_id, - }, - ) - async def async_step_pubsub( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -570,7 +501,7 @@ class NestFlowHandler( return await self.async_step_link() flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN] - tokens = await self.hass.async_add_executor_job(load_json, config_path) + tokens = await self.hass.async_add_executor_job(load_json_object, config_path) return self._entry_from_tokens( "Nest (import from configuration.yaml)", flow, tokens @@ -578,7 +509,7 @@ class NestFlowHandler( @callback def _entry_from_tokens( - self, title: str, flow: dict[str, Any], tokens: list[Any] | dict[Any, Any] + self, title: str, flow: dict[str, Any], tokens: JsonObjectType ) -> FlowResult: """Create an entry from tokens.""" return self.async_create_entry( diff --git a/homeassistant/components/nest/legacy/__init__.py b/homeassistant/components/nest/legacy/__init__.py index 3c3a10a61b..88d046fb62 100644 --- a/homeassistant/components/nest/legacy/__init__.py +++ b/homeassistant/components/nest/legacy/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import DeviceInfo, Entity @@ -108,6 +108,19 @@ async def async_setup_legacy(hass: HomeAssistant, config: dict) -> bool: if DOMAIN not in config: return True + ir.async_create_issue( + hass, + DOMAIN, + "legacy_nest_deprecated", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="legacy_nest_deprecated", + translation_placeholders={ + "documentation_url": "https://www.home-assistant.io/integrations/nest/", + }, + ) + conf = config[DOMAIN] local_auth.initialize(hass, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET]) 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..2578437acf 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -4,14 +4,6 @@ }, "config": { "step": { - "auth_upgrade": { - "title": "Nest: App Auth Deprecation", - "description": "App Auth has been deprecated by Google to improve security, and you need to take action by creating new application credentials.\n\nOpen the [documentation]({more_info_url}) to follow along as the next steps will guide you through the steps you need to take to restore access to your Nest devices." - }, - "device_project_upgrade": { - "title": "Nest: Update Device Access Project", - "description": "Update the Nest Device Access Project with your new OAuth Client ID ([more info]({more_info_url}))\n1. Go to the [Device Access Console]({device_access_console_url}).\n1. Click the trash icon next to *OAuth Client ID*.\n1. Click the `...` overflow menu and *Add Client ID*.\n1. Enter your new OAuth Client ID and click **Add**.\n\nYour OAuth Client ID is: `{client_id}`" - }, "create_cloud_project": { "title": "Nest: Create and configure Cloud Project", "description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, click **Create Project** then **New Project**.\n1. Give your Cloud Project a Name and then click **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and click **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and click **Enable**.\n\nProceed when your cloud project is set up." @@ -61,7 +53,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)", @@ -90,13 +82,19 @@ } }, "issues": { - "deprecated_yaml": { - "title": "The Nest YAML configuration is being removed", - "description": "Configuring Nest in configuration.yaml is being removed in Home Assistant 2022.10.\n\nYour existing OAuth Application Credentials and access settings have been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - }, - "removed_app_auth": { - "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." + "legacy_nest_deprecated": { + "title": "Legacy Works With Nest is being removed", + "description": "Legacy Works With Nest is being removed from Home Assistant.\n\nYou must take action to use the SDM API. Remove all `nest` configuration from `configuration.yaml` and restart Home Assistant, then see the Nest [integration instructions]({documentation_url}) for set up instructions and supported devices." + } + }, + "entity": { + "sensor": { + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + } } } } diff --git a/homeassistant/components/netatmo/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/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index c6a519a37d..f3f45458d7 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -20,7 +20,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import ( config_validation as cv, device_registry as dr, - entity_registry, + entity_registry as er, ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -93,11 +93,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Netatmo devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) device_registry = dr.async_get(hass) triggers = [] - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if ( device := device_registry.async_get(device_id) ) is None or device.model is None: diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 25c42f92ce..949c7336ea 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -266,7 +266,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="power", entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfPower.WATT, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), ) 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..ed9ee49a0d 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -1,8 +1,6 @@ """Support for Netgear LTE modems.""" import asyncio from datetime import timedelta -import logging -from typing import Final import aiohttp import attr @@ -18,48 +16,32 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_create_clientsession -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from . import sensor_types - -_LOGGER = logging.getLogger(__name__) +from .const import ( + ATTR_FROM, + ATTR_HOST, + ATTR_MESSAGE, + ATTR_SMS_ID, + CONF_BINARY_SENSOR, + CONF_NOTIFY, + CONF_SENSOR, + DISPATCHER_NETGEAR_LTE, + DOMAIN, + LOGGER, +) +from .services import async_setup_services SCAN_INTERVAL = timedelta(seconds=10) -DISPATCHER_NETGEAR_LTE = "netgear_lte_update" - -CONF_NOTIFY: Final = "notify" -CONF_BINARY_SENSOR: Final = "binary_sensor" -CONF_SENSOR: Final = "sensor" - -DOMAIN = "netgear_lte" -DATA_KEY = "netgear_lte" EVENT_SMS = "netgear_lte_sms" -SERVICE_DELETE_SMS = "delete_sms" -SERVICE_SET_OPTION = "set_option" -SERVICE_CONNECT_LTE = "connect_lte" -SERVICE_DISCONNECT_LTE = "disconnect_lte" - -ATTR_HOST = "host" -ATTR_SMS_ID = "sms_id" -ATTR_FROM = "from" -ATTR_MESSAGE = "message" -ATTR_FAILOVER = "failover" -ATTR_AUTOCONNECT = "autoconnect" - -FAILOVER_MODES = ["auto", "wire", "mobile"] -AUTOCONNECT_MODES = ["never", "home", "always"] - NOTIFY_SCHEMA = vol.Schema( { @@ -108,28 +90,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -DELETE_SMS_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_HOST): cv.string, - vol.Required(ATTR_SMS_ID): vol.All(cv.ensure_list, [cv.positive_int]), - } -) - -SET_OPTION_SCHEMA = vol.Schema( - vol.All( - cv.has_at_least_one_key(ATTR_FAILOVER, ATTR_AUTOCONNECT), - { - vol.Optional(ATTR_HOST): cv.string, - vol.Optional(ATTR_FAILOVER): vol.In(FAILOVER_MODES), - vol.Optional(ATTR_AUTOCONNECT): vol.In(AUTOCONNECT_MODES), - }, - ) -) - -CONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) - -DISCONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) - @attr.s class ModemData: @@ -148,11 +108,11 @@ class ModemData: try: self.data = await self.modem.information() if not self.connected: - _LOGGER.warning("Connected to %s", self.host) + LOGGER.warning("Connected to %s", self.host) self.connected = True except eternalegypt.Error: if self.connected: - _LOGGER.warning("Lost connection to %s", self.host) + LOGGER.warning("Lost connection to %s", self.host) self.connected = False self.data = None @@ -177,51 +137,21 @@ class LTEData: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Netgear LTE component.""" - if DATA_KEY not in hass.data: + if DOMAIN not in hass.data: websession = async_create_clientsession( hass, cookie_jar=aiohttp.CookieJar(unsafe=True) ) - hass.data[DATA_KEY] = LTEData(websession) + hass.data[DOMAIN] = LTEData(websession) - async def service_handler(service: ServiceCall) -> None: - """Apply a service.""" - host = service.data.get(ATTR_HOST) - conf = {CONF_HOST: host} - modem_data = hass.data[DATA_KEY].get_modem_data(conf) - - if not modem_data: - _LOGGER.error("%s: host %s unavailable", service.service, host) - return - - if service.service == SERVICE_DELETE_SMS: - for sms_id in service.data[ATTR_SMS_ID]: - await modem_data.modem.delete_sms(sms_id) - elif service.service == SERVICE_SET_OPTION: - if failover := service.data.get(ATTR_FAILOVER): - await modem_data.modem.set_failover_mode(failover) - if autoconnect := service.data.get(ATTR_AUTOCONNECT): - await modem_data.modem.set_autoconnect_mode(autoconnect) - elif service.service == SERVICE_CONNECT_LTE: - await modem_data.modem.connect_lte() - elif service.service == SERVICE_DISCONNECT_LTE: - await modem_data.modem.disconnect_lte() - - service_schemas = { - SERVICE_DELETE_SMS: DELETE_SMS_SCHEMA, - SERVICE_SET_OPTION: SET_OPTION_SCHEMA, - SERVICE_CONNECT_LTE: CONNECT_LTE_SCHEMA, - SERVICE_DISCONNECT_LTE: DISCONNECT_LTE_SCHEMA, - } - - for service, schema in service_schemas.items(): - hass.services.async_register( - DOMAIN, service, service_handler, schema=schema - ) + await async_setup_services(hass) 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 @@ -269,7 +199,7 @@ async def _setup_lte(hass, lte_config): host = lte_config[CONF_HOST] password = lte_config[CONF_PASSWORD] - websession = hass.data[DATA_KEY].websession + websession = hass.data[DOMAIN].websession modem = eternalegypt.Modem(hostname=host, websession=websession) modem_data = ModemData(hass, host, modem) @@ -305,7 +235,7 @@ async def _login(hass, modem_data, password): await modem_data.modem.add_sms_listener(fire_sms_event) await modem_data.async_update() - hass.data[DATA_KEY].modem_data[modem_data.host] = modem_data + hass.data[DOMAIN].modem_data[modem_data.host] = modem_data async def _update(now): """Periodic update.""" @@ -317,7 +247,7 @@ async def _login(hass, modem_data, password): """Clean up resources.""" update_unsub() await modem_data.modem.logout() - del hass.data[DATA_KEY].modem_data[modem_data.host] + del hass.data[DOMAIN].modem_data[modem_data.host] hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) @@ -325,7 +255,7 @@ async def _login(hass, modem_data, password): async def _retry_login(hass, modem_data, password): """Sleep and retry setup.""" - _LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host) + LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host) modem_data.connected = False delay = 15 @@ -337,50 +267,3 @@ async def _retry_login(hass, modem_data, password): await _login(hass, modem_data, password) except eternalegypt.Error: delay = min(2 * delay, 300) - - -@attr.s -class LTEEntity(Entity): - """Base LTE entity.""" - - modem_data = attr.ib() - sensor_type = attr.ib() - - _unique_id = attr.ib(init=False) - - @_unique_id.default - def _init_unique_id(self): - """Register unique_id while we know data is valid.""" - return f"{self.sensor_type}_{self.modem_data.data.serial_number}" - - async def async_added_to_hass(self): - """Register callback.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, DISPATCHER_NETGEAR_LTE, self.async_write_ha_state - ) - ) - - async def async_update(self): - """Force update of state.""" - await self.modem_data.async_update() - - @property - def should_poll(self): - """Return that the sensor should not be polled.""" - return False - - @property - def available(self): - """Return the availability of the sensor.""" - return self.modem_data.data is not None - - @property - def unique_id(self): - """Return a unique ID like 'usage_5TG365AB0078V'.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"Netgear LTE {self.sensor_type}" diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index 0e7cd14bea..add5909602 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -2,12 +2,14 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_BINARY_SENSOR, CONF_MONITORED_CONDITIONS, DATA_KEY, LTEEntity +from .const import CONF_BINARY_SENSOR, DOMAIN +from .entity import LTEEntity from .sensor_types import BINARY_SENSOR_CLASSES @@ -21,7 +23,7 @@ async def async_setup_platform( if discovery_info is None: return - modem_data = hass.data[DATA_KEY].get_modem_data(discovery_info) + modem_data = hass.data[DOMAIN].get_modem_data(discovery_info) if not modem_data or not modem_data.data: raise PlatformNotReady diff --git a/homeassistant/components/netgear_lte/const.py b/homeassistant/components/netgear_lte/const.py new file mode 100644 index 0000000000..12c8f06b69 --- /dev/null +++ b/homeassistant/components/netgear_lte/const.py @@ -0,0 +1,22 @@ +"""Constants for the Netgear LTE integration.""" +import logging +from typing import Final + +ATTR_HOST = "host" +ATTR_SMS_ID = "sms_id" +ATTR_FROM = "from" +ATTR_MESSAGE = "message" +ATTR_FAILOVER = "failover" +ATTR_AUTOCONNECT = "autoconnect" +AUTOCONNECT_MODES = ["never", "home", "always"] + +CONF_BINARY_SENSOR: Final = "binary_sensor" +CONF_NOTIFY: Final = "notify" +CONF_SENSOR: Final = "sensor" + +DISPATCHER_NETGEAR_LTE = "netgear_lte_update" +DOMAIN: Final = "netgear_lte" + +FAILOVER_MODES = ["auto", "wire", "mobile"] + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/netgear_lte/entity.py b/homeassistant/components/netgear_lte/entity.py new file mode 100644 index 0000000000..33e0aaab74 --- /dev/null +++ b/homeassistant/components/netgear_lte/entity.py @@ -0,0 +1,41 @@ +"""Entity representing a Netgear LTE entity.""" + +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import ModemData +from .const import DISPATCHER_NETGEAR_LTE + + +class LTEEntity(Entity): + """Base LTE entity.""" + + _attr_should_poll = False + + def __init__( + self, + modem_data: ModemData, + sensor_type: str, + ) -> None: + """Initialize a Netgear LTE entity.""" + self.modem_data = modem_data + self.sensor_type = sensor_type + self._attr_name = f"Netgear LTE {sensor_type}" + self._attr_unique_id = f"{sensor_type}_{modem_data.data.serial_number}" + + async def async_added_to_hass(self) -> None: + """Register callback.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, DISPATCHER_NETGEAR_LTE, self.async_write_ha_state + ) + ) + + async def async_update(self) -> None: + """Force update of state.""" + await self.modem_data.async_update() + + @property + def available(self) -> bool: + """Return the availability of the sensor.""" + return self.modem_data.data is not None diff --git a/homeassistant/components/netgear_lte/manifest.json b/homeassistant/components/netgear_lte/manifest.json index ae580dbb99..c9a5245da4 100644 --- a/homeassistant/components/netgear_lte/manifest.json +++ b/homeassistant/components/netgear_lte/manifest.json @@ -1,9 +1,9 @@ { "domain": "netgear_lte", "name": "NETGEAR LTE", - "codeowners": [], + "codeowners": ["@tkdrob"], "documentation": "https://www.home-assistant.io/integrations/netgear_lte", "iot_class": "local_polling", "loggers": ["eternalegypt"], - "requirements": ["eternalegypt==0.0.12"] + "requirements": ["eternalegypt==0.0.16"] } diff --git a/homeassistant/components/netgear_lte/notify.py b/homeassistant/components/netgear_lte/notify.py index 52ce3a85dc..c21b56799e 100644 --- a/homeassistant/components/netgear_lte/notify.py +++ b/homeassistant/components/netgear_lte/notify.py @@ -1,18 +1,15 @@ """Support for Netgear LTE notifications.""" from __future__ import annotations -import logging - import attr import eternalegypt from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService +from homeassistant.const import CONF_RECIPIENT from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_NOTIFY, CONF_RECIPIENT, DATA_KEY - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_NOTIFY, DOMAIN, LOGGER async def async_get_service( @@ -37,14 +34,14 @@ class NetgearNotifyService(BaseNotificationService): async def async_send_message(self, message="", **kwargs): """Send a message to a user.""" - modem_data = self.hass.data[DATA_KEY].get_modem_data(self.config) + modem_data = self.hass.data[DOMAIN].get_modem_data(self.config) if not modem_data: - _LOGGER.error("Modem not ready") + LOGGER.error("Modem not ready") return targets = kwargs.get(ATTR_TARGET, self.config[CONF_NOTIFY][CONF_RECIPIENT]) if not targets: - _LOGGER.warning("No recipients") + LOGGER.warning("No recipients") return if not message: @@ -54,4 +51,4 @@ class NetgearNotifyService(BaseNotificationService): try: await modem_data.modem.sms(target, message) except eternalegypt.Error: - _LOGGER.error("Unable to send to %s", target) + LOGGER.error("Unable to send to %s", target) diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index 49942306da..4ca127e572 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -2,12 +2,14 @@ from __future__ import annotations from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_MONITORED_CONDITIONS, CONF_SENSOR, DATA_KEY, LTEEntity +from .const import CONF_SENSOR, DOMAIN +from .entity import LTEEntity from .sensor_types import SENSOR_SMS, SENSOR_SMS_TOTAL, SENSOR_UNITS, SENSOR_USAGE @@ -21,7 +23,7 @@ async def async_setup_platform( if discovery_info is None: return - modem_data = hass.data[DATA_KEY].get_modem_data(discovery_info) + modem_data = hass.data[DOMAIN].get_modem_data(discovery_info) if not modem_data or not modem_data.data: raise PlatformNotReady diff --git a/homeassistant/components/netgear_lte/services.py b/homeassistant/components/netgear_lte/services.py new file mode 100644 index 0000000000..2ea9889679 --- /dev/null +++ b/homeassistant/components/netgear_lte/services.py @@ -0,0 +1,86 @@ +"""Services for the Netgear LTE integration.""" +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv + +from .const import ( + ATTR_AUTOCONNECT, + ATTR_FAILOVER, + ATTR_HOST, + ATTR_SMS_ID, + AUTOCONNECT_MODES, + DOMAIN, + FAILOVER_MODES, + LOGGER, +) + +if TYPE_CHECKING: + from . import LTEData, ModemData + +SERVICE_DELETE_SMS = "delete_sms" +SERVICE_SET_OPTION = "set_option" +SERVICE_CONNECT_LTE = "connect_lte" +SERVICE_DISCONNECT_LTE = "disconnect_lte" + +DELETE_SMS_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_HOST): cv.string, + vol.Required(ATTR_SMS_ID): vol.All(cv.ensure_list, [cv.positive_int]), + } +) + +SET_OPTION_SCHEMA = vol.Schema( + vol.All( + cv.has_at_least_one_key(ATTR_FAILOVER, ATTR_AUTOCONNECT), + { + vol.Optional(ATTR_HOST): cv.string, + vol.Optional(ATTR_FAILOVER): vol.In(FAILOVER_MODES), + vol.Optional(ATTR_AUTOCONNECT): vol.In(AUTOCONNECT_MODES), + }, + ) +) + +CONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) + +DISCONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Netgear LTE integration.""" + + async def service_handler(call: ServiceCall) -> None: + """Apply a service.""" + host = call.data.get(ATTR_HOST) + data: LTEData = hass.data[DOMAIN] + modem_data: ModemData = data.get_modem_data({CONF_HOST: host}) + + if not modem_data: + LOGGER.error("%s: host %s unavailable", call.service, host) + return + + if call.service == SERVICE_DELETE_SMS: + for sms_id in call.data[ATTR_SMS_ID]: + await modem_data.modem.delete_sms(sms_id) + elif call.service == SERVICE_SET_OPTION: + if failover := call.data.get(ATTR_FAILOVER): + await modem_data.modem.set_failover_mode(failover) + if autoconnect := call.data.get(ATTR_AUTOCONNECT): + await modem_data.modem.set_autoconnect_mode(autoconnect) + elif call.service == SERVICE_CONNECT_LTE: + await modem_data.modem.connect_lte() + elif call.service == SERVICE_DISCONNECT_LTE: + await modem_data.modem.disconnect_lte() + + service_schemas = { + SERVICE_DELETE_SMS: DELETE_SMS_SCHEMA, + SERVICE_SET_OPTION: SET_OPTION_SCHEMA, + SERVICE_CONNECT_LTE: CONNECT_LTE_SCHEMA, + SERVICE_DISCONNECT_LTE: DISCONNECT_LTE_SCHEMA, + } + + for service, schema in service_schemas.items(): + hass.services.async_register(DOMAIN, service, service_handler, schema=schema) 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/nextbus/const.py b/homeassistant/components/nextbus/const.py new file mode 100644 index 0000000000..9d9d0a5262 --- /dev/null +++ b/homeassistant/components/nextbus/const.py @@ -0,0 +1,6 @@ +"""NextBus Constants.""" +DOMAIN = "nextbus" + +CONF_AGENCY = "agency" +CONF_ROUTE = "route" +CONF_STOP = "stop" diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index f475c94ed4..b77ffa86f0 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -3,7 +3,7 @@ "name": "NextBus", "codeowners": ["@vividboarder"], "documentation": "https://www.home-assistant.io/integrations/nextbus", - "iot_class": "local_polling", + "iot_class": "cloud_polling", "loggers": ["py_nextbus"], "requirements": ["py_nextbusnext==0.1.5"] } diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 4f24a7aa7f..02f5d8695c 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -19,14 +19,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utc_from_timestamp +from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP +from .util import listify, maybe_first + _LOGGER = logging.getLogger(__name__) -DOMAIN = "nextbus" - -CONF_AGENCY = "agency" -CONF_ROUTE = "route" -CONF_STOP = "stop" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_AGENCY): cv.string, @@ -37,29 +34,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def listify(maybe_list): - """Return list version of whatever value is passed in. - - This is used to provide a consistent way of interacting with the JSON - results from the API. There are several attributes that will either missing - if there are no values, a single dictionary if there is only one value, and - a list if there are multiple. - """ - if maybe_list is None: - return [] - if isinstance(maybe_list, list): - return maybe_list - return [maybe_list] - - -def maybe_first(maybe_list): - """Return the first item out of a list or returns back the input.""" - if isinstance(maybe_list, list) and maybe_list: - return maybe_list[0] - - return maybe_list - - def validate_value(value_name, value, value_list): """Validate tag value is in the list of items and logs error if not.""" valid_values = {v["tag"]: v["title"] for v in value_list} diff --git a/homeassistant/components/nextbus/util.py b/homeassistant/components/nextbus/util.py new file mode 100644 index 0000000000..c753c45254 --- /dev/null +++ b/homeassistant/components/nextbus/util.py @@ -0,0 +1,25 @@ +"""Utils for NextBus integration module.""" +from typing import Any + + +def listify(maybe_list: Any) -> list[Any]: + """Return list version of whatever value is passed in. + + This is used to provide a consistent way of interacting with the JSON + results from the API. There are several attributes that will either missing + if there are no values, a single dictionary if there is only one value, and + a list if there are multiple. + """ + if maybe_list is None: + return [] + if isinstance(maybe_list, list): + return maybe_list + return [maybe_list] + + +def maybe_first(maybe_list: list[Any]) -> Any: + """Return the first item out of a list or returns back the input.""" + if isinstance(maybe_list, list) and maybe_list: + return maybe_list[0] + + return maybe_list diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 269bd96aa3..65829f713e 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -1,151 +1,116 @@ """The Nextcloud integration.""" -from datetime import timedelta 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 -_LOGGER = logging.getLogger(__name__) +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator -DOMAIN = "nextcloud" +_LOGGER = logging.getLogger(__name__) PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR) -SCAN_INTERVAL = timedelta(seconds=60) # 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=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, ) -BINARY_SENSORS = ( - "nextcloud_system_enable_avatars", - "nextcloud_system_enable_previews", - "nextcloud_system_filelocking.enabled", - "nextcloud_system_debug", -) - -SENSORS = ( - "nextcloud_system_version", - "nextcloud_system_theme", - "nextcloud_system_memcache.local", - "nextcloud_system_memcache.distributed", - "nextcloud_system_memcache.locking", - "nextcloud_system_freespace", - "nextcloud_system_cpuload", - "nextcloud_system_mem_total", - "nextcloud_system_mem_free", - "nextcloud_system_swap_total", - "nextcloud_system_swap_free", - "nextcloud_system_apps_num_installed", - "nextcloud_system_apps_num_updates_available", - "nextcloud_system_apps_app_updates_calendar", - "nextcloud_system_apps_app_updates_contacts", - "nextcloud_system_apps_app_updates_tasks", - "nextcloud_system_apps_app_updates_twofactor_totp", - "nextcloud_storage_num_users", - "nextcloud_storage_num_files", - "nextcloud_storage_num_storages", - "nextcloud_storage_num_storages_local", - "nextcloud_storage_num_storages_home", - "nextcloud_storage_num_storages_other", - "nextcloud_shares_num_shares", - "nextcloud_shares_num_shares_user", - "nextcloud_shares_num_shares_groups", - "nextcloud_shares_num_shares_link", - "nextcloud_shares_num_shares_mail", - "nextcloud_shares_num_shares_room", - "nextcloud_shares_num_shares_link_no_password", - "nextcloud_shares_num_fed_shares_sent", - "nextcloud_shares_num_fed_shares_received", - "nextcloud_shares_permissions_3_1", - "nextcloud_server_webserver", - "nextcloud_server_php_version", - "nextcloud_server_php_memory_limit", - "nextcloud_server_php_max_execution_time", - "nextcloud_server_php_upload_max_filesize", - "nextcloud_database_type", - "nextcloud_database_version", - "nextcloud_activeUsers_last5minutes", - "nextcloud_activeUsers_last1hour", - "nextcloud_activeUsers_last24hours", -) -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 e9d5b4a8d7..3cf3cc3ae2 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -2,57 +2,40 @@ 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 . import BINARY_SENSORS, DOMAIN +from .const import DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator +from .entity import NextcloudEntity + +BINARY_SENSORS = ( + "nextcloud_system_enable_avatars", + "nextcloud_system_enable_previews", + "nextcloud_system_filelocking.enabled", + "nextcloud_system_debug", +) -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(BinarySensorEntity): +class NextcloudBinarySensor(NextcloudEntity, BinarySensorEntity): """Represents a Nextcloud binary sensor.""" - def __init__(self, item): - """Initialize the Nextcloud binary sensor.""" - self._name = item - self._is_on = None - @property - def icon(self): - """Return the icon for this binary sensor.""" - return "mdi:cloud" - - @property - def name(self): - """Return the name for this binary sensor.""" - return self._name - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self._is_on == "yes" - - @property - def unique_id(self): - """Return the unique ID for this binary sensor.""" - return f"{self.hass.data[DOMAIN]['instance']}#{self._name}" - - def update(self) -> None: - """Update the binary sensor.""" - self._is_on = self.hass.data[DOMAIN][self._name] + 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 new file mode 100644 index 0000000000..248128dd53 --- /dev/null +++ b/homeassistant/components/nextcloud/const.py @@ -0,0 +1,6 @@ +"""Constants for Nextcloud integration.""" +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 new file mode 100644 index 0000000000..ed5882cfe7 --- /dev/null +++ b/homeassistant/components/nextcloud/entity.py @@ -0,0 +1,31 @@ +"""Base entity for the Nextcloud integration.""" + + +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(CoordinatorEntity[NextcloudDataUpdateCoordinator]): + """Base Nextcloud entity.""" + + _attr_has_entity_name = True + _attr_icon = "mdi:cloud" + + def __init__( + self, coordinator: NextcloudDataUpdateCoordinator, item: str, entry: ConfigEntry + ) -> None: + """Initialize the Nextcloud sensor.""" + super().__init__(coordinator) + self.item = 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 31caa46028..a5df872e08 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -2,57 +2,80 @@ 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 +from homeassistant.helpers.typing import StateType -from . import DOMAIN, SENSORS +from .const import DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator +from .entity import NextcloudEntity + +SENSORS = ( + "nextcloud_system_version", + "nextcloud_system_theme", + "nextcloud_system_memcache.local", + "nextcloud_system_memcache.distributed", + "nextcloud_system_memcache.locking", + "nextcloud_system_freespace", + "nextcloud_system_cpuload", + "nextcloud_system_mem_total", + "nextcloud_system_mem_free", + "nextcloud_system_swap_total", + "nextcloud_system_swap_free", + "nextcloud_system_apps_num_installed", + "nextcloud_system_apps_num_updates_available", + "nextcloud_system_apps_app_updates_calendar", + "nextcloud_system_apps_app_updates_contacts", + "nextcloud_system_apps_app_updates_tasks", + "nextcloud_system_apps_app_updates_twofactor_totp", + "nextcloud_storage_num_users", + "nextcloud_storage_num_files", + "nextcloud_storage_num_storages", + "nextcloud_storage_num_storages_local", + "nextcloud_storage_num_storages_home", + "nextcloud_storage_num_storages_other", + "nextcloud_shares_num_shares", + "nextcloud_shares_num_shares_user", + "nextcloud_shares_num_shares_groups", + "nextcloud_shares_num_shares_link", + "nextcloud_shares_num_shares_mail", + "nextcloud_shares_num_shares_room", + "nextcloud_shares_num_shares_link_no_password", + "nextcloud_shares_num_fed_shares_sent", + "nextcloud_shares_num_fed_shares_received", + "nextcloud_shares_permissions_3_1", + "nextcloud_server_webserver", + "nextcloud_server_php_version", + "nextcloud_server_php_memory_limit", + "nextcloud_server_php_max_execution_time", + "nextcloud_server_php_upload_max_filesize", + "nextcloud_database_type", + "nextcloud_database_version", + "nextcloud_activeUsers_last5minutes", + "nextcloud_activeUsers_last1hour", + "nextcloud_activeUsers_last24hours", +) -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(SensorEntity): +class NextcloudSensor(NextcloudEntity, SensorEntity): """Represents a Nextcloud sensor.""" - def __init__(self, item): - """Initialize the Nextcloud sensor.""" - self._name = item - self._state = None - @property - def icon(self): - """Return the icon for this sensor.""" - return "mdi:cloud" - - @property - def name(self): - """Return the name for this sensor.""" - return self._name - - @property - def native_value(self): + def native_value(self) -> StateType: """Return the state for this sensor.""" - return self._state - - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return f"{self.hass.data[DOMAIN]['instance']}#{self._name}" - - def update(self) -> None: - """Update the sensor.""" - self._state = self.hass.data[DOMAIN][self._name] + 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 ce58d9e002..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.2.2"] + "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/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index 57c5f680e0..b46102879c 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -8,11 +8,11 @@ from datetime import timedelta from functools import cached_property from typing import Any, Generic, TypeVar -from nibe.coil import Coil +from nibe.coil import Coil, CoilData from nibe.connection import Connection from nibe.connection.modbus import Modbus from nibe.connection.nibegw import NibeGW, ProductInfo -from nibe.exceptions import CoilNotFoundException, CoilReadException +from nibe.exceptions import CoilNotFoundException, ReadException from nibe.heatpump import HeatPump, Model, Series from homeassistant.config_entries import ConfigEntry @@ -54,6 +54,7 @@ PLATFORMS: list[Platform] = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.WATER_HEATER, ] COIL_READ_RETRIES = 5 @@ -62,13 +63,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nibe Heat Pump from a config entry.""" heatpump = HeatPump(Model[entry.data[CONF_MODEL]]) + heatpump.word_swap = entry.data.get(CONF_WORD_SWAP, True) await heatpump.initialize() connection: Connection connection_type = entry.data[CONF_CONNECTION_TYPE] if connection_type == CONF_CONNECTION_TYPE_NIBEGW: - heatpump.word_swap = entry.data[CONF_WORD_SWAP] connection = NibeGW( heatpump, entry.data[CONF_IP_ADDRESS], @@ -182,7 +183,7 @@ class ContextCoordinator( return release_update -class Coordinator(ContextCoordinator[dict[int, Coil], int]): +class Coordinator(ContextCoordinator[dict[int, CoilData], int]): """Update coordinator for nibe heat pumps.""" config_entry: ConfigEntry @@ -199,17 +200,18 @@ class Coordinator(ContextCoordinator[dict[int, Coil], int]): ) self.data = {} - self.seed: dict[int, Coil] = {} + self.seed: dict[int, CoilData] = {} self.connection = connection self.heatpump = heatpump self.task: asyncio.Task | None = None heatpump.subscribe(heatpump.COIL_UPDATE_EVENT, self._on_coil_update) - def _on_coil_update(self, coil: Coil): + def _on_coil_update(self, data: CoilData): """Handle callback on coil updates.""" - self.data[coil.address] = coil - self.seed[coil.address] = coil + coil = data.coil + self.data[coil.address] = data + self.seed[coil.address] = data self.async_update_context_listeners([coil.address]) @property @@ -246,26 +248,26 @@ class Coordinator(ContextCoordinator[dict[int, Coil], int]): async def async_write_coil(self, coil: Coil, value: int | float | str) -> None: """Write coil and update state.""" - coil.value = value - coil = await self.connection.write_coil(coil) + data = CoilData(coil, value) + await self.connection.write_coil(data) - self.data[coil.address] = coil + self.data[coil.address] = data self.async_update_context_listeners([coil.address]) - async def async_read_coil(self, coil: Coil) -> Coil: + async def async_read_coil(self, coil: Coil) -> CoilData: """Read coil and update state using callbacks.""" return await self.connection.read_coil(coil) - async def _async_update_data(self) -> dict[int, Coil]: + async def _async_update_data(self) -> dict[int, CoilData]: self.task = asyncio.current_task() try: return await self._async_update_data_internal() finally: self.task = None - async def _async_update_data_internal(self) -> dict[int, Coil]: - result: dict[int, Coil] = {} + async def _async_update_data_internal(self) -> dict[int, CoilData]: + result: dict[int, CoilData] = {} def _get_coils() -> Iterable[Coil]: for address in sorted(self.context_callbacks.keys()): @@ -282,10 +284,10 @@ class Coordinator(ContextCoordinator[dict[int, Coil], int]): yield coil try: - async for coil in self.connection.read_coils(_get_coils()): - result[coil.address] = coil - self.seed.pop(coil.address, None) - except CoilReadException as exception: + async for data in self.connection.read_coils(_get_coils()): + result[data.coil.address] = data + self.seed.pop(data.coil.address, None) + except ReadException as exception: if not result: raise UpdateFailed(f"Failed to update: {exception}") from exception self.logger.debug( @@ -329,7 +331,7 @@ class CoilEntity(CoordinatorEntity[Coordinator]): self.coordinator.data or {} ) - def _async_read_coil(self, coil: Coil): + def _async_read_coil(self, data: CoilData): """Update state of entity based on coil data.""" async def _async_write_coil(self, value: int | float | str): @@ -337,10 +339,9 @@ class CoilEntity(CoordinatorEntity[Coordinator]): await self.coordinator.async_write_coil(self._coil, value) def _handle_coordinator_update(self) -> None: - coil = self.coordinator.data.get(self._coil.address) - if coil is None: + data = self.coordinator.data.get(self._coil.address) + if data is None: return - self._coil = coil - self._async_read_coil(coil) + self._async_read_coil(data) self.async_write_ha_state() diff --git a/homeassistant/components/nibe_heatpump/binary_sensor.py b/homeassistant/components/nibe_heatpump/binary_sensor.py index 89c993cafa..263fd41b30 100644 --- a/homeassistant/components/nibe_heatpump/binary_sensor.py +++ b/homeassistant/components/nibe_heatpump/binary_sensor.py @@ -1,7 +1,7 @@ """The Nibe Heat Pump binary sensors.""" from __future__ import annotations -from nibe.coil import Coil +from nibe.coil import Coil, CoilData from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorEntity from homeassistant.config_entries import ConfigEntry @@ -37,5 +37,5 @@ class BinarySensor(CoilEntity, BinarySensorEntity): """Initialize entity.""" super().__init__(coordinator, coil, ENTITY_ID_FORMAT) - def _async_read_coil(self, coil: Coil) -> None: - self._attr_is_on = coil.value == "ON" + def _async_read_coil(self, data: CoilData) -> None: + self._attr_is_on = data.value == "ON" diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 9c7d8641b6..0df787de98 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -31,6 +31,7 @@ from . import Coordinator from .const import ( DOMAIN, LOGGER, + VALUES_COOL_WITH_ROOM_SENSOR_OFF, VALUES_MIXING_VALVE_CLOSED_STATE, VALUES_PRIORITY_COOLING, VALUES_PRIORITY_HEATING, @@ -139,10 +140,13 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): mode = HVACMode.OFF if _get_value(self._coil_use_room_sensor) == "ON": - if _get_value(self._coil_cooling_with_room_sensor) == "ON": - mode = HVACMode.HEAT_COOL - else: + if ( + _get_value(self._coil_cooling_with_room_sensor) + in VALUES_COOL_WITH_ROOM_SENSOR_OFF + ): mode = HVACMode.HEAT + else: + mode = HVACMode.HEAT_COOL self._attr_hvac_mode = mode setpoint_heat = _get_float(self._coil_setpoint_heat) diff --git a/homeassistant/components/nibe_heatpump/config_flow.py b/homeassistant/components/nibe_heatpump/config_flow.py index 6050010b20..6680ca6e32 100644 --- a/homeassistant/components/nibe_heatpump/config_flow.py +++ b/homeassistant/components/nibe_heatpump/config_flow.py @@ -8,10 +8,10 @@ from nibe.connection.nibegw import NibeGW from nibe.exceptions import ( AddressInUseException, CoilNotFoundException, - CoilReadException, - CoilReadSendException, - CoilWriteException, CoilWriteSendException, + ReadException, + ReadSendException, + WriteException, ) from nibe.heatpump import HeatPump, Model import voluptuous as vol @@ -89,6 +89,7 @@ async def validate_nibegw_input( """Validate the user input allows us to connect.""" heatpump = HeatPump(Model[data[CONF_MODEL]]) + heatpump.word_swap = True await heatpump.initialize() connection = NibeGW( @@ -108,13 +109,13 @@ async def validate_nibegw_input( try: await connection.verify_connectivity() - except (CoilReadSendException, CoilWriteSendException) as exception: + except (ReadSendException, CoilWriteSendException) as exception: raise FieldError(str(exception), CONF_IP_ADDRESS, "address") from exception except CoilNotFoundException as exception: raise FieldError("Coils not found", "base", "model") from exception - except CoilReadException as exception: + except ReadException as exception: raise FieldError("Timeout on read from pump", "base", "read") from exception - except CoilWriteException as exception: + except WriteException as exception: raise FieldError("Timeout on writing to pump", "base", "write") from exception finally: await connection.stop() @@ -147,13 +148,13 @@ async def validate_modbus_input( try: await connection.verify_connectivity() - except (CoilReadSendException, CoilWriteSendException) as exception: + except (ReadSendException, CoilWriteSendException) as exception: raise FieldError(str(exception), CONF_MODBUS_URL, "address") from exception except CoilNotFoundException as exception: raise FieldError("Coils not found", "base", "model") from exception - except CoilReadException as exception: + except ReadException as exception: raise FieldError("Timeout on read from pump", "base", "read") from exception - except CoilWriteException as exception: + except WriteException as exception: raise FieldError("Timeout on writing to pump", "base", "write") from exception finally: await connection.stop() diff --git a/homeassistant/components/nibe_heatpump/const.py b/homeassistant/components/nibe_heatpump/const.py index 7d9bf58709..0f16567671 100644 --- a/homeassistant/components/nibe_heatpump/const.py +++ b/homeassistant/components/nibe_heatpump/const.py @@ -17,3 +17,7 @@ CONF_MODBUS_UNIT = "modbus_unit" VALUES_MIXING_VALVE_CLOSED_STATE = (30, "CLOSED", "SHUNT CLOSED") VALUES_PRIORITY_HEATING = (30, "HEAT") VALUES_PRIORITY_COOLING = (60, "COOLING") +VALUES_PRIORITY_HOT_WATER = (20, "HOT WATER") +VALUES_TEMPORARY_LUX_INACTIVE = "OFF" +VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE = "ONE TIME INCREASE" +VALUES_COOL_WITH_ROOM_SENSOR_OFF = (0, "OFF") diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index d9a2bd365e..f57a4511ee 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==1.6.0"] + "requirements": ["nibe==2.2.0"] } diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index 579b8e7915..7907881188 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -1,7 +1,7 @@ """The Nibe Heat Pump numbers.""" from __future__ import annotations -from nibe.coil import Coil +from nibe.coil import Coil, CoilData from homeassistant.components.number import ENTITY_ID_FORMAT, NumberEntity from homeassistant.config_entries import ConfigEntry @@ -58,13 +58,13 @@ class Number(CoilEntity, NumberEntity): self._attr_native_unit_of_measurement = coil.unit self._attr_native_value = None - def _async_read_coil(self, coil: Coil) -> None: - if coil.value is None: + def _async_read_coil(self, data: CoilData) -> None: + if data.value is None: self._attr_native_value = None return try: - self._attr_native_value = float(coil.value) + self._attr_native_value = float(data.value) except ValueError: self._attr_native_value = None diff --git a/homeassistant/components/nibe_heatpump/select.py b/homeassistant/components/nibe_heatpump/select.py index d554eaf4ff..e255ff3650 100644 --- a/homeassistant/components/nibe_heatpump/select.py +++ b/homeassistant/components/nibe_heatpump/select.py @@ -1,7 +1,7 @@ """The Nibe Heat Pump select.""" from __future__ import annotations -from nibe.coil import Coil +from nibe.coil import Coil, CoilData from homeassistant.components.select import ENTITY_ID_FORMAT, SelectEntity from homeassistant.config_entries import ConfigEntry @@ -40,12 +40,12 @@ class Select(CoilEntity, SelectEntity): self._attr_options = list(coil.mappings.values()) self._attr_current_option = None - def _async_read_coil(self, coil: Coil) -> None: - if not isinstance(coil.value, str): + def _async_read_coil(self, data: CoilData) -> None: + if not isinstance(data.value, str): self._attr_current_option = None return - self._attr_current_option = coil.value + self._attr_current_option = data.value async def async_select_option(self, option: str) -> None: """Support writing value.""" diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py index 94f3704048..8aabad2c9f 100644 --- a/homeassistant/components/nibe_heatpump/sensor.py +++ b/homeassistant/components/nibe_heatpump/sensor.py @@ -1,7 +1,7 @@ """The Nibe Heat Pump sensors.""" from __future__ import annotations -from nibe.coil import Coil +from nibe.coil import Coil, CoilData from homeassistant.components.sensor import ( ENTITY_ID_FORMAT, @@ -146,5 +146,5 @@ class Sensor(CoilEntity, SensorEntity): self._attr_native_unit_of_measurement = coil.unit self._attr_entity_category = EntityCategory.DIAGNOSTIC - def _async_read_coil(self, coil: Coil): - self._attr_native_value = coil.value + def _async_read_coil(self, data: CoilData): + self._attr_native_value = data.value diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 23634e77c5..95d96de976 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from nibe.coil import Coil +from nibe.coil import Coil, CoilData from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -40,8 +40,8 @@ class Switch(CoilEntity, SwitchEntity): super().__init__(coordinator, coil, ENTITY_ID_FORMAT) self._attr_is_on = None - def _async_read_coil(self, coil: Coil) -> None: - self._attr_is_on = coil.value == "ON" + def _async_read_coil(self, data: CoilData) -> None: + self._attr_is_on = data.value == "ON" async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" diff --git a/homeassistant/components/nibe_heatpump/water_heater.py b/homeassistant/components/nibe_heatpump/water_heater.py new file mode 100644 index 0000000000..0c60638077 --- /dev/null +++ b/homeassistant/components/nibe_heatpump/water_heater.py @@ -0,0 +1,185 @@ +"""The Nibe Heat Pump sensors.""" +from __future__ import annotations + +from nibe.coil import Coil +from nibe.coil_groups import WATER_HEATER_COILGROUPS, WaterHeaterCoilGroup +from nibe.exceptions import CoilNotFoundException + +from homeassistant.components.water_heater import ( + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +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.update_coordinator import CoordinatorEntity + +from . import DOMAIN, LOGGER, Coordinator +from .const import VALUES_TEMPORARY_LUX_INACTIVE, VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up platform.""" + + coordinator: Coordinator = hass.data[DOMAIN][config_entry.entry_id] + + def water_heaters(): + for key, group in WATER_HEATER_COILGROUPS.get(coordinator.series, ()).items(): + try: + yield WaterHeater(coordinator, key, group) + except CoilNotFoundException as exception: + LOGGER.debug("Skipping water heater: %r", exception) + + async_add_entities(water_heaters()) + + +class WaterHeater(CoordinatorEntity[Coordinator], WaterHeaterEntity): + """Sensor entity.""" + + _attr_entity_category = None + _attr_has_entity_name = True + _attr_supported_features = WaterHeaterEntityFeature.OPERATION_MODE + _attr_max_temp = 35.0 + _attr_min_temp = 5.0 + + def __init__( + self, + coordinator: Coordinator, + key: str, + desc: WaterHeaterCoilGroup, + ) -> None: + """Initialize entity.""" + + super().__init__( + coordinator, + { + desc.hot_water_load, + desc.hot_water_comfort_mode, + *set(desc.start_temperature.values()), + *set(desc.stop_temperature.values()), + desc.active_accessory, + desc.temporary_lux, + }, + ) + self._attr_entity_registry_enabled_default = desc.active_accessory is None + self._attr_available = False + self._attr_name = desc.name + self._attr_unique_id = f"{coordinator.unique_id}-{key}" + self._attr_device_info = coordinator.device_info + + self._attr_current_operation = None + self._attr_target_temperature_high = None + self._attr_target_temperature_low = None + self._attr_operation_list = [] + self._operation_mode_to_lux: dict[str, str] = {} + + def _get(address: int) -> Coil: + return coordinator.heatpump.get_coil_by_address(address) + + def _map(data: dict[str, int]) -> dict[str, Coil]: + return {key: _get(address) for key, address in data.items()} + + self._coil_current = _get(desc.hot_water_load) + self._coil_start_temperature = _map(desc.start_temperature) + self._coil_stop_temperature = _map(desc.stop_temperature) + self._coil_temporary_lux: Coil | None = None + if desc.temporary_lux: + self._coil_temporary_lux = _get(desc.temporary_lux) + self._coil_active_accessory: Coil | None = None + if address := desc.active_accessory: + self._coil_active_accessory = _get(address) + + self._coil_hot_water_comfort_mode = _get(desc.hot_water_comfort_mode) + + def _add_lux_mode(temporary_lux: str, operation_mode: str) -> None: + assert self._attr_operation_list is not None + if ( + not self._coil_temporary_lux + or not self._coil_temporary_lux.reverse_mappings + ): + return + + if temporary_lux not in self._coil_temporary_lux.reverse_mappings: + return + + self._attr_operation_list.append(operation_mode) + self._operation_mode_to_lux[operation_mode] = temporary_lux + + _add_lux_mode(VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE, STATE_HIGH_DEMAND) + _add_lux_mode(VALUES_TEMPORARY_LUX_INACTIVE, STATE_HEAT_PUMP) + + self._attr_temperature_unit = self._coil_current.unit + + @callback + def _handle_coordinator_update(self) -> None: + if not self.coordinator.data: + return + + def _get_float(coil: Coil | None) -> float | None: + if coil is None: + return None + return self.coordinator.get_coil_float(coil) + + def _get_value(coil: Coil | None) -> int | str | float | None: + if coil is None: + return None + return self.coordinator.get_coil_value(coil) + + self._attr_current_temperature = _get_float(self._coil_current) + + if (mode := _get_value(self._coil_hot_water_comfort_mode)) and isinstance( + mode, str + ): + self._attr_target_temperature_low = _get_float( + self._coil_start_temperature.get(mode) + ) + self._attr_target_temperature_high = _get_float( + self._coil_stop_temperature.get(mode) + ) + else: + self._attr_target_temperature_low = None + self._attr_target_temperature_high = None + + if ( + _get_value(self._coil_temporary_lux) + == VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE + ): + self._attr_current_operation = STATE_HIGH_DEMAND + else: + self._attr_current_operation = STATE_HEAT_PUMP + + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return if entity is available.""" + if not self.coordinator.last_update_success: + return False + + if not self._coil_active_accessory: + return True + + if active_accessory := self.coordinator.get_coil_value( + self._coil_active_accessory + ): + return active_accessory == "ON" + + return False + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new target operation mode.""" + if not self._coil_temporary_lux: + raise HomeAssistantError("Not supported") + + lux = self._operation_mode_to_lux.get(operation_mode) + if not lux: + raise ValueError(f"Unsupported operation mode {operation_mode}") + + await self.coordinator.async_write_coil(self._coil_temporary_lux, lux) 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/nina/manifest.json b/homeassistant/components/nina/manifest.json index be14b57ed4..6386a70d08 100644 --- a/homeassistant/components/nina/manifest.json +++ b/homeassistant/components/nina/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nina", "iot_class": "cloud_polling", "loggers": ["pynina"], - "requirements": ["pynina==0.2.0"] + "requirements": ["pynina==0.3.0"] } diff --git a/homeassistant/components/nissan_leaf/sensor.py b/homeassistant/components/nissan_leaf/sensor.py index 5b2f99b997..cd3524eaf8 100644 --- a/homeassistant/components/nissan_leaf/sensor.py +++ b/homeassistant/components/nissan_leaf/sensor.py @@ -3,14 +3,12 @@ from __future__ import annotations import logging -from voluptuous.validators import Number - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import PERCENTAGE, UnitOfLength from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -63,11 +61,11 @@ class LeafBatterySensor(LeafEntity, SensorEntity): return f"{self.car.leaf.nickname} Charge" @property - def native_value(self) -> Number | None: + def native_value(self) -> StateType: """Battery state percentage.""" if self.car.data[DATA_BATTERY] is None: return None - return round(self.car.data[DATA_BATTERY]) + return round(self.car.data[DATA_BATTERY]) # type: ignore[no-any-return] @property def icon(self) -> str: diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 827fb93a01..0dafff996d 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -132,7 +132,7 @@ def signal_device_update(mac_address) -> str: class NmapDeviceScanner: - """This class scans for devices using nmap.""" + """Scanner for devices using nmap.""" def __init__( self, hass: HomeAssistant, entry: ConfigEntry, devices: NmapTrackedDevices diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index b9a216875f..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,10 +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"): + 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 = ( @@ -284,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/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index d828fb78b7..bc2c328d64 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -11,7 +11,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from .const import ( ATTR_HARDWARE_VERSION, @@ -38,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) # Register hub as device - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_reg.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, hub.hub_info[ATTR_SERIAL])}, 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..ad228f08a4 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -2,13 +2,24 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass, field 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, BridgeAllResponse from aionotion.errors import InvalidCredentialsError, NotionError +from aionotion.sensor.models import ( + Listener, + ListenerAllResponse, + ListenerKind, + Sensor, + SensorAllResponse, +) +from aionotion.user.models import UserPreferences, UserPreferencesResponse from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform @@ -18,6 +29,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,17 +38,111 @@ 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] ATTR_SYSTEM_MODE = "system_mode" ATTR_SYSTEM_NAME = "system_name" +DATA_BRIDGES = "bridges" +DATA_LISTENERS = "listeners" +DATA_SENSORS = "sensors" +DATA_USER_PREFERENCES = "user_preferences" + 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.""" + + hass: HomeAssistant + entry: ConfigEntry + + # 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) + + # Define a user preferences response object: + user_preferences: UserPreferences | None = field(default=None) + + def update_data_from_response( + self, + response: BridgeAllResponse + | ListenerAllResponse + | SensorAllResponse + | UserPreferencesResponse, + ) -> None: + """Update data from an aionotion response.""" + if isinstance(response, BridgeAllResponse): + for bridge in response.bridges: + # If a new bridge is discovered, register it: + if bridge.id not in self.bridges: + _async_register_new_bridge(self.hass, self.entry, bridge) + self.bridges[bridge.id] = bridge + elif isinstance(response, ListenerAllResponse): + self.listeners = {listener.id: listener for listener in response.listeners} + elif isinstance(response, SensorAllResponse): + self.sensors = {sensor.uuid: sensor for sensor in response.sensors} + elif isinstance(response, UserPreferencesResponse): + self.user_preferences = response.user_preferences + + def asdict(self) -> dict[str, Any]: + """Represent this dataclass (and its Pydantic contents) as a dict.""" + data: dict[str, Any] = { + DATA_BRIDGES: [bridge.dict() for bridge in self.bridges.values()], + DATA_LISTENERS: [listener.dict() for listener in self.listeners.values()], + DATA_SENSORS: [sensor.dict() for sensor in self.sensors.values()], + } + if self.user_preferences: + data[DATA_USER_PREFERENCES] = self.user_preferences.dict() + return data + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Notion as a config entry.""" @@ -56,13 +162,14 @@ 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(hass=hass, entry=entry) tasks = { - "bridges": client.bridge.async_all(), - "sensors": client.sensor.async_all(), - "tasks": client.task.async_all(), + DATA_BRIDGES: client.bridge.async_all(), + DATA_LISTENERS: client.sensor.async_listeners(), + DATA_SENSORS: client.sensor.async_all(), + DATA_USER_PREFERENCES: client.user.async_preferences(), } results = await asyncio.gather(*tasks.values(), return_exceptions=True) @@ -82,11 +189,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"There was an unknown error while updating {attr}: {result}" ) from result - for item in result: - if attr == "bridges" and item["id"] not in data["bridges"]: - # If a new bridge is discovered, register it: - _async_register_new_bridge(hass, item, entry) - data[attr][item["id"]] = item + data.update_data_from_response(result) return data @@ -102,6 +205,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 +251,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, entry: ConfigEntry, bridge: Bridge ) -> 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,36 +311,37 @@ 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 ) + @property + def listener(self) -> Listener: + """Return the listener related to this entity.""" + return self.coordinator.data.listeners[self._listener_id] + @callback def _async_update_bridge_id(self) -> None: """Update the entity's bridge ID if it has changed. 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 @@ -218,21 +350,9 @@ class NotionEntity(CoordinatorEntity): this_device.id, via_device_id=bridge_device.id ) - @callback - def _async_update_from_latest_data(self) -> None: - """Update the entity from the latest data.""" - raise NotImplementedError - @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() - - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - self._async_update_from_latest_data() + super()._handle_coordinator_update() diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index f5d40b2a9d..f70af18c3e 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, @@ -11,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import NotionEntity @@ -26,21 +28,23 @@ 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 class NotionBinarySensorDescriptionMixin: """Define an entity description mixin for binary and regular sensors.""" - on_state: Literal["alarm", "critical", "leak", "not_missing", "open"] + on_state: Literal["alarm", "leak", "low", "not_missing", "open"] @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, - on_state="critical", + listener_kind=ListenerKind.BATTERY, + on_state="low", ), 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]) ] ) @@ -140,17 +146,10 @@ class NotionBinarySensor(NotionEntity, BinarySensorEntity): entity_description: NotionBinarySensorDescription - @callback - def _async_update_from_latest_data(self) -> None: - """Fetch new state data for the sensor.""" - task = self.coordinator.data["tasks"][self._task_id] - - if "value" in task["status"]: - state = task["status"]["value"] - elif task["status"].get("insights", {}).get("primary"): - state = task["status"]["insights"]["primary"]["to_state"] - else: - LOGGER.warning("Unknown data payload: %s", task["status"]) - state = None - - self._attr_is_on = self.entity_description.on_state == state + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if not self.listener.insights.primary.value: + LOGGER.warning("Unknown listener structure: %s", self.listener.dict()) + return False + return self.listener.insights.primary.value == self.entity_description.on_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..86b8476001 100644 --- a/homeassistant/components/notion/diagnostics.py +++ b/homeassistant/components/notion/diagnostics.py @@ -9,12 +9,14 @@ 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" CONF_HARDWARE_ID = "hardware_id" CONF_LAST_BRIDGE_HARDWARE_ID = "last_bridge_hardware_id" CONF_TITLE = "title" +CONF_USER_ID = "user_id" TO_REDACT = { CONF_DEVICE_KEY, @@ -26,6 +28,7 @@ TO_REDACT = { CONF_TITLE, CONF_UNIQUE_ID, CONF_USERNAME, + CONF_USER_ID, } @@ -33,9 +36,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..168899c38e 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.05.4"] } 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..e6ff3eaab6 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, @@ -7,19 +11,27 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import NotionEntity -from .const import DOMAIN, LOGGER, SENSOR_TEMPERATURE +from .const import DOMAIN, 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]) ] ) @@ -51,16 +63,24 @@ async def async_setup_entry( class NotionSensor(NotionEntity, SensorEntity): """Define a Notion sensor.""" - @callback - def _async_update_from_latest_data(self) -> None: - """Fetch new state data for the sensor.""" - task = self.coordinator.data["tasks"][self._task_id] + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the sensor.""" + if self.listener.listener_kind == ListenerKind.TEMPERATURE: + if not self.coordinator.data.user_preferences: + return None + if self.coordinator.data.user_preferences.celsius_enabled: + return UnitOfTemperature.CELSIUS + return UnitOfTemperature.FAHRENHEIT + return None - if task["task_type"] == SENSOR_TEMPERATURE: - self._attr_native_value = round(float(task["status"]["value"]), 1) - else: - LOGGER.error( - "Unknown task type: %s: %s", - self.coordinator.data["sensors"][self._sensor_id], - task["task_type"], - ) + @property + def native_value(self) -> str | None: + """Return the value reported by the sensor. + + The Notion API only returns a localized string for temperature (e.g. "70°"); we + simply remove the degree symbol: + """ + if not self.listener.status_localized: + return None + return self.listener.status_localized.state[:-1] diff --git a/homeassistant/components/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py index 6ebbccc446..7106b48778 100644 --- a/homeassistant/components/nsw_fuel_station/sensor.py +++ b/homeassistant/components/nsw_fuel_station/sensor.py @@ -117,7 +117,7 @@ class StationPriceSensor( return prices.get((self._station_id, self._fuel_type)) @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, int | str]: """Return the state attributes of the device.""" return { ATTR_STATION_ID: self._station_id, diff --git a/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py b/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py index 3eb598ffd3..28e056e29f 100644 --- a/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py +++ b/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py @@ -177,7 +177,7 @@ class NswRuralFireServiceFeedEntityManager: class NswRuralFireServiceLocationEvent(GeolocationEvent): - """This represents an external event with NSW Rural Fire Service data.""" + """Represents an external event with NSW Rural Fire Service data.""" _attr_should_poll = False _attr_source = SOURCE diff --git a/homeassistant/components/nuheat/manifest.json b/homeassistant/components/nuheat/manifest.json index 91b0a9eb19..cda1e9b02d 100644 --- a/homeassistant/components/nuheat/manifest.json +++ b/homeassistant/components/nuheat/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nuheat", "iot_class": "cloud_polling", "loggers": ["nuheat"], - "requirements": ["nuheat==1.0.0"] + "requirements": ["nuheat==1.0.1"] } diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 3a75c10333..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, entity_registry +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.""" @@ -78,7 +196,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Device registration for the bridge info = bridge.info() bridge_id = parse_id(info["ids"]["hardwareId"]) - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_reg.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, bridge_id)}, @@ -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) @@ -150,7 +309,7 @@ class NukiCoordinator(DataUpdateCoordinator[None]): except RequestException as err: raise UpdateFailed(f"Error communicating with Bridge: {err}") from err - ent_reg = entity_registry.async_get(self.hass) + ent_reg = er.async_get(self.hass) for event, device_ids in events.items(): for device_id in device_ids: entity_id = ent_reg.async_get_entity_id( @@ -215,7 +374,7 @@ class NukiEntity(CoordinatorEntity[NukiCoordinator], Generic[_NukiDeviceT]): "identifiers": {(DOMAIN, parse_id(self._nuki_device.nuki_id))}, "name": self._nuki_device.name, "manufacturer": "Nuki Home Solutions GmbH", - "model": self._nuki_device.device_type_str.capitalize(), + "model": self._nuki_device.device_model_str.capitalize(), "sw_version": self._nuki_device.firmware_version, "via_device": (DOMAIN, self.coordinator.bridge_id), } diff --git a/homeassistant/components/nuki/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 ac69f97a9b..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_*" @@ -11,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nuki", "iot_class": "local_polling", "loggers": ["pynuki"], - "requirements": ["pynuki==1.6.0"] + "requirements": ["pynuki==1.6.1"] } 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/number/__init__.py b/homeassistant/components/number/__init__.py index e090a21a23..2ad63c75e0 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -127,10 +127,11 @@ class NumberEntityDescription(EntityDescription): device_class: NumberDeviceClass | None = None max_value: None = None min_value: None = None + mode: NumberMode | None = None native_max_value: float | None = None native_min_value: float | None = None - native_unit_of_measurement: str | None = None native_step: float | None = None + native_unit_of_measurement: str | None = None step: None = None unit_of_measurement: None = None # Type override, use native_unit_of_measurement @@ -193,7 +194,7 @@ class NumberEntity(Entity): _attr_device_class: NumberDeviceClass | None _attr_max_value: None _attr_min_value: None - _attr_mode: NumberMode = NumberMode.AUTO + _attr_mode: NumberMode _attr_state: None = None _attr_step: None _attr_unit_of_measurement: None # Subclasses of NumberEntity should not set this @@ -201,8 +202,8 @@ class NumberEntity(Entity): _attr_native_max_value: float _attr_native_min_value: float _attr_native_step: float - _attr_native_value: float | None = None _attr_native_unit_of_measurement: str | None + _attr_native_value: float | None = None _deprecated_number_entity_reported = False _number_option_unit_of_measurement: str | None = None @@ -357,7 +358,14 @@ class NumberEntity(Entity): @property def mode(self) -> NumberMode: """Return the mode of the entity.""" - return self._attr_mode + if hasattr(self, "_attr_mode"): + return self._attr_mode + if ( + hasattr(self, "entity_description") + and self.entity_description.mode is not None + ): + return self.entity_description.mode + return NumberMode.AUTO @property @final diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 91c1306c22..b0542aa588 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.backports.enum import StrEnum from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, @@ -127,6 +128,15 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` """ + ENERGY_STORAGE = "energy_storage" + """Stored energy. + + Use this device class for sensors measuring stored energy, for example the amount + of electric energy currently stored in a battery or the capacity of a battery. + + Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + """ + FREQUENCY = "frequency" """Frequency. @@ -300,6 +310,12 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `µg/m³` """ + VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" + """Ratio of VOC. + + Unit of measurement: `ppm`, `ppb` + """ + VOLTAGE = "voltage" """Voltage. @@ -315,6 +331,18 @@ class NumberDeviceClass(StrEnum): USCS/imperial units are currently assumed to be US volumes) """ + VOLUME_STORAGE = "volume_storage" + """Generic stored volume. + + Use this device class for sensors measuring stored volume, for example the amount + of fuel in a fuel tank. + + Unit of measurement: `VOLUME_*` units + - SI / metric: `mL`, `L`, `m³` + - USCS / imperial: `ft³`, `CCF`, `fl. oz.`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be US volumes) + """ + WATER = "water" """Water. @@ -365,6 +393,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.DATA_SIZE: set(UnitOfInformation), NumberDeviceClass.DISTANCE: set(UnitOfLength), NumberDeviceClass.ENERGY: set(UnitOfEnergy), + NumberDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), NumberDeviceClass.FREQUENCY: set(UnitOfFrequency), NumberDeviceClass.GAS: { UnitOfVolume.CENTUM_CUBIC_FEET, @@ -399,8 +428,13 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: { CONCENTRATION_MICROGRAMS_PER_CUBIC_METER }, + NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: { + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + }, NumberDeviceClass.VOLTAGE: set(UnitOfElectricPotential), NumberDeviceClass.VOLUME: set(UnitOfVolume), + NumberDeviceClass.VOLUME_STORAGE: set(UnitOfVolume), NumberDeviceClass.WATER: { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, diff --git a/homeassistant/components/number/device_action.py b/homeassistant/components/number/device_action.py index e4311f50dd..971f8d5a51 100644 --- a/homeassistant/components/number/device_action.py +++ b/homeassistant/components/number/device_action.py @@ -11,7 +11,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -32,11 +32,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Number.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions: list[dict[str, str]] = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 77ba7e7a91..46db471305 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -4,5 +4,155 @@ "action_type": { "set_value": "Set value for {entity_name}" } + }, + "entity_component": { + "_": { + "name": "[%key:component::number::title%]", + "state_attributes": { + "max": { + "name": "Maximum" + }, + "min": { + "name": "Minimum" + }, + "mode": { + "name": "Mode", + "state": { + "auto": "Automatic", + "box": "Box", + "slider": "Slider" + } + }, + "step": { + "name": "Step" + } + } + }, + "apparent_power": { + "name": "[%key:component::sensor::entity_component::apparent_power::name%]" + }, + "aqi": { + "name": "[%key:component::sensor::entity_component::aqi::name%]" + }, + "atmospheric_pressure": { + "name": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]" + }, + "battery": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + }, + "carbon_dioxide": { + "name": "[%key:component::sensor::entity_component::carbon_dioxide::name%]" + }, + "carbon_monoxide": { + "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" + }, + "current": { + "name": "[%key:component::sensor::entity_component::current::name%]" + }, + "data_rate": { + "name": "[%key:component::sensor::entity_component::data_rate::name%]" + }, + "distance": { + "name": "[%key:component::sensor::entity_component::distance::name%]" + }, + "energy": { + "name": "[%key:component::sensor::entity_component::energy::name%]" + }, + "energy_storage": { + "name": "[%key:component::sensor::entity_component::energy_storage::name%]" + }, + "frequency": { + "name": "[%key:component::sensor::entity_component::frequency::name%]" + }, + "gas": { + "name": "[%key:component::sensor::entity_component::gas::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "illuminance": { + "name": "[%key:component::sensor::entity_component::illuminance::name%]" + }, + "irradiance": { + "name": "[%key:component::sensor::entity_component::irradiance::name%]" + }, + "moisture": { + "name": "[%key:component::sensor::entity_component::moisture::name%]" + }, + "nitrogen_dioxide": { + "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" + }, + "nitrogen_monoxide": { + "name": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]" + }, + "nitrous_oxide": { + "name": "[%key:component::sensor::entity_component::nitrous_oxide::name%]" + }, + "ozone": { + "name": "[%key:component::sensor::entity_component::ozone::name%]" + }, + "pm1": { + "name": "[%key:component::sensor::entity_component::pm1::name%]" + }, + "pm10": { + "name": "[%key:component::sensor::entity_component::pm10::name%]" + }, + "pm25": { + "name": "[%key:component::sensor::entity_component::pm25::name%]" + }, + "power_factor": { + "name": "[%key:component::sensor::entity_component::power_factor::name%]" + }, + "power": { + "name": "[%key:component::sensor::entity_component::power::name%]" + }, + "precipitation": { + "name": "[%key:component::sensor::entity_component::precipitation::name%]" + }, + "precipitation_intensity": { + "name": "[%key:component::sensor::entity_component::precipitation_intensity::name%]" + }, + "pressure": { + "name": "[%key:component::sensor::entity_component::pressure::name%]" + }, + "reactive_power": { + "name": "[%key:component::sensor::entity_component::reactive_power::name%]" + }, + "signal_strength": { + "name": "[%key:component::sensor::entity_component::signal_strength::name%]" + }, + "sound_pressure": { + "name": "[%key:component::sensor::entity_component::sound_pressure::name%]" + }, + "speed": { + "name": "[%key:component::sensor::entity_component::speed::name%]" + }, + "sulphur_dioxide": { + "name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "volatile_organic_compounds": { + "name": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]" + }, + "voltage": { + "name": "[%key:component::sensor::entity_component::voltage::name%]" + }, + "volume": { + "name": "[%key:component::sensor::entity_component::volume::name%]" + }, + "volume_storage": { + "name": "[%key:component::sensor::entity_component::volume_storage::name%]" + }, + "water": { + "name": "[%key:component::sensor::entity_component::water::name%]" + }, + "weight": { + "name": "[%key:component::sensor::entity_component::weight::name%]" + }, + "wind_speed": { + "name": "[%key:component::sensor::entity_component::wind_speed::name%]" + } } } diff --git a/homeassistant/components/number/websocket_api.py b/homeassistant/components/number/websocket_api.py index eca280d7d4..1ca61fd158 100644 --- a/homeassistant/components/number/websocket_api.py +++ b/homeassistant/components/number/websocket_api.py @@ -29,7 +29,10 @@ def ws_device_class_units( ) -> None: """Return supported units for a device class.""" device_class = msg["device_class"] - convertible_units = set() + convertible_units = [] if device_class in UNIT_CONVERTERS and device_class in DEVICE_CLASS_UNITS: - convertible_units = DEVICE_CLASS_UNITS[device_class] + convertible_units = sorted( + DEVICE_CLASS_UNITS[device_class], + key=lambda s: str.casefold(str(s)), + ) connection.send_result(msg["id"], {"units": convertible_units}) 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/sensor.py b/homeassistant/components/nws/sensor.py index 61f823de8e..79a4294449 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -195,9 +195,12 @@ class NWSSensor(CoordinatorEntity[NwsDataUpdateCoordinator], SensorEntity): @property def native_value(self) -> float | None: """Return the state.""" - value = self._nws.observation.get(self.entity_description.key) - if value is None: + if ( + not (observation := self._nws.observation) + or (value := observation.get(self.entity_description.key)) is None + ): return None + # Set alias to unit property -> prevent unnecessary hasattr calls unit_of_measurement = self.native_unit_of_measurement if unit_of_measurement == UnitOfSpeed.MILES_PER_HOUR: 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 8e65423b73..12cb9e25f8 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -1 +1,46 @@ """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 .connectivity import ObihaiConnection +from .const import LOGGER, PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + 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/button.py b/homeassistant/components/obihai/button.py new file mode 100644 index 0000000000..0b84d40f4d --- /dev/null +++ b/homeassistant/components/obihai/button.py @@ -0,0 +1,59 @@ +"""Obihai button module.""" + +from __future__ import annotations + +from pyobihai import PyObihai + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_platform + +from .connectivity import ObihaiConnection +from .const import OBIHAI + +BUTTON_DESCRIPTION = ButtonEntityDescription( + key="reboot", + name=f"{OBIHAI} Reboot", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: + """Set up the Obihai sensor entries.""" + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + host = entry.data[CONF_HOST] + requester = ObihaiConnection(host, username, password) + + await hass.async_add_executor_job(requester.update) + buttons = [ObihaiButton(requester.pyobihai, requester.serial)] + async_add_entities(buttons, update_before_add=True) + + +class ObihaiButton(ButtonEntity): + """Obihai Reboot button.""" + + entity_description = BUTTON_DESCRIPTION + + def __init__(self, pyobihai: PyObihai, serial: str) -> None: + """Initialize monitor sensor.""" + self._pyobihai = pyobihai + self._attr_unique_id = f"{serial}-reboot" + + def press(self) -> None: + """Press button.""" + + if not self._pyobihai.call_reboot(): + raise HomeAssistantError("Reboot failed!") diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py new file mode 100644 index 0000000000..6216fe0b97 --- /dev/null +++ b/homeassistant/components/obihai/config_flow.py @@ -0,0 +1,162 @@ +"""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 +from homeassistant.data_entry_flow import FlowResult + +from .connectivity import validate_auth +from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required( + CONF_USERNAME, + default=DEFAULT_USERNAME, + ): str, + vol.Required( + CONF_PASSWORD, + default=DEFAULT_PASSWORD, + ): str, + } +) + + +async def async_validate_creds( + hass: HomeAssistant, user_input: dict[str, Any] +) -> PyObihai | None: + """Manage Obihai options.""" + + 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 = 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: + try: + ip = await self.hass.async_add_executor_job( + gethostbyname, user_input[CONF_HOST] + ) + except gaierror: + errors["base"] = "cannot_connect" + + 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=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.""" + + try: + _ = await self.hass.async_add_executor_job(gethostbyname, config[CONF_HOST]) + except gaierror: + return self.async_abort(reason="cannot_connect") + + if pyobihai := await async_validate_creds(self.hass, config): + device_mac = await self.hass.async_add_executor_job(pyobihai.get_device_mac) + await self.async_set_unique_id(device_mac) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=config.get(CONF_NAME, config[CONF_HOST]), + data={ + CONF_HOST: config[CONF_HOST], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_USERNAME: config[CONF_USERNAME], + }, + ) + + return self.async_abort(reason="invalid_auth") diff --git a/homeassistant/components/obihai/connectivity.py b/homeassistant/components/obihai/connectivity.py new file mode 100644 index 0000000000..071390f1ad --- /dev/null +++ b/homeassistant/components/obihai/connectivity.py @@ -0,0 +1,71 @@ +"""Support for Obihai Connectivity.""" + +from __future__ import annotations + +from pyobihai import PyObihai + +from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, LOGGER + + +def get_pyobihai( + host: str, + username: str, + password: str, +) -> PyObihai: + """Retrieve an authenticated PyObihai.""" + + return PyObihai(host, username, password) + + +def validate_auth( + host: str, + username: str, + password: str, +) -> 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 None + + return obi + + +class ObihaiConnection: + """Contains a list of Obihai Sensors.""" + + def __init__( + self, + host: str, + username: str = DEFAULT_USERNAME, + password: str = DEFAULT_PASSWORD, + ) -> None: + """Store configuration.""" + self.sensors: list = [] + self.host = host + self.username = username + self.password = password + self.serial: str + self.services: list = [] + self.line_services: list = [] + self.call_direction: list = [] + self.pyobihai: PyObihai = None + + 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) + + if not self.pyobihai.check_account(): + return False + + self.serial = self.pyobihai.get_device_serial() + self.services = self.pyobihai.get_state() + self.line_services = self.pyobihai.get_line_state() + self.call_direction = self.pyobihai.get_call_direction() + + return True diff --git a/homeassistant/components/obihai/const.py b/homeassistant/components/obihai/const.py new file mode 100644 index 0000000000..764534d479 --- /dev/null +++ b/homeassistant/components/obihai/const.py @@ -0,0 +1,15 @@ +"""Constants for the Obihai integration.""" + +import logging +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "obihai" +DEFAULT_USERNAME = "admin" +DEFAULT_PASSWORD = "admin" +OBIHAI = "Obihai" + +LOGGER = logging.getLogger(__package__) + +PLATFORMS: Final = [Platform.BUTTON, Platform.SENSOR] diff --git a/homeassistant/components/obihai/manifest.json b/homeassistant/components/obihai/manifest.json index 867d7d875d..2907f3f179 100644 --- a/homeassistant/components/obihai/manifest.json +++ b/homeassistant/components/obihai/manifest.json @@ -1,7 +1,13 @@ { "domain": "obihai", "name": "Obihai", - "codeowners": ["@dshokouhi"], + "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/sensor.py b/homeassistant/components/obihai/sensor.py index cff4e6232e..61411b0ce2 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import timedelta -import logging from pyobihai import PyObihai import voluptuous as vol @@ -12,20 +11,19 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) +from .connectivity import ObihaiConnection +from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN, OBIHAI SCAN_INTERVAL = timedelta(seconds=5) -OBIHAI = "Obihai" -DEFAULT_USERNAME = "admin" -DEFAULT_PASSWORD = "admin" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -35,52 +33,64 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +# DEPRECATED +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 Obihai sensor platform.""" + ir.async_create_issue( + hass, + DOMAIN, + "manual_migration", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="manual_migration", + ) - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - host = config[CONF_HOST] + 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 Obihai sensor entries.""" + + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + host = entry.data[CONF_HOST] + requester = ObihaiConnection(host, username, password) + + await hass.async_add_executor_job(requester.update) sensors = [] + for key in requester.services: + sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key)) - pyobihai = PyObihai(host, username, password) + if requester.line_services is not None: + for key in requester.line_services: + sensors.append( + ObihaiServiceSensors(requester.pyobihai, requester.serial, key) + ) - login = pyobihai.check_account() - if not login: - _LOGGER.error("Invalid credentials") - return + for key in requester.call_direction: + sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key)) - serial = pyobihai.get_device_serial() - - services = pyobihai.get_state() - - line_services = pyobihai.get_line_state() - - call_direction = pyobihai.get_call_direction() - - for key in services: - sensors.append(ObihaiServiceSensors(pyobihai, serial, key)) - - if line_services is not None: - for key in line_services: - sensors.append(ObihaiServiceSensors(pyobihai, serial, key)) - - for key in call_direction: - sensors.append(ObihaiServiceSensors(pyobihai, serial, key)) - - add_entities(sensors) + async_add_entities(sensors, update_before_add=True) class ObihaiServiceSensors(SensorEntity): """Get the status of each Obihai Lines.""" - def __init__(self, pyobihai, serial, service_name): + def __init__(self, pyobihai: PyObihai, serial: str, service_name: str) -> None: """Initialize monitor sensor.""" self._service_name = service_name self._state = None @@ -148,6 +158,10 @@ class ObihaiServiceSensors(SensorEntity): def update(self) -> None: """Update the sensor.""" + if not self._pyobihai.check_account(): + self._state = None + return + services = self._pyobihai.get_state() if self._service_name in services: diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json new file mode 100644 index 0000000000..1b91cd6065 --- /dev/null +++ b/homeassistant/components/obihai/strings.json @@ -0,0 +1,34 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "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%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "manual_migration": { + "title": "Obihai YAML configuration is being removed", + "description": "Configuration of the Obihai platform in YAML is deprecated and will be removed in Home Assistant 2023.6; Your existing configuration has been imported into the UI automatically and can be safely removed from your configuration.yaml file." + } + } +} diff --git a/homeassistant/components/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/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index a12f2bc798..4d6d0f6965 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -14,6 +14,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, ) from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall @@ -394,7 +395,9 @@ class OnkyoDevice(MediaPlayerEntity): source = self._reverse_mapping[source] self.command(f"input-selector {source}") - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play radio station by preset number.""" source = self._reverse_mapping[self._attr_source] if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES: diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 77a5c6d1bd..a834a8f2df 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -1,5 +1,12 @@ """The ONVIF integration.""" +import asyncio +from http import HTTPStatus +import logging + +from httpx import RequestError from onvif.exceptions import ONVIFAuthError, ONVIFError, ONVIFTimeoutError +from onvif.util import is_auth_error, stringify_onvif_error +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,11 +18,19 @@ 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 .const import ( + CONF_ENABLE_WEBHOOKS, + CONF_SNAPSHOT_AUTH, + DEFAULT_ARGUMENTS, + DEFAULT_ENABLE_WEBHOOKS, + DOMAIN, +) from .device import ONVIFDevice +LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up ONVIF from a config entry.""" @@ -27,27 +42,62 @@ 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}: {stringify_onvif_error(err)}" + ) from err + except TransportError as err: + await device.device.close() + stringified_onvif_error = stringify_onvif_error(err) + if err.status_code in ( + HTTPStatus.UNAUTHORIZED.value, + HTTPStatus.FORBIDDEN.value, + ): + raise ConfigEntryAuthFailed( + f"Auth Failed: {stringified_onvif_error}" + ) from err + raise ConfigEntryNotReady( + f"Could not setup camera {device.device.host}:{device.device.port}: {stringified_onvif_error}" + ) from err + except asyncio.CancelledError as err: + # After https://github.com/agronholm/anyio/issues/374 is resolved + # this may be able to be removed + await device.device.close() + raise ConfigEntryNotReady(f"Setup was unexpectedly canceled: {err}") from err if not device.available: raise ConfigEntryNotReady() - if not entry.data.get(CONF_SNAPSHOT_AUTH): - await async_populate_snapshot_auth(hass, device, entry) - hass.data[DOMAIN][entry.unique_id] = device - 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 +109,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): @@ -100,6 +149,7 @@ async def async_populate_options(hass, entry): options = { CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS, CONF_RTSP_TRANSPORT: next(iter(RTSP_TRANSPORTS)), + CONF_ENABLE_WEBHOOKS: DEFAULT_ENABLE_WEBHOOKS, } hass.config_entries.async_update_entry(entry, options=options) 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/button.py b/homeassistant/components/onvif/button.py index cacf317f7b..f263821a46 100644 --- a/homeassistant/components/onvif/button.py +++ b/homeassistant/components/onvif/button.py @@ -34,7 +34,7 @@ class RebootButton(ONVIFBaseEntity, ButtonEntity): async def async_press(self) -> None: """Send out a SystemReboot command.""" - device_mgmt = self.device.device.create_devicemgmt_service() + device_mgmt = await self.device.device.create_devicemgmt_service() await device_mgmt.SystemReboot() 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..020649db87 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -1,11 +1,12 @@ """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 +from onvif.util import is_auth_error, stringify_onvif_error import voluptuous as vol from wsdiscovery.discovery import ThreadedWSDiscovery as WSDiscovery from wsdiscovery.scope import Scope @@ -13,6 +14,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,8 +29,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, + CONF_ENABLE_WEBHOOKS, + DEFAULT_ARGUMENTS, + DEFAULT_ENABLE_WEBHOOKS, + DEFAULT_PORT, + DOMAIN, + GET_CAPABILITIES_EXCEPTIONS, + LOGGER, +) from .device import get_device CONF_MANUAL_INPUT = "Manually configure ONVIF device" @@ -74,6 +87,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a ONVIF config flow.""" VERSION = 1 + _reauth_entry: config_entries.ConfigEntry @staticmethod @callback @@ -101,6 +115,72 @@ 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") + + username = (user_input or {}).get(CONF_USERNAME) or entry.data[CONF_USERNAME] + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME, default=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 +228,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 +260,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) @@ -195,8 +281,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: await device.update_xaddrs() - device_mgmt = device.create_devicemgmt_service() - + device_mgmt = await 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 +295,75 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except Fault as fault: if "not implemented" not in fault.message: raise fault - LOGGER.debug( - ( - "Couldn't get network interfaces from ONVIF deivice '%s'." - " Error: %s" - ), + "%s: Could not get network interfaces: %s", self.onvif_config[CONF_NAME], - fault, + stringify_onvif_error(fault), ) - # If no network interfaces are exposed, fallback to serial number if not self.device_id: device_info = await device_mgmt.GetDeviceInformation() self.device_id = device_info.SerialNumber if not self.device_id: - return self.async_abort(reason="no_mac") - - await self.async_set_unique_id(self.device_id, raise_on_progress=False) - self._abort_if_unique_id_configured( - updates={ - CONF_HOST: self.onvif_config[CONF_HOST], - CONF_PORT: self.onvif_config[CONF_PORT], - CONF_NAME: self.onvif_config[CONF_NAME], - } - ) + raise AbortFlow(reason="no_mac") + if configure_unique_id: + await self.async_set_unique_id(self.device_id, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.onvif_config[CONF_HOST], + CONF_PORT: self.onvif_config[CONF_PORT], + CONF_NAME: self.onvif_config[CONF_NAME], + CONF_USERNAME: self.onvif_config[CONF_USERNAME], + CONF_PASSWORD: self.onvif_config[CONF_PASSWORD], + } + ) # Verify there is an H264 profile - media_service = device.create_media_service() + media_service = await device.create_media_service() profiles = await media_service.GetProfiles() - h264 = any( + except AttributeError: # Likely an empty document or 404 from the wrong port + LOGGER.debug( + "%s: No ONVIF service found at %s:%s", + self.onvif_config[CONF_NAME], + self.onvif_config[CONF_HOST], + self.onvif_config[CONF_PORT], + exc_info=True, + ) + return {CONF_PORT: "no_onvif_service"}, {} + except Fault as err: + stringified_error = stringify_onvif_error(err) + description_placeholders = {"error": stringified_error} + if is_auth_error(err): + LOGGER.debug( + "%s: Could not authenticate with camera: %s", + self.onvif_config[CONF_NAME], + stringified_error, + ) + return {CONF_PASSWORD: "auth_failed"}, description_placeholders + LOGGER.debug( + "%s: Could not determine camera capabilities: %s", + self.onvif_config[CONF_NAME], + stringified_error, + exc_info=True, + ) + return {"base": "onvif_error"}, description_placeholders + except GET_CAPABILITIES_EXCEPTIONS as err: + LOGGER.debug( + "%s: Could not determine camera capabilities: %s", + self.onvif_config[CONF_NAME], + stringify_onvif_error(err), + exc_info=True, + ) + return {"base": "onvif_error"}, {"error": stringify_onvif_error(err)} + else: + if not any( profile.VideoEncoderConfiguration and profile.VideoEncoderConfiguration.Encoding == "H264" for profile in profiles - ) - - if not h264: - return self.async_abort(reason="no_h264") - - title = f"{self.onvif_config[CONF_NAME]} - {self.device_id}" - return self.async_create_entry(title=title, data=self.onvif_config) - - except ONVIFError as err: - LOGGER.error( - "Couldn't setup ONVIF device '%s'. Error: %s", - self.onvif_config[CONF_NAME], - err, - ) - return self.async_abort(reason="onvif_error") - + ): + raise AbortFlow(reason="no_h264") + return {}, {} finally: await device.close() @@ -285,6 +389,12 @@ class OnvifOptionsFlowHandler(config_entries.OptionsFlow): CONF_USE_WALLCLOCK_AS_TIMESTAMPS, self.config_entry.options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False), ) + self.options[CONF_ENABLE_WEBHOOKS] = user_input.get( + CONF_ENABLE_WEBHOOKS, + self.config_entry.options.get( + CONF_ENABLE_WEBHOOKS, DEFAULT_ENABLE_WEBHOOKS + ), + ) return self.async_create_entry(title="", data=self.options) advanced_options = {} @@ -313,6 +423,12 @@ class OnvifOptionsFlowHandler(config_entries.OptionsFlow): CONF_RTSP_TRANSPORT, next(iter(RTSP_TRANSPORTS)) ), ): vol.In(RTSP_TRANSPORTS), + vol.Optional( + CONF_ENABLE_WEBHOOKS, + default=self.config_entry.options.get( + CONF_ENABLE_WEBHOOKS, DEFAULT_ENABLE_WEBHOOKS + ), + ): bool, **advanced_options, } ), diff --git a/homeassistant/components/onvif/const.py b/homeassistant/components/onvif/const.py index 410088f28d..8d95ef484b 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" @@ -10,6 +14,8 @@ DEFAULT_ARGUMENTS = "-pred 1" CONF_DEVICE_ID = "deviceid" CONF_SNAPSHOT_AUTH = "snapshot_auth" +CONF_ENABLE_WEBHOOKS = "enable_webhooks" +DEFAULT_ENABLE_WEBHOOKS = True ATTR_PAN = "pan" ATTR_TILT = "tilt" @@ -36,3 +42,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..e470b3c700 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -6,12 +6,13 @@ from contextlib import suppress import datetime as dt import os import time +from typing import Any from httpx import RequestError import onvif from onvif import ONVIFCamera from onvif.exceptions import ONVIFError -from zeep.exceptions import Fault, XMLParseError +from zeep.exceptions import Fault, TransportError, XMLParseError, XMLSyntaxError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -20,13 +21,17 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_USERNAME, + Platform, ) from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util from .const import ( ABSOLUTE_MOVE, + CONF_ENABLE_WEBHOOKS, CONTINUOUS_MOVE, + DEFAULT_ENABLE_WEBHOOKS, + GET_CAPABILITIES_EXCEPTIONS, GOTOPRESET_MOVE, LOGGER, PAN_FACTOR, @@ -49,15 +54,25 @@ class ONVIFDevice: """Initialize the device.""" self.hass: HomeAssistant = hass self.config_entry: ConfigEntry = config_entry + self._original_options = dict(config_entry.options) self.available: bool = True self.info: DeviceInfo = DeviceInfo() self.capabilities: Capabilities = Capabilities() + self.onvif_capabilities: dict[str, Any] | None = None self.profiles: list[Profile] = [] self.max_resolution: int = 0 + self.platforms: list[Platform] = [] self._dt_diff_seconds: float = 0 + async def _async_update_listener( + self, hass: HomeAssistant, entry: ConfigEntry + ) -> None: + """Handle options update.""" + if self._original_options != entry.options: + hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) + @property def name(self) -> str: """Return the name of this device.""" @@ -83,7 +98,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 +109,65 @@ class ONVIFDevice: ) # Get all device info - try: - await self.device.update_xaddrs() - await self.async_check_date_and_time() + await self.device.update_xaddrs() + LOGGER.debug("%s: xaddrs = %s", self.name, self.device.xaddrs) - # Create event manager - assert self.config_entry.unique_id - self.events = EventManager( - self.hass, self.device, self.config_entry.unique_id - ) + # Get device capabilities + self.onvif_capabilities = await self.device.get_capabilities() - # 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) + await self.async_check_date_and_time() - # No camera profiles to add - if not self.profiles: - return False + # Create event manager + assert self.config_entry.unique_id + self.events = EventManager(self.hass, self.device, self.config_entry, self.name) - if self.capabilities.ptz: - self.device.create_ptz_service() + # Fetch basic device info and capabilities + self.info = await self.async_get_device_info() + LOGGER.debug("%s: camera info = %s", self.name, self.info) - # 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 + # + # We need to check capabilities before profiles, because we need the data + # from capabilities to determine profiles correctly. + # + # We no longer initialize events in capabilities to avoid the problem + # where cameras become slow to respond for a bit after starting events, and + # instead we start events last and than update capabilities. + # + LOGGER.debug("%s: fetching initial capabilities", self.name) + self.capabilities = await self.async_get_capabilities() - return True + LOGGER.debug("%s: fetching profiles", self.name) + 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: + raise ONVIFError("No camera profiles found") + + if self.capabilities.ptz: + LOGGER.debug("%s: creating PTZ service", self.name) + await 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" + ) + + # Start events last since some cameras become slow to respond + # for a bit after starting events + LOGGER.debug("%s: starting events", self.name) + self.capabilities.events = await self.async_start_events() + LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities) + + # Bind the listener to the ONVIFDevice instance since + # async_update_listener only creates a weak reference to the listener + # and we need to make sure it doesn't get garbage collected since only + # the ONVIFDevice instance is stored in hass.data + self.config_entry.async_on_unload( + self.config_entry.add_update_listener(self._async_update_listener) + ) async def async_stop(self, event=None): """Shut it all down.""" @@ -154,7 +177,7 @@ class ONVIFDevice: async def async_manually_set_date_and_time(self) -> None: """Set Date and Time Manually using SetSystemDateAndTime command.""" - device_mgmt = self.device.create_devicemgmt_service() + device_mgmt = await self.device.create_devicemgmt_service() # Retrieve DateTime object from camera to use as template for Set operation device_time = await device_mgmt.GetSystemDateAndTime() @@ -168,91 +191,136 @@ 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 + # Some cameras don't support setting the timezone and will throw an IndexError + # if we try to set it. If we get an error, try again without the timezone. + except (IndexError, 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") - device_mgmt = self.device.create_devicemgmt_service() + LOGGER.debug("%s: Setting up the ONVIF device management service", self.name) + device_mgmt = await self.device.create_devicemgmt_service() + system_date = dt_util.utcnow() - 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() - if not device_time: - LOGGER.debug( - """Couldn't get device '%s' date/time. - GetSystemDateAndTime() return null/empty""", - self.name, - ) - return - - LOGGER.debug("Device time: %s", device_time) - - tzone = dt_util.DEFAULT_TIME_ZONE - cdate = device_time.LocalDateTime - if device_time.UTCDateTime: - tzone = dt_util.UTC - cdate = device_time.UTCDateTime - elif device_time.TimeZone: - 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") - else: - cam_date = dt.datetime( - cdate.Date.Year, - cdate.Date.Month, - cdate.Date.Day, - cdate.Time.Hour, - cdate.Time.Minute, - cdate.Time.Second, - 0, - tzone, - ) - - cam_date_utc = cam_date.astimezone(dt_util.UTC) - - LOGGER.debug( - "Device date/time: %s | System date/time: %s", - cam_date_utc, - system_date, - ) - - dt_diff = cam_date - system_date - self._dt_diff_seconds = dt_diff.total_seconds() - - if self._dt_diff_seconds > 5: - LOGGER.warning( - ( - "The date/time on %s (UTC) is '%s', " - "which is different from the system '%s', " - "this could lead to authentication issues" - ), - self.name, - cam_date_utc, - system_date, - ) - if device_time.DateTimeType == "Manual": - # Set Date and Time ourselves if Date and Time is set manually in the camera. - await self.async_manually_set_date_and_time() except RequestError as err: LOGGER.warning( "Couldn't get device '%s' date/time. Error: %s", self.name, err ) + return + + if not device_time: + LOGGER.debug( + """Couldn't get device '%s' date/time. + GetSystemDateAndTime() return null/empty""", + self.name, + ) + return + + LOGGER.debug("%s: Device time: %s", self.name, device_time) + + tzone = dt_util.DEFAULT_TIME_ZONE + cdate = device_time.LocalDateTime + if device_time.UTCDateTime: + tzone = dt_util.UTC + cdate = device_time.UTCDateTime + elif device_time.TimeZone: + tzone = dt_util.get_time_zone(device_time.TimeZone.TZ) or tzone + + if cdate is None: + LOGGER.warning("%s: Could not retrieve date/time on this camera", self.name) + return + + cam_date = dt.datetime( + cdate.Date.Year, + cdate.Date.Month, + cdate.Date.Day, + cdate.Time.Hour, + cdate.Time.Minute, + cdate.Time.Second, + 0, + tzone, + ) + + cam_date_utc = cam_date.astimezone(dt_util.UTC) + + LOGGER.debug( + "%s: Device date/time: %s | System date/time: %s", + self.name, + cam_date_utc, + system_date, + ) + + dt_diff = cam_date - system_date + self._dt_diff_seconds = dt_diff.total_seconds() + + # It could be off either direction, so we need to check the absolute value + if abs(self._dt_diff_seconds) < 5: + return + + LOGGER.warning( + ( + "The date/time on %s (UTC) is '%s', " + "which is different from the system '%s', " + "this could lead to authentication issues" + ), + self.name, + cam_date_utc, + system_date, + ) + + if device_time.DateTimeType != "Manual": + return + + # Set Date and Time ourselves if Date and Time is set manually in the camera. + try: + await self.async_manually_set_date_and_time() + except (RequestError, TransportError, IndexError, Fault): + LOGGER.warning("%s: Could not sync date/time on this camera", self.name) async def async_get_device_info(self) -> DeviceInfo: """Obtain information about this device.""" - device_mgmt = self.device.create_devicemgmt_service() - device_info = await device_mgmt.GetDeviceInformation() + device_mgmt = await self.device.create_devicemgmt_service() + manufacturer = None + model = None + firmware_version = None + serial_number = None + try: + device_info = await device_mgmt.GetDeviceInformation() + except (XMLParseError, XMLSyntaxError, TransportError) as ex: + # Some cameras have invalid UTF-8 in their device information (TransportError) + # and others have completely invalid XML (XMLParseError, XMLSyntaxError) + LOGGER.warning("%s: Failed to fetch device information: %s", self.name, ex) + else: + manufacturer = device_info.Manufacturer + model = device_info.Model + firmware_version = device_info.FirmwareVersion + serial_number = device_info.SerialNumber # Grab the last MAC address for backwards compatibility mac = None @@ -272,41 +340,61 @@ class ONVIFDevice: ) return DeviceInfo( - device_info.Manufacturer, - device_info.Model, - device_info.FirmwareVersion, - device_info.SerialNumber, + manufacturer, + model, + firmware_version, + serial_number, mac, ) async def async_get_capabilities(self): """Obtain information about the available services on the device.""" snapshot = False - with suppress(ONVIFError, Fault, RequestError): - media_service = self.device.create_media_service() + with suppress(*GET_CAPABILITIES_EXCEPTIONS): + media_service = await 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): - self.device.create_imaging_service() + with suppress(*GET_CAPABILITIES_EXCEPTIONS): + await self.device.create_imaging_service() imaging = True - return Capabilities(snapshot, pullpoint, ptz, imaging) + return Capabilities(snapshot=snapshot, ptz=ptz, imaging=imaging) + + async def async_start_events(self): + """Start the event handler.""" + with suppress(*GET_CAPABILITIES_EXCEPTIONS, XMLParseError): + onvif_capabilities = self.onvif_capabilities or {} + pull_point_support = (onvif_capabilities.get("Events") or {}).get( + "WSPullPointSupport" + ) + LOGGER.debug("%s: WSPullPointSupport: %s", self.name, pull_point_support) + return await self.events.async_start( + pull_point_support is not False, + self.config_entry.options.get( + CONF_ENABLE_WEBHOOKS, DEFAULT_ENABLE_WEBHOOKS + ), + ) + + return False async def async_get_profiles(self) -> list[Profile]: """Obtain media profiles for this device.""" - media_service = self.device.create_media_service() - result = await media_service.GetProfiles() + media_service = await self.device.create_media_service() + LOGGER.debug("%s: xaddr for media_service: %s", self.name, media_service.xaddr) + try: + result = await media_service.GetProfiles() + except GET_CAPABILITIES_EXCEPTIONS: + LOGGER.debug( + "%s: Could not get profiles from ONVIF device", self.name, exc_info=True + ) + raise profiles: list[Profile] = [] if not isinstance(result, list): @@ -345,10 +433,10 @@ class ONVIFDevice: ) try: - ptz_service = self.device.create_ptz_service() + ptz_service = await 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 = [] @@ -364,7 +452,7 @@ class ONVIFDevice: async def async_get_stream_uri(self, profile: Profile) -> str: """Get the stream URI for a specified profile.""" - media_service = self.device.create_media_service() + media_service = await self.device.create_media_service() req = media_service.create_type("GetStreamUri") req.ProfileToken = profile.token req.StreamSetup = { @@ -391,7 +479,7 @@ class ONVIFDevice: LOGGER.warning("PTZ actions are not supported on device '%s'", self.name) return - ptz_service = self.device.create_ptz_service() + ptz_service = await self.device.create_ptz_service() pan_val = distance * PAN_FACTOR.get(pan, 0) tilt_val = distance * TILT_FACTOR.get(tilt, 0) @@ -513,7 +601,7 @@ class ONVIFDevice: LOGGER.warning("PTZ actions are not supported on device '%s'", self.name) return - ptz_service = self.device.create_ptz_service() + ptz_service = await self.device.create_ptz_service() LOGGER.debug( "Running Aux Command | Cmd = %s", @@ -544,7 +632,7 @@ class ONVIFDevice: ) return - imaging_service = self.device.create_imaging_service() + imaging_service = await self.device.create_imaging_service() LOGGER.debug("Setting Imaging Setting | Settings = %s", settings) try: diff --git a/homeassistant/components/onvif/diagnostics.py b/homeassistant/components/onvif/diagnostics.py index eb818f53a3..a802aed5e8 100644 --- a/homeassistant/components/onvif/diagnostics.py +++ b/homeassistant/components/onvif/diagnostics.py @@ -27,6 +27,14 @@ async def async_get_config_entry_diagnostics( "info": asdict(device.info), "capabilities": asdict(device.capabilities), "profiles": [asdict(profile) for profile in device.profiles], + "services": { + str(key): service.url for key, service in device.device.services.items() + }, + "xaddrs": device.device.xaddrs, + } + 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..bb42e63c52 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -3,61 +3,109 @@ from __future__ import annotations 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 onvif import ONVIFCamera, ONVIFService -from zeep.exceptions import Fault, XMLParseError +from aiohttp.web import Request +from httpx import RemoteProtocolError, RequestError, TransportError +from onvif import ONVIFCamera +from onvif.client import ( + NotificationManager, + PullPointManager as ONVIFPullPointManager, + retry_connection_error, +) +from onvif.exceptions import ONVIFError +from onvif.util import stringify_onvif_error +from zeep.exceptions import Fault, ValidationError, XMLParseError -from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback +from homeassistant.components import webhook +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.event import async_call_later -from homeassistant.util import dt as dt_util +from homeassistant.helpers.network import NoURLAvailableError, get_url -from .const import LOGGER -from .models import Event +from .const import DOMAIN, LOGGER +from .models import Event, PullPointManagerState, WebHookManagerState from .parsers import PARSERS -UNHANDLED_TOPICS: set[str] = set() -SUBSCRIPTION_ERRORS = ( - Fault, - asyncio.TimeoutError, - TransportError, -) +# Topics in this list are ignored because we do not want to create +# entities for them. +UNHANDLED_TOPICS: set[str] = {"tns1:MediaControl/VideoEncoderConfiguration"} + +SUBSCRIPTION_ERRORS = (Fault, asyncio.TimeoutError, TransportError) +CREATE_ERRORS = (ONVIFError, Fault, RequestError, XMLParseError, ValidationError) +SET_SYNCHRONIZATION_POINT_ERRORS = (*SUBSCRIPTION_ERRORS, TypeError) +UNSUBSCRIBE_ERRORS = (XMLParseError, *SUBSCRIPTION_ERRORS) +RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS) +# +# We only keep the subscription alive for 10 minutes, and will keep +# renewing it every 8 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=10) + +# SUBSCRIPTION_RENEW_INTERVAL Must be less than the +# overall timeout of 90 * (SUBSCRIPTION_ATTEMPTS) 2 = 180 seconds +# +# We use 8 minutes between renewals to make sure we never hit the +# 10 minute limit even if the first renewal attempt fails +SUBSCRIPTION_RENEW_INTERVAL = 8 * 60 + +# The number of attempts to make when creating or renewing a subscription +SUBSCRIPTION_ATTEMPTS = 2 + +# The time to wait before trying to restart the subscription if it fails +SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR = 60 + +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 + ) @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() - + # We always have to listen for events or we will never + # know which sensors to create. In practice we always have + # a listener anyways since binary_sensor and sensor will + # create a listener when they are created. self._listeners.append(update_callback) @callback @@ -73,186 +121,540 @@ 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 - - async def async_start(self) -> bool: + async def async_start(self, try_pullpoint: bool, try_webhook: bool) -> 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 = ( + try_pullpoint and await self.pullpoint_manager.async_start() + ) + events_via_webhook = try_webhook and await self.webhook_manager.async_start() + return events_via_webhook or event_via_pull_point async def async_stop(self) -> None: """Unsubscribe from events.""" self._listeners = [] - self.started = False + await self.pullpoint_manager.async_stop() + await self.webhook_manager.async_stop() - if not self._subscription: - return - - 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_manager: ONVIFPullPointManager | None = None + + self._cancel_pull_messages: CALLBACK_TYPE | None = None + self._pull_messages_job = HassJob( + self._async_background_pull_messages_or_reschedule, + f"{self._name}: pull messages", + ) + self._pull_messages_task: asyncio.Task[None] | None = None + + 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 + self.async_schedule_pull_messages() + return True + + @callback + def async_pause(self) -> None: + """Pause pullpoint subscription.""" + LOGGER.debug("%s: Pausing PullPoint manager", self._name) + self.state = PullPointManagerState.PAUSED + # Cancel the renew job so we don't renew the subscription + # and stop pulling messages. + self.async_cancel_pull_messages() + if self._pullpoint_manager: + self._pullpoint_manager.pause() + # We do not unsubscribe from the pullpoint subscription and instead + # let the subscription expire since some cameras will terminate all + # subscriptions if we unsubscribe which will break the webhook. + + @callback + def async_resume(self) -> None: + """Resume pullpoint subscription.""" + LOGGER.debug("%s: Resuming PullPoint manager", self._name) + self.state = PullPointManagerState.STARTED + if self._pullpoint_manager: + self._pullpoint_manager.resume() + self.async_schedule_pull_messages() + + 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: + 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 + return True + + async def _async_cancel_and_unsubscribe(self) -> None: + """Cancel and unsubscribe from PullPoint.""" + self.async_cancel_pull_messages() + if self._pull_messages_task: + self._pull_messages_task.cancel() + await self._async_unsubscribe_pullpoint() + + @retry_connection_error(SUBSCRIPTION_ATTEMPTS) + async def _async_create_pullpoint_subscription(self) -> None: + """Create pullpoint subscription.""" + self._pullpoint_manager = await self._device.create_pullpoint_manager( + SUBSCRIPTION_TIME, self._event_manager.async_mark_events_stale + ) + await self._pullpoint_manager.set_synchronization_point() + + async def _async_unsubscribe_pullpoint(self) -> None: + """Unsubscribe the pullpoint subscription.""" + if not self._pullpoint_manager or self._pullpoint_manager.closed: + return + LOGGER.debug("%s: Unsubscribing from PullPoint", self._name) + try: + await self._pullpoint_manager.shutdown() + 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_manager = None + + async def _async_pull_messages(self) -> None: + """Pull messages from device.""" + if self._pullpoint_manager is None: + return + service = self._pullpoint_manager.get_service() + LOGGER.debug( + "%s: Pulling PullPoint messages timeout=%s limit=%s", + self._name, + PULLPOINT_POLL_TIME, + PULLPOINT_MESSAGE_LIMIT, + ) + next_pull_delay = None + response = None + try: + if self._hass.is_running: + response = await service.PullMessages( + { + "MessageLimit": PULLPOINT_MESSAGE_LIMIT, + "Timeout": PULLPOINT_POLL_TIME, + } + ) + else: + LOGGER.debug( + "%s: PullPoint skipped because Home Assistant is not running yet", + self._name, + ) + 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), + ) + except Fault 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. + self._pullpoint_manager.resume() + except (XMLParseError, RequestError, TimeoutError, TransportError) as err: + LOGGER.debug( + "%s: PullPoint subscription encountered an unexpected error and will be retried " + "(this is normal for some cameras): %s", + self._name, + stringify_onvif_error(err), + ) + # Avoid renewing the subscription too often since it causes problems + # for some cameras, mainly the Tapo ones. + next_pull_delay = SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR + finally: + self.async_schedule_pull_messages(next_pull_delay) + + 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 state is %s (likely due to working webhook), skipping PullPoint messages", + self._name, + self.state, + ) + return + + if not response: + return + + # Parse response + event_manager = self._event_manager + 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) + + @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_manager: + 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 + ) + + @callback + def _async_background_pull_messages_or_reschedule( + self, _now: dt.datetime | None = None + ) -> None: + """Pull messages from device in the background.""" + if self._pull_messages_task and not self._pull_messages_task.done(): + LOGGER.debug( + "%s: PullPoint message pull is already in process, skipping pull", + self._name, + ) + self.async_schedule_pull_messages() + return + self._pull_messages_task = self._hass.async_create_background_task( + self._async_pull_messages(), + f"{self._name} background 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 + config_entry = event_manager.config_entry + + self._old_webhook_unique_id = f"{DOMAIN}_{config_entry.entry_id}" + # Some cameras have a limit on the length of the webhook URL + # so we use a shorter unique ID for the webhook. + unique_id = config_entry.unique_id + assert unique_id is not None + webhook_id = format_mac(unique_id).replace(":", "").lower() + self._webhook_unique_id = f"{DOMAIN}{webhook_id}" + self._name = event_manager.name + + self._webhook_url: str | None = None + + self._notification_manager: NotificationManager | None = None + + 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 + await self._async_unsubscribe_webhook() + self._async_unregister_webhook() + + @retry_connection_error(SUBSCRIPTION_ATTEMPTS) + async def _async_create_webhook_subscription(self) -> None: + """Create webhook subscription.""" + LOGGER.debug( + "%s: Creating webhook subscription with URL: %s", + self._name, + self._webhook_url, + ) + try: + self._notification_manager = await self._device.create_notification_manager( + address=self._webhook_url, + interval=SUBSCRIPTION_TIME, + subscription_lost_callback=self._event_manager.async_mark_events_stale, + ) + except ValidationError as err: + # This should only happen if there is a problem with the webhook URL + # that is causing it to not be well formed. + LOGGER.exception( + "%s: validation error while creating webhook subscription: %s", + self._name, + err, + ) + raise + await self._notification_manager.set_synchronization_point() + LOGGER.debug( + "%s: Webhook subscription created with URL: %s", + self._name, + self._webhook_url, + ) + + async def _async_start_webhook(self) -> bool: + """Start webhook.""" + try: + 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 + return True + + @callback + def _async_register_webhook(self) -> None: + """Register the webhook for motion events.""" + LOGGER.debug("%s: Registering webhook: %s", self._name, self._webhook_unique_id) + + try: + base_url = get_url(self._hass, prefer_external=False) + except NoURLAvailableError: + try: + base_url = get_url(self._hass, prefer_external=True) + except NoURLAvailableError: + return + + webhook_id = self._webhook_unique_id + self._async_unregister_webhook() + webhook.async_register( + self._hass, DOMAIN, webhook_id, webhook_id, self._async_handle_webhook + ) + webhook_path = webhook.async_generate_path(webhook_id) + self._webhook_url = f"{base_url}{webhook_path}" + LOGGER.debug("%s: Registered webhook: %s", self._name, webhook_id) + + @callback + def _async_unregister_webhook(self): + """Unregister the webhook for motion events.""" + LOGGER.debug( + "%s: Unregistering webhook %s", self._name, self._webhook_unique_id + ) + webhook.async_unregister(self._hass, self._old_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() + + async def _async_unsubscribe_webhook(self) -> None: + """Unsubscribe from the webhook.""" + if not self._notification_manager or self._notification_manager.closed: + return + LOGGER.debug("%s: Unsubscribing from webhook", self._name) + try: + await self._notification_manager.shutdown() + 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._notification_manager = None diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index db9f76189d..a749e59be4 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==3.1.7", "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..8e6e3e2586 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -15,6 +15,19 @@ PARSERS: Registry[ str, Callable[[str, Any], Coroutine[Any, Any, Event | None]] ] = Registry() +VIDEO_SOURCE_MAPPING = { + "vsconf": "VideoSourceToken", +} + + +def _normalize_video_source(source: str) -> str: + """Normalize video source. + + Some cameras do not set the VideoSourceToken correctly so we get duplicate + sensors, so we need to normalize it to the correct value. + """ + return VIDEO_SOURCE_MAPPING.get(source, source) + def local_datetime_or_none(value: str) -> datetime.datetime | None: """Convert strings to datetimes, if invalid, return None.""" @@ -188,7 +201,7 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: rule = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value + video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": video_analytics = source.Value if source.Name == "Rule": @@ -220,7 +233,7 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: rule = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value + video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": video_analytics = source.Value if source.Name == "Rule": @@ -251,7 +264,7 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: rule = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value + video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": video_analytics = source.Value if source.Name == "Rule": @@ -282,7 +295,7 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: rule = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value + video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": video_analytics = source.Value if source.Name == "Rule": @@ -301,6 +314,131 @@ 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 = _normalize_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 = _normalize_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 = _normalize_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 = _normalize_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:RuleEngine/MyRuleDetector/Visitor") +# pylint: disable=protected-access +async def async_parse_visitor_detector(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/MyRuleDetector/Visitor + """ + try: + video_source = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}", + "Visitor Detection", + "binary_sensor", + "occupancy", + 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 +649,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 = _normalize_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..8e989f1dfa 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -2,12 +2,16 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "onvif_error": "Error setting up ONVIF device. Check logs for more information.", "no_h264": "There were no H264 streams available. Check the profile configuration on your device.", - "no_mac": "Could not configure unique ID for ONVIF device." + "no_mac": "Could not configure unique ID for ONVIF device.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { + "onvif_error": "Error setting up ONVIF device: {error}. Check logs for more information.", + "auth_failed": "Could not authenticate: {error}", + "no_onvif_service": "No ONVIF service found. Check that the port number is correct.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { @@ -40,6 +44,14 @@ "data": { "include": "Create camera entity" } + }, + "reauth_confirm": { + "title": "Reauthenticate the ONVIF device", + "description": "Some devices will reject authentication if the time is out of sync by more than 5 seconds. If authentication is unsuccessful, verify the time on the device is correct and try again.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } } }, @@ -49,7 +61,8 @@ "data": { "extra_arguments": "Extra FFMPEG arguments", "rtsp_transport": "RTSP transport mechanism", - "use_wallclock_as_timestamps": "Use wall clock as timestamps" + "use_wallclock_as_timestamps": "Use wall clock as timestamps", + "enable_webhooks": "Enable Webhooks" }, "title": "ONVIF Device Options" } 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..5077a65e0b --- /dev/null +++ b/homeassistant/components/onvif/util.py @@ -0,0 +1,54 @@ +"""ONVIF util.""" +from __future__ import annotations + +from typing import Any + +from zeep.exceptions import Fault + + +def extract_subcodes_as_strings(subcodes: Any) -> list[str]: + """Stringify ONVIF subcodes.""" + if isinstance(subcodes, list): + return [code.text if hasattr(code, "text") else str(code) for code in subcodes] + return [str(subcodes)] + + +def stringify_onvif_error(error: Exception) -> str: + """Stringify ONVIF error.""" + if isinstance(error, Fault): + message = error.message + if error.detail is not None: # checking true is deprecated + # Detail may be a bytes object, so we need to convert it to string + if isinstance(error.detail, bytes): + detail = error.detail.decode("utf-8", "replace") + else: + detail = str(error.detail) + message += ": " + detail + if error.code is not None: # checking true is deprecated + message += f" (code:{error.code})" + if error.subcodes is not None: # checking true is deprecated + 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 f"Device sent empty error with type {type(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 41ff6bcf9c..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, 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(area_registry.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/opencv/image_processing.py b/homeassistant/components/opencv/image_processing.py index 7c3a881edf..41738100ca 100644 --- a/homeassistant/components/opencv/image_processing.py +++ b/homeassistant/components/opencv/image_processing.py @@ -104,7 +104,7 @@ def setup_platform( if not CV2_IMPORTED: _LOGGER.error( "No OpenCV library found! Install or compile for your system " - "following instructions here: http://opencv.org/releases.html" + "following instructions here: https://opencv.org/?s=releases" ) return diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index ef30d37bdc..b625d9976d 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -211,7 +211,7 @@ class OpenhomeDevice(MediaPlayerEntity): @catch_request_errors() async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the play_media command to the media player.""" if media_source.is_media_source_id(media_id): @@ -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/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 4c96f2575f..03e242f40b 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -38,7 +38,8 @@ DEFAULT_ALTITUDE = 0 EVENT_OPENSKY_ENTRY = f"{DOMAIN}_entry" EVENT_OPENSKY_EXIT = f"{DOMAIN}_exit" -SCAN_INTERVAL = timedelta(seconds=12) # opensky public limit is 10 seconds +# OpenSky free user has 400 credits, with 4 credits per API call. 100/24 = ~4 requests per hour +SCAN_INTERVAL = timedelta(minutes=15) OPENSKY_API_URL = "https://opensky-network.org/api/states/all" OPENSKY_API_FIELDS = [ diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 51071c9a0a..3efe911b27 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -3,6 +3,7 @@ import asyncio from datetime import date, datetime import logging +import async_timeout import pyotgw import pyotgw.vars as gw_vars from serial import SerialException @@ -112,10 +113,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.add_update_listener(options_updated) try: - await asyncio.wait_for( - gateway.connect_and_subscribe(), - timeout=CONNECTION_TIMEOUT, - ) + async with async_timeout.timeout(CONNECTION_TIMEOUT): + await gateway.connect_and_subscribe() except (asyncio.TimeoutError, ConnectionError, SerialException) as ex: await gateway.cleanup() raise ConfigEntryNotReady( @@ -143,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( { @@ -261,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 @@ -284,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, @@ -299,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, @@ -314,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, @@ -342,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 @@ -356,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 @@ -368,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 @@ -384,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 @@ -396,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 @@ -417,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] @@ -425,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/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index ed9b62ff49..87a5102165 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import async_timeout import pyotgw from pyotgw import vars as gw_vars from serial import SerialException @@ -68,10 +69,8 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT) try: - await asyncio.wait_for( - test_connection(), - timeout=CONNECTION_TIMEOUT, - ) + async with async_timeout.timeout(CONNECTION_TIMEOUT): + await test_connection() except asyncio.TimeoutError: return self._show_form({"base": "timeout_connect"}) except (ConnectionError, SerialException): diff --git a/homeassistant/components/opnsense/device_tracker.py b/homeassistant/components/opnsense/device_tracker.py index b5c75f1cc2..527856ed56 100644 --- a/homeassistant/components/opnsense/device_tracker.py +++ b/homeassistant/components/opnsense/device_tracker.py @@ -20,7 +20,7 @@ async def async_get_scanner( class OPNSenseDeviceScanner(DeviceScanner): - """This class queries a router running OPNsense.""" + """Class which queries a router running OPNsense.""" def __init__(self, client, interfaces): """Initialize the scanner.""" diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json index 37b043e543..adf72f5fe5 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -11,5 +11,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/oralb", "iot_class": "local_push", - "requirements": ["oralb-ble==0.17.5"] + "loggers": ["oralb_ble"], + "requirements": ["oralb-ble==0.17.6"] } diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py index 4a63fccb88..76104c7516 100644 --- a/homeassistant/components/oralb/sensor.py +++ b/homeassistant/components/oralb/sensor.py @@ -124,3 +124,20 @@ class OralBBluetoothSensorEntity( def native_value(self) -> str | int | None: """Return the native value.""" return self.processor.entity_data.get(self.entity_key) + + @property + def available(self) -> bool: + """Return True if entity is available. + + The sensor is only created when the device is seen. + + Since these are sleepy devices which stop broadcasting + when not in use, we can't rely on the last update time + so once we have seen the device we always return True. + """ + return True + + @property + def assumed_state(self) -> bool: + """Return True if the device is no longer broadcasting.""" + return not self.processor.available diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 19eaa55f00..5942d67b50 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -1,11 +1,9 @@ """The Open Thread Border Router integration.""" from __future__ import annotations -from collections.abc import Callable, Coroutine -import dataclasses -from functools import wraps -from typing import Any, Concatenate, ParamSpec, TypeVar +import asyncio +import aiohttp import python_otbr_api from homeassistant.components.thread import async_add_dataset @@ -17,37 +15,7 @@ from homeassistant.helpers.typing import ConfigType from . import websocket_api from .const import DOMAIN - -_R = TypeVar("_R") -_P = ParamSpec("_P") - - -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 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() +from .util import OTBRData, update_issues async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -60,13 +28,20 @@ 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 = await otbrdata.get_active_dataset_tlvs() - except HomeAssistantError as err: - raise ConfigEntryNotReady from err - if dataset: - await async_add_dataset(hass, entry.title, dataset.hex()) + dataset_tlvs = await otbrdata.get_active_dataset_tlvs() + except ( + HomeAssistantError, + aiohttp.ClientError, + asyncio.TimeoutError, + ) as err: + raise ConfigEntryNotReady("Unable to connect") from err + if 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 @@ -79,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 1d54084969..32842ad6cc 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -1,19 +1,24 @@ """Config flow for the Open Thread Border Router integration.""" from __future__ import annotations +import asyncio import logging +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 DOMAIN +from .const import DEFAULT_CHANNEL, DOMAIN +from .util import get_allowed_channel _LOGGER = logging.getLogger(__name__) @@ -23,15 +28,32 @@ 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: - if dataset := await async_get_preferred_dataset(self.hass): - await api.set_active_dataset_tlvs(bytes.fromhex(dataset)) + 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: + dataset = tlv_parser.parse_tlv(thread_dataset_tlv) + if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): + thread_dataset_channel = int(channel_str, base=16) + + if 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( + "not importing TLV with channel %s", thread_dataset_channel + ) await api.create_active_dataset( - python_otbr_api.OperationalDataSet(network_name="home-assistant") + python_otbr_api.OperationalDataSet( + channel=allowed_channel if allowed_channel else DEFAULT_CHANNEL, + network_name="home-assistant", + ) ) await api.set_enabled(True) @@ -47,8 +69,12 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: url = user_input[CONF_URL] try: - await self._connect_and_create_dataset(url) - except python_otbr_api.OTBRError: + await self._connect_and_set_dataset(url) + except ( + python_otbr_api.OTBRError, + aiohttp.ClientError, + asyncio.TimeoutError, + ): errors["base"] = "cannot_connect" else: await self.async_set_unique_id(DOMAIN) @@ -64,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/const.py b/homeassistant/components/otbr/const.py index 72884a198d..cc3e4a9e6c 100644 --- a/homeassistant/components/otbr/const.py +++ b/homeassistant/components/otbr/const.py @@ -1,3 +1,5 @@ """Constants for the Open Thread Border Router integration.""" DOMAIN = "otbr" + +DEFAULT_CHANNEL = 15 diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 7abf716cec..c10a2417dc 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -1,12 +1,12 @@ { "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", - "requirements": ["python-otbr-api==1.0.3"] + "requirements": ["python-otbr-api==1.0.9"] } diff --git a/homeassistant/components/otbr/strings.json b/homeassistant/components/otbr/strings.json index 58b32276ba..129cbec446 100644 --- a/homeassistant/components/otbr/strings.json +++ b/homeassistant/components/otbr/strings.json @@ -12,7 +12,17 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "issues": { + "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 a07819793b..2189df363b 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -1,35 +1,36 @@ """Websocket API for OTBR.""" -from typing import TYPE_CHECKING -from homeassistant.components.websocket_api import ( - ActiveConnection, - async_register_command, - async_response, - websocket_command, -) +import python_otbr_api +from python_otbr_api import tlv_parser +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.thread import async_add_dataset, async_get_dataset from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from .const import DOMAIN - -if TYPE_CHECKING: - from . import OTBRData +from .const import DEFAULT_CHANNEL, DOMAIN +from .util import OTBRData, get_allowed_channel, update_issues @callback def async_setup(hass: HomeAssistant) -> None: """Set up the OTBR Websocket API.""" - async_register_command(hass, websocket_info) + websocket_api.async_register_command(hass, websocket_info) + websocket_api.async_register_command(hass, websocket_create_network) + websocket_api.async_register_command(hass, websocket_get_extended_address) + websocket_api.async_register_command(hass, websocket_set_network) -@websocket_command( +@websocket_api.websocket_command( { "type": "otbr/info", } ) -@async_response +@websocket_api.require_admin +@websocket_api.async_response async def websocket_info( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """Get OTBR info.""" if DOMAIN not in hass.data: @@ -51,3 +52,147 @@ async def websocket_info( "active_dataset_tlvs": dataset.hex() if dataset else None, }, ) + + +@websocket_api.websocket_command( + { + "type": "otbr/create_network", + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_create_network( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Create a new Thread network.""" + if DOMAIN not in hass.data: + connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") + return + + data: OTBRData = hass.data[DOMAIN] + channel = await get_allowed_channel(hass, data.url) or DEFAULT_CHANNEL + + try: + await data.set_enabled(False) + except HomeAssistantError as exc: + connection.send_error(msg["id"], "set_enabled_failed", str(exc)) + return + + try: + await data.create_active_dataset( + python_otbr_api.OperationalDataSet( + channel=channel, network_name="home-assistant" + ) + ) + except HomeAssistantError as exc: + connection.send_error(msg["id"], "create_active_dataset_failed", str(exc)) + return + + try: + await data.set_enabled(True) + except HomeAssistantError as exc: + connection.send_error(msg["id"], "set_enabled_failed", str(exc)) + return + + try: + dataset_tlvs = await data.get_active_dataset_tlvs() + except HomeAssistantError as exc: + connection.send_error(msg["id"], "get_active_dataset_tlvs_failed", str(exc)) + return + if not dataset_tlvs: + connection.send_error(msg["id"], "get_active_dataset_tlvs_empty", "") + return + + await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) + + # Update repair issues + await update_issues(hass, data, dataset_tlvs) + + connection.send_result(msg["id"]) + + +@websocket_api.websocket_command( + { + "type": "otbr/set_network", + vol.Required("dataset_id"): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_set_network( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Set the Thread network to be used by the OTBR.""" + if DOMAIN not in hass.data: + connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") + return + + dataset_tlv = await async_get_dataset(hass, msg["dataset_id"]) + + if not dataset_tlv: + connection.send_error(msg["id"], "unknown_dataset", "Unknown dataset") + return + dataset = tlv_parser.parse_tlv(dataset_tlv) + if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): + thread_dataset_channel = int(channel_str, base=16) + + data: OTBRData = hass.data[DOMAIN] + allowed_channel = await get_allowed_channel(hass, data.url) + + 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 {allowed_channel}", + ) + return + + try: + await data.set_enabled(False) + except HomeAssistantError as exc: + connection.send_error(msg["id"], "set_enabled_failed", str(exc)) + return + + try: + await data.set_active_dataset_tlvs(bytes.fromhex(dataset_tlv)) + except HomeAssistantError as exc: + connection.send_error(msg["id"], "set_active_dataset_tlvs_failed", str(exc)) + return + + try: + await data.set_enabled(True) + except HomeAssistantError as exc: + 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"]) + + +@websocket_api.websocket_command( + { + "type": "otbr/get_extended_address", + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_extended_address( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Get extended address (EUI-64).""" + if DOMAIN not in hass.data: + connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") + return + + data: OTBRData = hass.data[DOMAIN] + + try: + extended_address = await data.get_extended_address() + except HomeAssistantError as exc: + connection.send_error(msg["id"], "get_extended_address_failed", str(exc)) + return + + connection.send_result(msg["id"], {"extended_address": extended_address.hex()}) 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/overkiz/button.py b/homeassistant/components/overkiz/button.py index 23f1558b22..8388e2c3b2 100644 --- a/homeassistant/components/overkiz/button.py +++ b/homeassistant/components/overkiz/button.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from pyoverkiz.enums import OverkizCommand from pyoverkiz.types import StateType as OverkizStateType from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -65,6 +66,11 @@ BUTTON_DESCRIPTIONS: list[OverkizButtonDescription] = [ name="My position", icon="mdi:star", ), + OverkizButtonDescription( + key=OverkizCommand.CYCLE, + name="Toggle", + icon="mdi:sync", + ), ] SUPPORTED_COMMANDS = { diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py index e70315e099..9d54c04422 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate_entities/__init__.py @@ -10,6 +10,7 @@ from .atlantic_heat_recovery_ventilation import AtlanticHeatRecoveryVentilation from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl from .somfy_thermostat import SomfyThermostat +from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater, @@ -21,4 +22,5 @@ WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, + UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, } diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py index bb09543605..46a330c97c 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import UnitOfTemperature +from ..const import DOMAIN from ..entity import OverkizEntity PRESET_COMFORT1 = "comfort-1" @@ -47,6 +48,7 @@ class AtlanticElectricalHeater(OverkizEntity, ClimateEntity): _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] _attr_supported_features = ClimateEntityFeature.PRESET_MODE _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py index 3b02523ec2..d79d2fca68 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -16,6 +16,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -70,6 +71,7 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( _attr_supported_features = ( ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) + _attr_translation_key = DOMAIN def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py index c9885ada42..c8e4920a11 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py @@ -14,6 +14,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -43,6 +44,7 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py b/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py index 7c469518f8..1da7c48f9e 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py @@ -13,6 +13,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import UnitOfTemperature +from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -49,6 +50,7 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity): _attr_supported_features = ( ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.FAN_MODE ) + _attr_translation_key = DOMAIN def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py index e90edad113..b6835d93eb 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py @@ -17,6 +17,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -78,6 +79,7 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py index c3fd7cd964..aaae64e045 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -15,19 +15,17 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity PRESET_FREEZE = "freeze" PRESET_NIGHT = "night" -STATE_DEROGATION_ACTIVE = "active" -STATE_DEROGATION_INACTIVE = "inactive" - OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { - STATE_DEROGATION_ACTIVE: HVACMode.HEAT, - STATE_DEROGATION_INACTIVE: HVACMode.AUTO, + OverkizCommandParam.ACTIVE: HVACMode.HEAT, + OverkizCommandParam.INACTIVE: HVACMode.AUTO, } HVAC_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODES.items()} @@ -60,6 +58,8 @@ class SomfyThermostat(OverkizEntity, ClimateEntity): ) _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] + _attr_translation_key = DOMAIN + # Both min and max temp values have been retrieved from the Somfy Application. _attr_min_temp = 15.0 _attr_max_temp = 26.0 diff --git a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py new file mode 100644 index 0000000000..fdaf0d61f1 --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py @@ -0,0 +1,137 @@ +"""Support for ValveHeatingTemperatureInterface.""" +from __future__ import annotations + +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, + UnitOfTemperature, +) +from homeassistant.const import ATTR_TEMPERATURE + +from ..const import DOMAIN +from ..coordinator import OverkizDataUpdateCoordinator +from ..entity import OverkizEntity + +PRESET_MANUAL = "manual" +PRESET_FROST_PROTECTION = "frost_protection" + +OVERKIZ_TO_HVAC_ACTION: dict[str, HVACAction] = { + OverkizCommandParam.OPEN: HVACAction.HEATING, + OverkizCommandParam.CLOSED: HVACAction.IDLE, +} + +OVERKIZ_TO_PRESET_MODE: dict[str, str] = { + OverkizCommandParam.GEOFENCING_MODE: PRESET_NONE, + OverkizCommandParam.SUDDEN_DROP_MODE: PRESET_NONE, + OverkizCommandParam.AWAY: PRESET_AWAY, + OverkizCommandParam.COMFORT: PRESET_COMFORT, + OverkizCommandParam.ECO: PRESET_ECO, + OverkizCommandParam.FROSTPROTECTION: PRESET_FROST_PROTECTION, + OverkizCommandParam.MANUAL: PRESET_MANUAL, +} +PRESET_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODE.items()} + +TEMPERATURE_SENSOR_DEVICE_INDEX = 2 + + +class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity): + """Representation of Valve Heating Temperature Interface device.""" + + _attr_hvac_mode = HVACMode.HEAT + _attr_hvac_modes = [HVACMode.HEAT] + _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + self.temperature_device = self.executor.linked_device( + TEMPERATURE_SENSOR_DEVICE_INDEX + ) + + self._attr_min_temp = cast( + float, self.executor.select_state(OverkizState.CORE_MIN_SETPOINT) + ) + self._attr_max_temp = cast( + float, self.executor.select_state(OverkizState.CORE_MAX_SETPOINT) + ) + + @property + def hvac_action(self) -> str: + """Return the current running hvac operation.""" + return OVERKIZ_TO_HVAC_ACTION[ + cast(str, self.executor.select_state(OverkizState.CORE_OPEN_CLOSED_VALVE)) + ] + + @property + def target_temperature(self) -> float: + """Return the temperature.""" + return cast( + float, self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE) + ) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]: + return temperature.value_as_float + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + temperature = kwargs[ATTR_TEMPERATURE] + + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION, + float(temperature), + OverkizCommandParam.FURTHER_NOTICE, + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + return + + @property + def preset_mode(self) -> str: + """Return the current preset mode, e.g., home, away, temp.""" + return OVERKIZ_TO_PRESET_MODE[ + cast( + str, self.executor.select_state(OverkizState.IO_DEROGATION_HEATING_MODE) + ) + ] + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + + # If we want to switch to manual mode via a preset, we need to pass in a temperature + # Manual mode will be on automatically if an user sets a temperature + if preset_mode == PRESET_MANUAL: + if current_temperature := self.current_temperature: + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION, + current_temperature, + OverkizCommandParam.FURTHER_NOTICE, + ) + else: + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION, + PRESET_MODE_TO_OVERKIZ[preset_mode], + OverkizCommandParam.FURTHER_NOTICE, + ) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index d176a13754..0db01a2d84 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -5,9 +5,28 @@ from datetime import timedelta import logging from typing import Final -from pyoverkiz.enums import OverkizCommandParam, UIClass, UIWidget +from pyoverkiz.enums import MeasuredValueType, OverkizCommandParam, UIClass, UIWidget -from homeassistant.const import Platform +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + DEGREE, + LIGHT_LUX, + PERCENTAGE, + Platform, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfIrradiance, + UnitOfLength, + UnitOfPower, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfTime, + UnitOfVolume, + UnitOfVolumeFlowRate, +) DOMAIN: Final = "overkiz" LOGGER: logging.Logger = logging.getLogger(__package__) @@ -83,6 +102,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH, # widgetName, uiClass is ExteriorHeatingSystem (not supported) UIWidget.TSKALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) + UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) } # Map Overkiz camelCase to Home Assistant snake_case for translation @@ -97,3 +117,42 @@ OVERKIZ_STATE_TO_TRANSLATION: dict[str, str] = { OverkizCommandParam.SFC: "sfc", OverkizCommandParam.UPS: "ups", } + +OVERKIZ_UNIT_TO_HA: dict[str, str] = { + MeasuredValueType.ABSOLUTE_VALUE: "", + MeasuredValueType.ANGLE_IN_DEGREES: DEGREE, + MeasuredValueType.ANGULAR_SPEED_IN_DEGREES_PER_SECOND: f"{DEGREE}/{UnitOfTime.SECONDS}", + MeasuredValueType.ELECTRICAL_ENERGY_IN_KWH: UnitOfEnergy.KILO_WATT_HOUR, + MeasuredValueType.ELECTRICAL_ENERGY_IN_WH: UnitOfEnergy.WATT_HOUR, + MeasuredValueType.ELECTRICAL_POWER_IN_KW: UnitOfPower.KILO_WATT, + MeasuredValueType.ELECTRICAL_POWER_IN_W: UnitOfPower.WATT, + MeasuredValueType.ELECTRIC_CURRENT_IN_AMPERE: UnitOfElectricCurrent.AMPERE, + MeasuredValueType.ELECTRIC_CURRENT_IN_MILLI_AMPERE: UnitOfElectricCurrent.MILLIAMPERE, + MeasuredValueType.ENERGY_IN_CAL: "cal", + MeasuredValueType.ENERGY_IN_KCAL: "kcal", + MeasuredValueType.FLOW_IN_LITRE_PER_SECOND: f"{UnitOfVolume.LITERS}/{UnitOfTime.SECONDS}", + MeasuredValueType.FLOW_IN_METER_CUBE_PER_HOUR: UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + MeasuredValueType.FLOW_IN_METER_CUBE_PER_SECOND: f"{UnitOfVolume.CUBIC_METERS}/{UnitOfTime.SECONDS}", + MeasuredValueType.FOSSIL_ENERGY_IN_WH: UnitOfEnergy.WATT_HOUR, + MeasuredValueType.GRADIENT_IN_PERCENTAGE_PER_SECOND: f"{PERCENTAGE}/{UnitOfTime.SECONDS}", + MeasuredValueType.LENGTH_IN_METER: UnitOfLength.METERS, + MeasuredValueType.LINEAR_SPEED_IN_METER_PER_SECOND: UnitOfSpeed.METERS_PER_SECOND, + MeasuredValueType.LUMINANCE_IN_LUX: LIGHT_LUX, + MeasuredValueType.PARTS_PER_BILLION: CONCENTRATION_PARTS_PER_BILLION, + MeasuredValueType.PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION, + MeasuredValueType.PARTS_PER_QUADRILLION: "ppq", + MeasuredValueType.PARTS_PER_TRILLION: "ppt", + MeasuredValueType.POWER_PER_SQUARE_METER: UnitOfIrradiance.WATTS_PER_SQUARE_METER, + MeasuredValueType.PRESSURE_IN_HPA: UnitOfPressure.HPA, + MeasuredValueType.PRESSURE_IN_MILLI_BAR: UnitOfPressure.MBAR, + MeasuredValueType.RELATIVE_VALUE_IN_PERCENTAGE: PERCENTAGE, + MeasuredValueType.TEMPERATURE_IN_CELCIUS: UnitOfTemperature.CELSIUS, + MeasuredValueType.TEMPERATURE_IN_KELVIN: UnitOfTemperature.KELVIN, + MeasuredValueType.TIME_IN_SECOND: UnitOfTime.SECONDS, + # MeasuredValueType.VECTOR_COORDINATE: "", + MeasuredValueType.VOLTAGE_IN_MILLI_VOLT: UnitOfElectricPotential.MILLIVOLT, + MeasuredValueType.VOLTAGE_IN_VOLT: UnitOfElectricPotential.VOLT, + MeasuredValueType.VOLUME_IN_CUBIC_METER: UnitOfVolume.CUBIC_METERS, + MeasuredValueType.VOLUME_IN_GALLON: UnitOfVolume.GALLONS, + MeasuredValueType.VOLUME_IN_LITER: UnitOfVolume.LITERS, +} diff --git a/homeassistant/components/overkiz/cover_entities/generic_cover.py b/homeassistant/components/overkiz/cover_entities/generic_cover.py index 1bc108b531..06f257d416 100644 --- a/homeassistant/components/overkiz/cover_entities/generic_cover.py +++ b/homeassistant/components/overkiz/cover_entities/generic_cover.py @@ -27,13 +27,11 @@ COMMANDS_STOP_TILT: list[OverkizCommand] = [ COMMANDS_OPEN: list[OverkizCommand] = [ OverkizCommand.OPEN, OverkizCommand.UP, - OverkizCommand.CYCLE, ] COMMANDS_OPEN_TILT: list[OverkizCommand] = [OverkizCommand.OPEN_SLATS] COMMANDS_CLOSE: list[OverkizCommand] = [ OverkizCommand.CLOSE, OverkizCommand.DOWN, - OverkizCommand.CYCLE, ] COMMANDS_CLOSE_TILT: list[OverkizCommand] = [OverkizCommand.CLOSE_SLATS] diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 658f3f5c7f..dfd4a6c28e 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.7.3"], + "requirements": ["pyoverkiz==1.7.8"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 1e37d938cc..9aca0850b0 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -34,7 +34,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import HomeAssistantOverkizData -from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES, OVERKIZ_STATE_TO_TRANSLATION +from .const import ( + DOMAIN, + IGNORED_OVERKIZ_DEVICES, + OVERKIZ_STATE_TO_TRANSLATION, + OVERKIZ_UNIT_TO_HA, +) from .coordinator import OverkizDataUpdateCoordinator from .entity import OverkizDescriptiveEntity, OverkizEntity @@ -111,8 +116,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ key=OverkizState.IO_OUTLET_ENGINE, name="Outlet engine", icon="mdi:fan-chevron-down", - native_unit_of_measurement=UnitOfVolume.LITERS, - device_class=SensorDeviceClass.VOLUME, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, ), OverkizSensorDescription( @@ -473,6 +477,29 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): return state.value + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement.""" + if ( + not (default_unit := self.entity_description.native_unit_of_measurement) + or not (state := self.device.states.get(self.entity_description.key)) + or not state.value + ): + return default_unit + + attrs = self.device.attributes + if (unit := attrs[f"{state.name}MeasuredValueType"]) and ( + unit_value := unit.value_as_str + ): + return OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit) + + if (unit := attrs[OverkizAttribute.CORE_MEASURED_VALUE_TYPE]) and ( + unit_value := unit.value_as_str + ): + return OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit) + + return default_unit + class OverkizHomeKitSetupCodeSensor(OverkizEntity, SensorEntity): """Representation of an Overkiz HomeKit Setup Code.""" diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 5f4f3a0464..4140578012 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -28,6 +28,34 @@ } }, "entity": { + "climate": { + "overkiz": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "Auto", + "comfort-1": "Comfort 1", + "comfort-2": "Comfort 2", + "drying": "Drying", + "external": "External", + "freeze": "Freeze", + "frost_protection": "Frost protection", + "manual": "Manual", + "night": "Night", + "prog": "Prog" + } + }, + "fan_mode": { + "state": { + "away": "Away", + "bypass_boost": "Bypass boost", + "home_boost": "Home boost", + "kitchen_boost": "Kitchen boost" + } + } + } + } + }, "select": { "open_closed_pedestrian": { "state": { 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/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index f983d0f98d..a1fc632c2f 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -13,7 +13,7 @@ from homeassistant.const import ( ATTR_LONGITUDE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -26,7 +26,7 @@ async def async_setup_entry( ) -> None: """Set up OwnTracks based off an entry.""" # Restore previously loaded devices - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_ids = { identifier[1] for device in dev_reg.devices.values() diff --git a/homeassistant/components/p1_monitor/diagnostics.py b/homeassistant/components/p1_monitor/diagnostics.py index 29f48d47cd..b2668f060a 100644 --- a/homeassistant/components/p1_monitor/diagnostics.py +++ b/homeassistant/components/p1_monitor/diagnostics.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import asdict -from typing import Any +from typing import TYPE_CHECKING, Any, cast from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry @@ -18,6 +18,9 @@ from .const import ( SERVICE_WATERMETER, ) +if TYPE_CHECKING: + from _typeshed import DataclassInstance + TO_REDACT = { CONF_HOST, } @@ -42,6 +45,8 @@ async def async_get_config_entry_diagnostics( } if coordinator.has_water_meter: - data["data"]["watermeter"] = asdict(coordinator.data[SERVICE_WATERMETER]) + data["data"]["watermeter"] = asdict( + cast("DataclassInstance", coordinator.data[SERVICE_WATERMETER]) + ) return data diff --git a/homeassistant/components/panasonic_bluray/manifest.json b/homeassistant/components/panasonic_bluray/manifest.json index 78c132eafb..fa0202c087 100644 --- a/homeassistant/components/panasonic_bluray/manifest.json +++ b/homeassistant/components/panasonic_bluray/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/panasonic_bluray", "iot_class": "local_polling", "loggers": ["panacotta"], - "requirements": ["panacotta==0.1"] + "requirements": ["panacotta==0.2"] } diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index 14c440f0ec..5e2ed77233 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -185,7 +185,7 @@ class PanasonicVieraTVEntity(MediaPlayerEntity): await self._remote.async_send_key(Keys.rewind) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play media.""" if media_source.is_media_source_id(media_id): @@ -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 523d21aa69..fe6925b484 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -42,7 +42,7 @@ from homeassistant.core import ( from homeassistant.helpers import ( collection, config_validation as cv, - entity_registry, + entity_registry as er, service, ) from homeassistant.helpers.entity_component import EntityComponent @@ -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. @@ -226,19 +226,22 @@ class PersonStorageCollection(collection.StorageCollection): """Load the Storage collection.""" await super().async_load() self.hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, self._entity_registry_updated + er.EVENT_ENTITY_REGISTRY_UPDATED, + self._entity_registry_updated, + event_filter=self._entity_registry_filter, ) - async def _entity_registry_updated(self, event) -> None: + @callback + def _entity_registry_filter(self, event: Event) -> bool: + """Filter entity registry events.""" + return ( + event.data["action"] == "remove" + and split_entity_id(event.data[ATTR_ENTITY_ID])[0] == "device_tracker" + ) + + async def _entity_registry_updated(self, event: Event) -> None: """Handle entity registry updated.""" - if event.data["action"] != "remove": - return - entity_id = event.data[ATTR_ENTITY_ID] - - if split_entity_id(entity_id)[0] != "device_tracker": - return - for person in list(self.data.values()): if entity_id not in person[CONF_DEVICE_TRACKERS]: continue @@ -268,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.""" @@ -334,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, ) @@ -353,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) @@ -445,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 c94499d92f..8a8915541d 100644 --- a/homeassistant/components/person/strings.json +++ b/homeassistant/components/person/strings.json @@ -1,9 +1,29 @@ { "title": "Person", - "state": { + "entity_component": { "_": { - "home": "[%key:common::state::home%]", - "not_home": "[%key:common::state::not_home%]" + "name": "[%key:component::person::title%]", + "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 dbca866137..c6a8d5da83 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -1,8 +1,6 @@ """Support for getting statistical data from a Pi-hole system.""" from __future__ import annotations -from typing import Any - from hole import Hole from homeassistant.components.sensor import SensorEntity, SensorEntityDescription @@ -10,6 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, PERCENTAGE 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 from . import PiHoleEntity @@ -18,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", ), @@ -96,6 +95,7 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): """Representation of a Pi-hole sensor.""" entity_description: SensorEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -109,13 +109,12 @@ 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) -> Any: + def native_value(self) -> StateType: """Return the state of the device.""" try: - return round(self.api.data[self.entity_description.key], 2) + return round(self.api.data[self.entity_description.key], 2) # type: ignore[no-any-return] except TypeError: - return self.api.data[self.entity_description.key] + return self.api.data[self.entity_description.key] # type: ignore[no-any-return] 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/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 7500d9988a..c8b4ce5a20 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -8,6 +8,7 @@ import logging import re from typing import Any +import async_timeout from icmplib import NameLookupError, async_ping import voluptuous as vol @@ -230,9 +231,8 @@ class PingDataSubProcess(PingData): close_fds=False, # required for posix_spawn ) try: - out_data, out_error = await asyncio.wait_for( - pinger.communicate(), self._count + PING_TIMEOUT - ) + async with async_timeout.timeout(self._count + PING_TIMEOUT): + out_data, out_error = await pinger.communicate() if out_data: _LOGGER.debug( diff --git a/homeassistant/components/piper/__init__.py b/homeassistant/components/piper/__init__.py new file mode 100644 index 0000000000..6a67fa3975 --- /dev/null +++ b/homeassistant/components/piper/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Piper.""" diff --git a/homeassistant/components/piper/manifest.json b/homeassistant/components/piper/manifest.json new file mode 100644 index 0000000000..de8f1dede5 --- /dev/null +++ b/homeassistant/components/piper/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "piper", + "name": "Piper", + "integration_type": "virtual", + "supported_by": "wyoming" +} 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 e7c4eeea1e..4bbf1225a9 100644 --- a/homeassistant/components/pjlink/media_player.py +++ b/homeassistant/components/pjlink/media_player.py @@ -1,6 +1,8 @@ """Support for controlling projector via the PJLink protocol.""" from __future__ import annotations +import socket + from pypjlink import MUTE_AUDIO, Projector from pypjlink.projector import ProjectorError import voluptuous as vol @@ -17,11 +19,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -CONF_ENCODING = "encoding" +from .const import CONF_ENCODING, DEFAULT_ENCODING, DEFAULT_PORT, DOMAIN -DEFAULT_PORT = 4352 -DEFAULT_ENCODING = "utf-8" -DEFAULT_TIMEOUT = 10 +ERR_PROJECTOR_UNAVAILABLE = "projector unavailable" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -47,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: @@ -79,55 +79,80 @@ class PjLinkDevice(MediaPlayerEntity): """Iinitialize the PJLink device.""" self._host = host self._port = port - self._attr_name = name self._password = password self._encoding = encoding + self._source_name_mapping = {} + + self._attr_name = name self._attr_is_volume_muted = False self._attr_state = MediaPlayerState.OFF - with self.projector() as projector: - if not self._attr_name: - self._attr_name = projector.get_name() - inputs = projector.get_inputs() + self._attr_source = None + self._attr_source_list = [] + self._attr_available = False + + def _force_off(self): + self._attr_state = MediaPlayerState.OFF + self._attr_is_volume_muted = False + self._attr_source = None + + def _setup_projector(self): + try: + with self.projector() as projector: + if not self._attr_name: + self._attr_name = projector.get_name() + inputs = projector.get_inputs() + except ProjectorError as err: + if str(err) == ERR_PROJECTOR_UNAVAILABLE: + return False + raise + self._source_name_mapping = {format_input_source(*x): x for x in inputs} - self._attr_source_list = sorted(self._source_name_mapping.keys()) + self._attr_source_list = sorted(self._source_name_mapping) + return True def projector(self): """Create PJLink Projector instance.""" - projector = Projector.from_address( - self._host, self._port, self._encoding, DEFAULT_TIMEOUT - ) - projector.authenticate(self._password) + try: + projector = Projector.from_address(self._host, self._port) + projector.authenticate(self._password) + except (socket.timeout, OSError) as err: + self._attr_available = False + raise ProjectorError(ERR_PROJECTOR_UNAVAILABLE) from err + return projector def update(self) -> None: """Get the latest state from the device.""" - with self.projector() as projector: - try: + if not self._attr_available: + self._attr_available = self._setup_projector() + + if not self._attr_available: + self._force_off() + return + + try: + with self.projector() as projector: pwstate = projector.get_power() if pwstate in ("on", "warm-up"): self._attr_state = MediaPlayerState.ON self._attr_is_volume_muted = projector.get_mute()[1] self._attr_source = format_input_source(*projector.get_input()) else: - self._attr_state = MediaPlayerState.OFF - self._attr_is_volume_muted = False - self._attr_source = None - except KeyError as err: - if str(err) == "'OK'": - self._attr_state = MediaPlayerState.OFF - self._attr_is_volume_muted = False - self._attr_source = None - else: - raise - except ProjectorError as err: - if str(err) == "unavailable time": - self._attr_state = MediaPlayerState.OFF - self._attr_is_volume_muted = False - self._attr_source = None - else: - raise + self._force_off() + except KeyError as err: + if str(err) == "'OK'": + self._force_off() + else: + raise + except ProjectorError as err: + if str(err) == "unavailable time": + self._force_off() + elif str(err) == ERR_PROJECTOR_UNAVAILABLE: + self._attr_available = False + else: + raise def turn_off(self) -> None: """Turn projector off.""" 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/plant/strings.json b/homeassistant/components/plant/strings.json index 2478564ca8..853e5daee1 100644 --- a/homeassistant/components/plant/strings.json +++ b/homeassistant/components/plant/strings.json @@ -1,9 +1,12 @@ { "title": "Plant Monitor", - "state": { + "entity_component": { "_": { - "ok": "[%key:component::binary_sensor::state::problem::off%]", - "problem": "[%key:component::binary_sensor::state::problem::on%]" + "name": "[%key:component::plant::title%]", + "state": { + "ok": "[%key:component::binary_sensor::entity_component::problem::state::off%]", + "problem": "[%key:component::binary_sensor::entity_component::problem::state::on%]" + } } } } diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index b215bc0d82..59ae14b8ca 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dev_reg, entity_registry as ent_reg +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import ( @@ -35,8 +35,7 @@ from .const import ( CONF_SERVER_IDENTIFIER, DISPATCHERS, DOMAIN, - GDM_DEBOUNCER, - GDM_SCANNER, + INVALID_TOKEN_MESSAGE, PLATFORMS, PLATFORMS_COMPLETED, PLEX_SERVER_CONFIG, @@ -47,6 +46,7 @@ from .const import ( WEBSOCKETS, ) from .errors import ShouldUpdateConfigEntry +from .helpers import PlexData, get_plex_data from .media_browser import browse_media from .server import PlexServer from .services import async_setup_services @@ -62,7 +62,7 @@ def is_plex_media_id(media_content_id): async def async_browse_media(hass, media_content_type, media_content_id, platform=None): """Browse Plex media.""" - plex_server = next(iter(hass.data[DOMAIN][SERVERS].values()), None) + plex_server = next(iter(get_plex_data(hass)[SERVERS].values()), None) if not plex_server: raise BrowseError("No Plex servers available") is_internal = is_internal_request(hass) @@ -80,22 +80,13 @@ async def async_browse_media(hass, media_content_type, media_content_id, platfor async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Plex component.""" - hass.data.setdefault( - DOMAIN, - {SERVERS: {}, DISPATCHERS: {}, WEBSOCKETS: {}, PLATFORMS_COMPLETED: {}}, - ) - - await async_setup_services(hass) - - hass.http.register_view(PlexImageView()) - - gdm = hass.data[DOMAIN][GDM_SCANNER] = GDM() + gdm = GDM() def gdm_scan(): _LOGGER.debug("Scanning for GDM clients") gdm.scan(scan_for_clients=True) - hass.data[DOMAIN][GDM_DEBOUNCER] = Debouncer[None]( + debouncer = Debouncer[None]( hass, _LOGGER, cooldown=10, @@ -103,6 +94,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: function=gdm_scan, ).async_call + hass_data = PlexData( + servers={}, + dispatchers={}, + websockets={}, + platforms_completed={}, + gdm_scanner=gdm, + gdm_debouncer=debouncer, + ) + hass.data.setdefault(DOMAIN, hass_data) + + await async_setup_services(hass) + + hass.http.register_view(PlexImageView()) + return True @@ -149,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], @@ -161,8 +171,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Connected to: %s (%s)", plex_server.friendly_name, plex_server.url_in_use ) server_id = plex_server.machine_identifier - hass.data[DOMAIN][SERVERS][server_id] = plex_server - hass.data[DOMAIN][PLATFORMS_COMPLETED][server_id] = set() + hass_data = get_plex_data(hass) + hass_data[SERVERS][server_id] = plex_server + hass_data[PLATFORMS_COMPLETED][server_id] = set() entry.add_update_listener(async_options_updated) @@ -171,8 +182,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id), plex_server.async_update_platforms, ) - hass.data[DOMAIN][DISPATCHERS].setdefault(server_id, []) - hass.data[DOMAIN][DISPATCHERS][server_id].append(unsub) + hass_data[DISPATCHERS].setdefault(server_id, []) + hass_data[DISPATCHERS][server_id].append(unsub) @callback def plex_websocket_callback(msgtype, data, error): @@ -213,11 +224,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=session, verify_ssl=verify_ssl, ) - hass.data[DOMAIN][WEBSOCKETS][server_id] = websocket + hass_data[WEBSOCKETS][server_id] = websocket def start_websocket_session(platform): - hass.data[DOMAIN][PLATFORMS_COMPLETED][server_id].add(platform) - if hass.data[DOMAIN][PLATFORMS_COMPLETED][server_id] == PLATFORMS: + hass_data[PLATFORMS_COMPLETED][server_id].add(platform) + if hass_data[PLATFORMS_COMPLETED][server_id] == PLATFORMS: hass.loop.create_task(websocket.listen()) def close_websocket_session(_): @@ -226,7 +237,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unsub = hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, close_websocket_session ) - hass.data[DOMAIN][DISPATCHERS][server_id].append(unsub) + hass_data[DISPATCHERS][server_id].append(unsub) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -263,16 +274,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" server_id = entry.data[CONF_SERVER_IDENTIFIER] - websocket = hass.data[DOMAIN][WEBSOCKETS].pop(server_id) + hass_data = get_plex_data(hass) + websocket = hass_data[WEBSOCKETS].pop(server_id) websocket.close() - dispatchers = hass.data[DOMAIN][DISPATCHERS].pop(server_id) + dispatchers = hass_data[DISPATCHERS].pop(server_id) for unsub in dispatchers: unsub() unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN][SERVERS].pop(server_id) + hass_data[SERVERS].pop(server_id) return unload_ok @@ -281,25 +293,24 @@ async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None """Triggered by config entry options updates.""" server_id = entry.data[CONF_SERVER_IDENTIFIER] + hass_data = get_plex_data(hass) # Guard incomplete setup during reauth flows - if server_id in hass.data[DOMAIN][SERVERS]: - hass.data[DOMAIN][SERVERS][server_id].options = entry.options + if server_id in hass_data[SERVERS]: + hass_data[SERVERS][server_id].options = entry.options @callback def async_cleanup_plex_devices(hass, entry): """Clean up old and invalid devices from the registry.""" - device_registry = dev_reg.async_get(hass) - entity_registry = ent_reg.async_get(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) - device_entries = dev_reg.async_entries_for_config_entry( - device_registry, entry.entry_id - ) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) for device_entry in device_entries: if ( len( - ent_reg.async_entries_for_device( + er.async_entries_for_device( entity_registry, device_entry.id, include_disabled_entities=True ) ) diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 1ebe439ff7..10ae380a08 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -49,13 +49,13 @@ from .const import ( DOMAIN, MANUAL_SETUP_STRING, PLEX_SERVER_CONFIG, - SERVERS, X_PLEX_DEVICE_NAME, X_PLEX_PLATFORM, X_PLEX_PRODUCT, X_PLEX_VERSION, ) from .errors import NoServersFound, ServerNotSpecified +from .helpers import get_plex_server from .server import PlexServer HEADER_FRONTEND_BASE = "HA-Frontend-Base" @@ -360,7 +360,7 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow): async def async_step_plex_mp_settings(self, user_input=None): """Manage the Plex media_player options.""" - plex_server = self.hass.data[DOMAIN][SERVERS][self.server_id] + plex_server = get_plex_server(self.hass, self.server_id) if user_input is not None: self.options[MP_DOMAIN][CONF_USE_EPISODE_ART] = user_input[ diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index dea976f46d..7936cb6e6c 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -1,5 +1,6 @@ """Constants for the Plex component.""" from datetime import timedelta +from typing import Final from homeassistant.const import Platform, __version__ @@ -16,14 +17,14 @@ PLEXTV_THROTTLE = 60 CLIENT_SCAN_INTERVAL = timedelta(minutes=10) DEBOUNCE_TIMEOUT = 1 -DISPATCHERS = "dispatchers" -GDM_DEBOUNCER = "gdm_debouncer" -GDM_SCANNER = "gdm_scanner" +DISPATCHERS: Final = "dispatchers" +GDM_DEBOUNCER: Final = "gdm_debouncer" +GDM_SCANNER: Final = "gdm_scanner" PLATFORMS = frozenset([Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR]) -PLATFORMS_COMPLETED = "platforms_completed" +PLATFORMS_COMPLETED: Final = "platforms_completed" PLAYER_SOURCE = "player_source" -SERVERS = "servers" -WEBSOCKETS = "websockets" +SERVERS: Final = "servers" +WEBSOCKETS: Final = "websockets" PLEX_SERVER_CONFIG = "server_config" @@ -56,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/helpers.py b/homeassistant/components/plex/helpers.py index 6a0f0780e0..6a334c5ff6 100644 --- a/homeassistant/components/plex/helpers.py +++ b/homeassistant/components/plex/helpers.py @@ -1,4 +1,40 @@ """Helper methods for common Plex integration operations.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import TYPE_CHECKING, Any, TypedDict + +from plexapi.gdm import GDM +from plexwebsocket import PlexWebsocket + +from homeassistant.const import Platform +from homeassistant.core import CALLBACK_TYPE, HomeAssistant + +from .const import DOMAIN, SERVERS + +if TYPE_CHECKING: + from . import PlexServer + + +class PlexData(TypedDict): + """Typed description of plex data stored in `hass.data`.""" + + servers: dict[str, PlexServer] + dispatchers: dict[str, list[CALLBACK_TYPE]] + websockets: dict[str, PlexWebsocket] + platforms_completed: dict[str, set[Platform]] + gdm_scanner: GDM + gdm_debouncer: Callable[[], Coroutine[Any, Any, None]] + + +def get_plex_data(hass: HomeAssistant) -> PlexData: + """Get typed data from hass.data.""" + return hass.data[DOMAIN] + + +def get_plex_server(hass: HomeAssistant, server_id: str) -> PlexServer: + """Get Plex server from hass.data.""" + return get_plex_data(hass)[SERVERS][server_id] def pretty_title(media, short_name=False): diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py index 95ad3f39c7..d3a0cc0fb2 100644 --- a/homeassistant/components/plex/media_browser.py +++ b/homeassistant/components/plex/media_browser.py @@ -7,7 +7,7 @@ from homeassistant.components.media_player import BrowseError, BrowseMedia, Medi from .const import DOMAIN, SERVERS from .errors import MediaNotFound -from .helpers import pretty_title +from .helpers import get_plex_data, get_plex_server, pretty_title class UnknownMediaType(BrowseError): @@ -42,7 +42,7 @@ def browse_media( # noqa: C901 if media_content_id: url = URL(media_content_id) server_id = url.host - plex_server = hass.data[DOMAIN][SERVERS][server_id] + plex_server = get_plex_server(hass, server_id) if media_content_type == "hub": _, hub_location, hub_identifier = url.parts elif media_content_type in ["library", "server"] and len(url.parts) > 2: @@ -294,7 +294,7 @@ def root_payload(hass, is_internal, platform=None): """Return root payload for Plex.""" children = [] - for server_id in hass.data[DOMAIN][SERVERS]: + for server_id in get_plex_data(hass)[SERVERS]: children.append( browse_media( hass, diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 13422beec4..be57267960 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -40,9 +40,9 @@ from .const import ( PLEX_UPDATE_MEDIA_PLAYER_SESSION_SIGNAL, PLEX_UPDATE_MEDIA_PLAYER_SIGNAL, PLEX_UPDATE_SENSOR_SIGNAL, - SERVERS, TRANSIENT_DEVICE_MODELS, ) +from .helpers import get_plex_data, get_plex_server from .media_browser import browse_media from .services import process_plex_payload @@ -85,7 +85,7 @@ async def async_setup_entry( unsub = async_dispatcher_connect( hass, PLEX_NEW_MP_SIGNAL.format(server_id), async_new_media_players ) - hass.data[DOMAIN][DISPATCHERS][server_id].append(unsub) + get_plex_data(hass)[DISPATCHERS][server_id].append(unsub) _LOGGER.debug("New entity listener created") @@ -94,7 +94,7 @@ def _async_add_entities(hass, registry, async_add_entities, server_id, new_entit """Set up Plex media_player entities.""" _LOGGER.debug("New entities: %s", new_entities) entities = [] - plexserver = hass.data[DOMAIN][SERVERS][server_id] + plexserver = get_plex_server(hass, server_id) for entity_params in new_entities: plex_mp = PlexMediaPlayer(plexserver, **entity_params) entities.append(plex_mp) @@ -479,7 +479,9 @@ class PlexMediaPlayer(MediaPlayerEntity): if self.device and "playback" in self._device_protocol_capabilities: self.device.skipPrevious(self._active_media_plexapi_type) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play a piece of media.""" if not (self.device and "playback" in self._device_protocol_capabilities): raise HomeAssistantError( @@ -539,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/plex/sensor.py b/homeassistant/components/plex/sensor.py index f4a2ac6e03..3b66fe0cf6 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -20,9 +20,8 @@ from .const import ( NAME_FORMAT, PLEX_UPDATE_LIBRARY_SIGNAL, PLEX_UPDATE_SENSOR_SIGNAL, - SERVERS, ) -from .helpers import pretty_title +from .helpers import get_plex_server, pretty_title LIBRARY_ATTRIBUTE_TYPES = { "artist": ["artist", "album"], @@ -57,7 +56,7 @@ async def async_setup_entry( ) -> None: """Set up Plex sensor from a config entry.""" server_id = config_entry.data[CONF_SERVER_IDENTIFIER] - plexserver = hass.data[DOMAIN][SERVERS][server_id] + plexserver = get_plex_server(hass, server_id) sensors = [PlexSensor(hass, plexserver)] def create_library_sensors(): diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index 827712889e..9684c79792 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -1,4 +1,6 @@ """Shared class to maintain Plex server instances.""" +from __future__ import annotations + import logging import ssl import time @@ -27,7 +29,6 @@ from .const import ( CONF_USE_EPISODE_ART, DEBOUNCE_TIMEOUT, DEFAULT_VERIFY_SSL, - DOMAIN, GDM_DEBOUNCER, GDM_SCANNER, PLAYER_SOURCE, @@ -47,6 +48,7 @@ from .errors import ( ServerNotSpecified, ShouldUpdateConfigEntry, ) +from .helpers import get_plex_data from .media_search import search_media from .models import PlexSession @@ -316,7 +318,7 @@ class PlexServer: """Update the platform entities.""" _LOGGER.debug("Updating devices") - await self.hass.data[DOMAIN][GDM_DEBOUNCER]() + await get_plex_data(self.hass)[GDM_DEBOUNCER]() available_clients = {} ignored_clients = set() @@ -429,7 +431,7 @@ class PlexServer: def connect_new_clients(): """Create connections to newly discovered clients.""" - for gdm_entry in self.hass.data[DOMAIN][GDM_SCANNER].entries: + for gdm_entry in get_plex_data(self.hass)[GDM_SCANNER].entries: machine_identifier = gdm_entry["data"]["Resource-Identifier"] if machine_identifier in self._client_device_cache: client = self._client_device_cache[machine_identifier] diff --git a/homeassistant/components/plex/services.py b/homeassistant/components/plex/services.py index 46c0df8861..6257647144 100644 --- a/homeassistant/components/plex/services.py +++ b/homeassistant/components/plex/services.py @@ -19,6 +19,7 @@ from .const import ( SERVICE_SCAN_CLIENTS, ) from .errors import MediaNotFound +from .helpers import get_plex_data from .models import PlexMediaSearchResult from .server import PlexServer @@ -41,7 +42,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: " Service calls will still work for now but the service will be removed in" " a future release" ) - for server_id in hass.data[DOMAIN][SERVERS]: + for server_id in get_plex_data(hass)[SERVERS]: async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) hass.services.async_register( @@ -84,7 +85,7 @@ def get_plex_server( """Retrieve a configured Plex server by name.""" if DOMAIN not in hass.data: raise HomeAssistantError("Plex integration not configured") - servers: dict[str, PlexServer] = hass.data[DOMAIN][SERVERS] + servers: dict[str, PlexServer] = get_plex_data(hass)[SERVERS] if not servers: raise HomeAssistantError("No Plex servers available") diff --git a/homeassistant/components/plex/view.py b/homeassistant/components/plex/view.py index a2c31f17eb..ba883883dd 100644 --- a/homeassistant/components/plex/view.py +++ b/homeassistant/components/plex/view.py @@ -11,7 +11,8 @@ from aiohttp.typedefs import LooseHeaders from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.media_player import async_fetch_image -from .const import DOMAIN, SERVERS +from .const import SERVERS +from .helpers import get_plex_data _LOGGER = logging.getLogger(__name__) @@ -33,7 +34,7 @@ class PlexImageView(HomeAssistantView): return web.Response(status=HTTPStatus.UNAUTHORIZED) hass = request.app["hass"] - if (server := hass.data[DOMAIN][SERVERS].get(server_id)) is None: + if (server := get_plex_data(hass)[SERVERS].get(server_id)) is None: return web.Response(status=HTTPStatus.NOT_FOUND) if (image_url := server.thumbnail_cache.get(media_content_id)) is None: 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..4fdcd0a8bd 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -1,12 +1,12 @@ { "domain": "plugwise", "name": "Plugwise", - "codeowners": ["@CoMPaTech", "@bouwew", "@brefra", "@frenck"], + "codeowners": ["@CoMPaTech", "@bouwew", "@frenck"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/plugwise", "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.27.5"], + "requirements": ["plugwise==0.31.1"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/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/sensor.py b/homeassistant/components/plugwise/sensor.py index cf83db4f9c..d708fe741c 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( UnitOfPressure, UnitOfTemperature, UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -303,9 +304,9 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="gas_consumed_interval", name="Gas consumed interval", - native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, - device_class=SensorDeviceClass.GAS, - state_class=SensorStateClass.TOTAL, + icon="mdi:meter-gas", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="gas_consumed_cumulative", 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/point/__init__.py b/homeassistant/components/point/__init__.py index d4b8377230..6600a8240a 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -322,9 +322,7 @@ class MinutPointEntity(Entity): """Return a device description for device registry.""" device = self.device.device return DeviceInfo( - connections={ - (device_registry.CONNECTION_NETWORK_MAC, device["device_mac"]) - }, + connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, identifiers={(DOMAIN, device["device_id"])}, manufacturer="Minut", model=f"Point v{device['hardware_version']}", diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index bfffb93440..21469c2c5a 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -67,7 +67,7 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): self._attr_device_info = DeviceInfo( identifiers={(POINT_DOMAIN, home_id)}, manufacturer="Minut", - name=self._attr_name, + name=self._home["name"], ) async def async_added_to_hass(self) -> None: 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..ba5f25a1c0 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, @@ -270,11 +403,6 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall) # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - if sys.version_info >= (3, 11): - raise HomeAssistantError( - "Memory profiling is not supported on Python 3.11. Please use Python 3.10." - ) - from guppy import hpy # pylint: disable=import-outside-toplevel start_time = int(time.time() * 1000000) @@ -323,4 +451,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/manifest.json b/homeassistant/components/profiler/manifest.json index 81eb77537f..1b33c77884 100644 --- a/homeassistant/components/profiler/manifest.json +++ b/homeassistant/components/profiler/manifest.json @@ -5,9 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/profiler", "quality_scale": "internal", - "requirements": [ - "pyprof2calltree==1.4.5", - "guppy3==3.1.2;python_version<'3.11'", - "objgraph==3.5.0" - ] + "requirements": ["pyprof2calltree==1.4.5", "guppy3==3.1.3", "objgraph==3.5.0"] } 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/__init__.py b/homeassistant/components/prosegur/__init__.py index 04f353e96b..9f594fc6da 100644 --- a/homeassistant/components/prosegur/__init__.py +++ b/homeassistant/components/prosegur/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import aiohttp_client from .const import CONF_COUNTRY, DOMAIN -PLATFORMS = [Platform.ALARM_CONTROL_PANEL] +PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.CAMERA] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index 133c182e2c..b05a5f245f 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -15,6 +15,7 @@ from homeassistant.const import ( STATE_ALARM_DISARMED, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN @@ -59,11 +60,19 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): self._attr_name = f"contract {self.contract}" self._attr_unique_id = self.contract + self._attr_device_info = DeviceInfo( + name="Prosegur Alarm", + manufacturer="Prosegur", + model="smart", + identifiers={(DOMAIN, self.contract)}, + configuration_url="https://smart.prosegur.com", + ) + async def async_update(self) -> None: """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 new file mode 100644 index 0000000000..9041a6526f --- /dev/null +++ b/homeassistant/components/prosegur/camera.py @@ -0,0 +1,99 @@ +"""Support for Prosegur cameras.""" +from __future__ import annotations + +import logging + +from pyprosegur.auth import Auth +from pyprosegur.exceptions import ProsegurException +from pyprosegur.installation import Camera as InstallationCamera, Installation + +from homeassistant.components.camera import Camera +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) + +from . import DOMAIN +from .const import SERVICE_REQUEST_IMAGE + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Prosegur camera platform.""" + + platform = async_get_current_platform() + platform.async_register_entity_service( + SERVICE_REQUEST_IMAGE, + {}, + "async_request_image", + ) + + _installation = await Installation.retrieve( + hass.data[DOMAIN][entry.entry_id], entry.data["contract"] + ) + + async_add_entities( + [ + ProsegurCamera(_installation, camera, hass.data[DOMAIN][entry.entry_id]) + for camera in _installation.cameras + ], + update_before_add=True, + ) + + +class ProsegurCamera(Camera): + """Representation of a Smart Prosegur Camera.""" + + def __init__( + self, installation: Installation, camera: InstallationCamera, auth: Auth + ) -> None: + """Initialize Prosegur Camera component.""" + Camera.__init__(self) + + self._installation = installation + self._camera = camera + self._auth = auth + self._attr_name = camera.description + self._attr_unique_id = f"{self._installation.contract} {camera.id}" + + self._attr_device_info = DeviceInfo( + name=self._camera.description, + manufacturer="Prosegur", + model="smart camera", + identifiers={(DOMAIN, self._installation.contract)}, + configuration_url="https://smart.prosegur.com", + ) + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return bytes of camera image.""" + + _LOGGER.debug("Get image for %s", self._camera.description) + try: + return await self._installation.get_image(self._auth, self._camera.id) + + except ProsegurException as err: + _LOGGER.error("Image %s doesn't exist: %s", self._camera.description, err) + + return None + + async def async_request_image(self): + """Request new image from the camera.""" + + _LOGGER.debug("Request image for %s", self._camera.description) + try: + await self._installation.request_image(self._auth, self._camera.id) + + except ProsegurException as err: + _LOGGER.error( + "Could not request image from camera %s: %s", + self._camera.description, + err, + ) 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 b066b320a1..ea823e7606 100644 --- a/homeassistant/components/prosegur/const.py +++ b/homeassistant/components/prosegur/const.py @@ -3,3 +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 new file mode 100644 index 0000000000..59b51f5b5d --- /dev/null +++ b/homeassistant/components/prosegur/diagnostics.py @@ -0,0 +1,31 @@ +"""Diagnostics support for Prosegur.""" +from __future__ import annotations + +from typing import Any + +from pyprosegur.installation import Installation + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import CONF_CONTRACT, DOMAIN + +TO_REDACT = {"description", "latitude", "longitude", "contractId", "address"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + installation = await Installation.retrieve( + hass.data[DOMAIN][entry.entry_id], entry.data[CONF_CONTRACT] + ) + + activity = await installation.activity(hass.data[DOMAIN][entry.entry_id]) + + return { + "installation": async_redact_data(installation.data, TO_REDACT), + "activity": activity, + } diff --git a/homeassistant/components/prosegur/manifest.json b/homeassistant/components/prosegur/manifest.json index 1827939d09..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.5"] + "requirements": ["pyprosegur==0.0.9"] } diff --git a/homeassistant/components/prosegur/services.yaml b/homeassistant/components/prosegur/services.yaml new file mode 100644 index 0000000000..0db63cb7ad --- /dev/null +++ b/homeassistant/components/prosegur/services.yaml @@ -0,0 +1,7 @@ +request_image: + name: Request Camera image + description: Request a new image from a Prosegur Camera + target: + entity: + domain: camera + integration: prosegur 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/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index edd5014655..0567c551d9 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -13,10 +13,10 @@ from homeassistant.const import ( CONF_ZONE, UnitOfLength, ) -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_state_change +from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.typing import ConfigType from homeassistant.util.location import distance from homeassistant.util.unit_conversion import DistanceConverter @@ -62,7 +62,8 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup_proximity_component( +@callback +def async_setup_proximity_component( hass: HomeAssistant, name: str, config: ConfigType ) -> bool: """Set up the individual proximity component.""" @@ -89,17 +90,19 @@ def setup_proximity_component( ) proximity.entity_id = f"{DOMAIN}.{proximity_zone}" - proximity.schedule_update_ha_state() + proximity.async_write_ha_state() - track_state_change(hass, proximity_devices, proximity.check_proximity_state_change) + async_track_state_change( + hass, proximity_devices, proximity.async_check_proximity_state_change + ) return True -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Get the zones and offsets from configuration.yaml.""" for zone, proximity_config in config[DOMAIN].items(): - setup_proximity_component(hass, zone, proximity_config) + async_setup_proximity_component(hass, zone, proximity_config) return True @@ -152,7 +155,8 @@ class Proximity(Entity): """Return the state attributes.""" return {ATTR_DIR_OF_TRAVEL: self.dir_of_travel, ATTR_NEAREST: self.nearest} - def check_proximity_state_change( + @callback + def async_check_proximity_state_change( self, entity: str, old_state: State | None, new_state: State | None ) -> None: """Perform the proximity checking.""" @@ -192,7 +196,7 @@ class Proximity(Entity): self.dist_to = "not set" self.dir_of_travel = "not set" self.nearest = "not set" - self.schedule_update_ha_state() + self.async_write_ha_state() return # At least one device is in the monitored zone so update the entity. @@ -200,7 +204,7 @@ class Proximity(Entity): self.dist_to = 0 self.dir_of_travel = "arrived" self.nearest = devices_in_zone - self.schedule_update_ha_state() + self.async_write_ha_state() return # We can't check proximity because latitude and longitude don't exist. @@ -254,7 +258,7 @@ class Proximity(Entity): device_state = self.hass.states.get(closest_device) assert device_state self.nearest = device_state.name - self.schedule_update_ha_state() + self.async_write_ha_state() return # Stop if we cannot calculate the direction of travel (i.e. we don't @@ -263,7 +267,7 @@ class Proximity(Entity): self.dist_to = round(distances_to_zone[entity]) self.dir_of_travel = "unknown" self.nearest = entity_name - self.schedule_update_ha_state() + self.async_write_ha_state() return # Reset the variables @@ -299,7 +303,7 @@ class Proximity(Entity): ) self.dir_of_travel = direction_of_travel self.nearest = entity_name - self.schedule_update_ha_state() + self.async_write_ha_state() _LOGGER.debug( "proximity.%s update entity: distance=%s: direction=%s: device=%s", self.friendly_name, 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/__init__.py b/homeassistant/components/ps4/__init__.py index 28ebd8ff07..0f5c57c5e4 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -23,12 +23,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, split_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType from homeassistant.util import location -from homeassistant.util.json import load_json +from homeassistant.util.json import JsonObjectType, load_json_object from .config_flow import PlayStation4FlowHandler # noqa: F401 from .const import ( @@ -116,7 +116,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Migrate Version 2 -> Version 3: Update identifier format. if version == 2: # Prevent changing entity_id. Updates entity registry. - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) for entity_id, e_entry in registry.entities.items(): if e_entry.config_entry_id == entry.entry_id: @@ -165,19 +165,15 @@ def format_unique_id(creds, mac_address): return f"{mac_address}_{suffix}" -def load_games(hass: HomeAssistant, unique_id: str) -> dict: +def load_games(hass: HomeAssistant, unique_id: str) -> JsonObjectType: """Load games for sources.""" g_file = hass.config.path(GAMES_FILE.format(unique_id)) try: - games = load_json(g_file) + games = load_json_object(g_file) except HomeAssistantError as error: games = {} _LOGGER.error("Failed to load games file: %s", error) - if not isinstance(games, dict): - _LOGGER.error("Games file was not parsed correctly") - games = {} - # If file exists if os.path.isfile(g_file): games = _reformat_data(hass, games, unique_id) diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 5df92fd795..23438dd80c 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -2,6 +2,7 @@ import asyncio from contextlib import suppress import logging +from typing import Any, cast from pyps4_2ndscreen.errors import NotReady, PSDataIncomplete from pyps4_2ndscreen.media_art import TYPE_APP as PS_TYPE_APP @@ -24,9 +25,10 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.json import JsonObjectType from . import format_unique_id, load_games, save_games from .const import ( @@ -52,12 +54,12 @@ async def async_setup_entry( ) -> None: """Set up PS4 from a config entry.""" config = config_entry - creds = config.data[CONF_TOKEN] + creds: str = config.data[CONF_TOKEN] device_list = [] for device in config.data["devices"]: - host = device[CONF_HOST] - region = device[CONF_REGION] - name = device[CONF_NAME] + host: str = device[CONF_HOST] + region: str = device[CONF_REGION] + name: str = device[CONF_NAME] ps4 = pyps4.Ps4Async(host, creds, device_name=DEFAULT_ALIAS) device_list.append(PS4Device(config, name, host, region, ps4, creds)) async_add_entities(device_list, update_before_add=True) @@ -75,7 +77,15 @@ class PS4Device(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, config, name, host, region, ps4, creds): + def __init__( + self, + config: ConfigEntry, + name: str, + host: str, + region: str, + ps4: pyps4.Ps4Async, + creds: str, + ) -> None: """Initialize the ps4 device.""" self._entry_id = config.entry_id self._ps4 = ps4 @@ -83,30 +93,30 @@ class PS4Device(MediaPlayerEntity): self._attr_name = name self._region = region self._creds = creds - self._media_image = None - self._games = {} + self._media_image: str | None = None + self._games: JsonObjectType = {} self._retry = 0 self._disconnected = False @callback - def status_callback(self): + def status_callback(self) -> None: """Handle status callback. Parse status.""" self._parse_status() self.async_write_ha_state() @callback - def subscribe_to_protocol(self): + def subscribe_to_protocol(self) -> None: """Notify protocol to callback with update changes.""" self.hass.data[PS4_DATA].protocol.add_callback(self._ps4, self.status_callback) @callback - def unsubscribe_to_protocol(self): + def unsubscribe_to_protocol(self) -> None: """Notify protocol to remove callback.""" self.hass.data[PS4_DATA].protocol.remove_callback( self._ps4, self.status_callback ) - def check_region(self): + def check_region(self) -> None: """Display logger msg if region is deprecated.""" # Non-Breaking although data returned may be inaccurate. if self._region in deprecated_regions: @@ -151,10 +161,11 @@ class PS4Device(MediaPlayerEntity): self._parse_status() - def _parse_status(self): + def _parse_status(self) -> None: """Parse status.""" - if (status := self._ps4.status) is not None: - self._games = load_games(self.hass, self.unique_id) + status: dict[str, Any] | None = self._ps4.status + if status is not None: + self._games = load_games(self.hass, cast(str, self.unique_id)) if self._games: self.get_source_list() @@ -177,7 +188,10 @@ class PS4Device(MediaPlayerEntity): self._attr_source = self._attr_media_title self._attr_media_content_type = None # Get data from PS Store. - asyncio.ensure_future(self.async_get_title_data(title_id, name)) + self.hass.async_create_background_task( + self.async_get_title_data(title_id, name), + "ps4.media_player-get_title_data", + ) else: if self.state != MediaPlayerState.IDLE: self.idle() @@ -193,28 +207,30 @@ class PS4Device(MediaPlayerEntity): def _use_saved(self) -> bool: """Return True, Set media attrs if data is locked.""" if self.media_content_id in self._games: - store = self._games[self.media_content_id] + store = cast(JsonObjectType, self._games[self.media_content_id]) # If locked get attributes from file. if store.get(ATTR_LOCKED): - self._attr_media_title = store.get(ATTR_MEDIA_TITLE) + self._attr_media_title = cast(str | None, store.get(ATTR_MEDIA_TITLE)) self._attr_source = self._attr_media_title - self._media_image = store.get(ATTR_MEDIA_IMAGE_URL) - self._attr_media_content_type = store.get(ATTR_MEDIA_CONTENT_TYPE) + self._media_image = cast(str | None, store.get(ATTR_MEDIA_IMAGE_URL)) + self._attr_media_content_type = cast( + str | None, store.get(ATTR_MEDIA_CONTENT_TYPE) + ) return True return False - def idle(self): + def idle(self) -> None: """Set states for state idle.""" self.reset_title() self._attr_state = MediaPlayerState.IDLE - def state_standby(self): + def state_standby(self) -> None: """Set states for state standby.""" self.reset_title() self._attr_state = MediaPlayerState.STANDBY - def state_unknown(self): + def state_unknown(self) -> None: """Set states for state unknown.""" self.reset_title() self._attr_state = None @@ -223,14 +239,14 @@ class PS4Device(MediaPlayerEntity): self._disconnected = True self._retry = 0 - def reset_title(self): + def reset_title(self) -> None: """Update if there is no title.""" self._attr_media_title = None self._attr_media_content_id = None self._attr_media_content_type = None self._attr_source = None - async def async_get_title_data(self, title_id, name): + async def async_get_title_data(self, title_id: str, name: str) -> None: """Get PS Store Data.""" app_name = None @@ -272,10 +288,10 @@ class PS4Device(MediaPlayerEntity): await self.hass.async_add_executor_job(self.update_list) self.async_write_ha_state() - def update_list(self): + def update_list(self) -> None: """Update Game List, Correct data if different.""" if self.media_content_id in self._games: - store = self._games[self.media_content_id] + store = cast(JsonObjectType, self._games[self.media_content_id]) if ( store.get(ATTR_MEDIA_TITLE) != self.media_title @@ -290,7 +306,7 @@ class PS4Device(MediaPlayerEntity): self._media_image, self._attr_media_content_type, ) - self._games = load_games(self.hass, self.unique_id) + self._games = load_games(self.hass, cast(str, self.unique_id)) self.get_source_list() @@ -298,14 +314,22 @@ class PS4Device(MediaPlayerEntity): """Parse data entry and update source list.""" games = [] for data in self._games.values(): - games.append(data[ATTR_MEDIA_TITLE]) + data = cast(JsonObjectType, data) + games.append(cast(str, data[ATTR_MEDIA_TITLE])) self._attr_source_list = sorted(games) - def add_games(self, title_id, app_name, image, g_type, is_locked=False): + def add_games( + self, + title_id: str | None, + app_name: str | None, + image: str | None, + g_type: str | None, + is_locked: bool = False, + ) -> None: """Add games to list.""" games = self._games if title_id is not None and title_id not in games: - game = { + game: JsonObjectType = { title_id: { ATTR_MEDIA_TITLE: app_name, ATTR_MEDIA_IMAGE_URL: image, @@ -314,15 +338,15 @@ class PS4Device(MediaPlayerEntity): } } games.update(game) - save_games(self.hass, games, self.unique_id) + save_games(self.hass, games, cast(str, self.unique_id)) - async def async_get_device_info(self, status): + async def async_get_device_info(self, status: dict[str, Any] | None) -> None: """Set device info for registry.""" # If cannot get status on startup, assume info from registry. if status is None: _LOGGER.info("Assuming status from registry") - e_registry = entity_registry.async_get(self.hass) - d_registry = device_registry.async_get(self.hass) + e_registry = er.async_get(self.hass) + d_registry = dr.async_get(self.hass) for entity_id, entry in e_registry.entities.items(): if entry.config_entry_id == self._entry_id: self._attr_unique_id = entry.unique_id @@ -362,7 +386,7 @@ class PS4Device(MediaPlayerEntity): self.hass.data[PS4_DATA].devices.remove(self) @property - def entity_picture(self): + def entity_picture(self) -> str | None: """Return picture.""" if ( self.state == MediaPlayerState.PLAYING @@ -376,7 +400,7 @@ class PS4Device(MediaPlayerEntity): return None @property - def media_image_url(self): + def media_image_url(self) -> str | None: """Image url of current playing media.""" if self.media_content_id is None: return None @@ -405,7 +429,8 @@ class PS4Device(MediaPlayerEntity): async def async_select_source(self, source: str) -> None: """Select input source.""" for title_id, data in self._games.items(): - game = data[ATTR_MEDIA_TITLE] + data = cast(JsonObjectType, data) + game = cast(str, data[ATTR_MEDIA_TITLE]) if ( source.lower().encode(encoding="utf-8") == game.lower().encode(encoding="utf-8") @@ -421,10 +446,10 @@ class PS4Device(MediaPlayerEntity): _LOGGER.warning("Could not start title. '%s' is not in source list", source) return - async def async_send_command(self, command): + async def async_send_command(self, command: str) -> None: """Send Button Command.""" await self.async_send_remote_control(command) - async def async_send_remote_control(self, command): + async def async_send_remote_control(self, command: str) -> None: """Send RC command.""" await self._ps4.remote_control(command) 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/pushover/notify.py b/homeassistant/components/pushover/notify.py index 2d79a0bf65..52eaed227e 100644 --- a/homeassistant/components/pushover/notify.py +++ b/homeassistant/components/pushover/notify.py @@ -5,21 +5,16 @@ import logging from typing import Any from pushover_complete import BadAPIRequestError, PushoverAPI -import voluptuous as vol from homeassistant.components.notify import ( ATTR_DATA, ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, - PLATFORM_SCHEMA, BaseNotificationService, ) -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 ( @@ -40,11 +35,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_USER_KEY): cv.string, vol.Required(CONF_API_KEY): cv.string} -) - - async def async_get_service( hass: HomeAssistant, config: ConfigType, @@ -52,17 +42,7 @@ async def async_get_service( ) -> PushoverNotificationService | None: """Get the Pushover notification service.""" if discovery_info is None: - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.11.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) return None - pushover_api: PushoverAPI = hass.data[DOMAIN][discovery_info["entry_id"]] return PushoverNotificationService( hass, pushover_api, discovery_info[CONF_USER_KEY] diff --git a/homeassistant/components/pushover/strings.json b/homeassistant/components/pushover/strings.json index 3c2ab66bf3..cd72a9c20a 100644 --- a/homeassistant/components/pushover/strings.json +++ b/homeassistant/components/pushover/strings.json @@ -24,11 +24,5 @@ } } } - }, - "issues": { - "removed_yaml": { - "title": "The Pushover YAML configuration has been removed", - "description": "Configuring Pushover using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the Pushover YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/pushsafer/notify.py b/homeassistant/components/pushsafer/notify.py index ddf4ca5ef4..5411db05e2 100644 --- a/homeassistant/components/pushsafer/notify.py +++ b/homeassistant/components/pushsafer/notify.py @@ -40,7 +40,10 @@ ATTR_TIME2LIVE = "time2live" ATTR_PRIORITY = "priority" ATTR_RETRY = "retry" ATTR_EXPIRE = "expire" +ATTR_CONFIRM = "confirm" ATTR_ANSWER = "answer" +ATTR_ANSWEROPTIONS = "answeroptions" +ATTR_ANSWERFORCE = "answerforce" ATTR_PICTURE1 = "picture1" # Attributes contained in picture1 @@ -120,7 +123,10 @@ class PushsaferNotificationService(BaseNotificationService): "pr": data.get(ATTR_PRIORITY, ""), "re": data.get(ATTR_RETRY, ""), "ex": data.get(ATTR_EXPIRE, ""), + "cr": data.get(ATTR_CONFIRM, ""), "a": data.get(ATTR_ANSWER, ""), + "ao": data.get(ATTR_ANSWEROPTIONS, ""), + "af": data.get(ATTR_ANSWERFORCE, ""), "p": picture1_encoded, } 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/pvpc_hourly_pricing/manifest.json b/homeassistant/components/pvpc_hourly_pricing/manifest.json index 89520f079a..64e6e19086 100644 --- a/homeassistant/components/pvpc_hourly_pricing/manifest.json +++ b/homeassistant/components/pvpc_hourly_pricing/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["aiopvpc"], "quality_scale": "platinum", - "requirements": ["aiopvpc==4.0.1"] + "requirements": ["aiopvpc==4.1.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 14bc0eb2ed..6b758daab0 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -12,7 +12,9 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -22,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, @@ -43,14 +45,18 @@ 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, ), 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, ), ) @@ -64,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): @@ -104,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: @@ -125,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/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py index fc9fd72761..3606b664a3 100644 --- a/homeassistant/components/qld_bushfire/geo_location.py +++ b/homeassistant/components/qld_bushfire/geo_location.py @@ -126,7 +126,10 @@ class QldBushfireFeedEntityManager: def _init_regular_updates(self) -> None: """Schedule regular updates at the specified interval.""" track_time_interval( - self._hass, lambda now: self._feed_manager.update(), self._scan_interval + self._hass, + lambda now: self._feed_manager.update(), + self._scan_interval, + cancel_on_shutdown=True, ) def get_entry(self, external_id: str) -> QldBushfireAlertFeedEntry | None: @@ -149,7 +152,7 @@ class QldBushfireFeedEntityManager: class QldBushfireLocationEvent(GeolocationEvent): - """This represents an external event with Qld Bushfire feed data.""" + """Represents an external event with Qld Bushfire feed data.""" _attr_icon = "mdi:fire" _attr_should_poll = False diff --git a/homeassistant/components/qnap_qsw/manifest.json b/homeassistant/components/qnap_qsw/manifest.json index e2f188541d..1782511049 100644 --- a/homeassistant/components/qnap_qsw/manifest.json +++ b/homeassistant/components/qnap_qsw/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/qnap_qsw", "iot_class": "local_polling", "loggers": ["aioqsw"], - "requirements": ["aioqsw==0.3.1"] + "requirements": ["aioqsw==0.3.2"] } 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/quadrafire/__init__.py b/homeassistant/components/quadrafire/__init__.py new file mode 100644 index 0000000000..662e9d0887 --- /dev/null +++ b/homeassistant/components/quadrafire/__init__.py @@ -0,0 +1 @@ +"""Virtual integration for quadrafire.""" diff --git a/homeassistant/components/quadrafire/manifest.json b/homeassistant/components/quadrafire/manifest.json new file mode 100644 index 0000000000..fcd263e321 --- /dev/null +++ b/homeassistant/components/quadrafire/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "quadrafire", + "name": "Quadra-Fire", + "integration_type": "virtual", + "supported_by": "intellifire" +} diff --git a/homeassistant/components/quantum_gateway/device_tracker.py b/homeassistant/components/quantum_gateway/device_tracker.py index 076c1d2722..c8e23b6841 100644 --- a/homeassistant/components/quantum_gateway/device_tracker.py +++ b/homeassistant/components/quantum_gateway/device_tracker.py @@ -40,7 +40,7 @@ def get_scanner( class QuantumGatewayDeviceScanner(DeviceScanner): - """This class queries a Quantum Gateway.""" + """Class which queries a Quantum Gateway.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/rachio/entity.py b/homeassistant/components/rachio/entity.py index 1bb971e3e0..a109c4b99f 100644 --- a/homeassistant/components/rachio/entity.py +++ b/homeassistant/components/rachio/entity.py @@ -1,6 +1,6 @@ """Adapter to wrap the rachiopy api for home assistant.""" -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DEFAULT_NAME, DOMAIN @@ -25,7 +25,7 @@ class RachioDevice(Entity): }, connections={ ( - device_registry.CONNECTION_NETWORK_MAC, + dr.CONNECTION_NETWORK_MAC, self._controller.mac_address, ) }, diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index bc27c0b220..c04a1a09f8 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -399,7 +399,7 @@ class RachioZone(RachioSwitch): return self._entity_picture @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" props = {ATTR_ZONE_NUMBER: self._zone_number, ATTR_ZONE_SUMMARY: self._summary} if self._shade_type: @@ -506,7 +506,7 @@ class RachioSchedule(RachioSwitch): return "mdi:water" if self.schedule_is_enabled else "mdi:water-off" @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" return { ATTR_SCHEDULE_SUMMARY: self._summary, 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/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 7b41a3f2f5..14a81f2c66 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -1,96 +1,21 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" from __future__ import annotations -import logging - from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState -from homeassistant.const import ( - CONF_FRIENDLY_NAME, - CONF_HOST, - CONF_PASSWORD, - CONF_TRIGGER_TIME, - Platform, -) -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType -from .const import ATTR_CONFIG_ENTRY_ID, ATTR_DURATION, CONF_SERIAL_NUMBER, CONF_ZONES +from .const import CONF_SERIAL_NUMBER from .coordinator import RainbirdUpdateCoordinator PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR, Platform.NUMBER] -_LOGGER = logging.getLogger(__name__) DOMAIN = "rainbird" -TRIGGER_TIME_SCHEMA = vol.All( - cv.time_period, cv.positive_timedelta, lambda td: (td.total_seconds() // 60) -) - -ZONE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_TRIGGER_TIME): TRIGGER_TIME_SCHEMA, - } -) -CONTROLLER_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_TRIGGER_TIME): TRIGGER_TIME_SCHEMA, - vol.Optional(CONF_ZONES): vol.Schema({cv.positive_int: ZONE_SCHEMA}), - } -) -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [CONTROLLER_SCHEMA]))}, - extra=vol.ALLOW_EXTRA, -) - -SERVICE_SET_RAIN_DELAY = "set_rain_delay" -SERVICE_SCHEMA_RAIN_DELAY = vol.All( - vol.Schema( - { - vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, - vol.Required(ATTR_DURATION): cv.positive_float, - } - ), -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Rain Bird component.""" - if DOMAIN not in config: - return True - - for controller_config in config[DOMAIN]: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=controller_config, - ) - ) - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.4.0", - 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 config entry for Rain Bird.""" @@ -112,48 +37,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def set_rain_delay(call: ServiceCall) -> None: - """Service call to delay automatic irrigigation.""" - - entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - duration = call.data[ATTR_DURATION] - if entry_id not in hass.data[DOMAIN]: - raise HomeAssistantError(f"Config entry id does not exist: {entry_id}") - coordinator = hass.data[DOMAIN][entry_id] - - entity_registry = er.async_get(hass) - entity_ids = ( - entry.entity_id - for entry in er.async_entries_for_config_entry(entity_registry, entry_id) - if entry.unique_id == f"{coordinator.serial_number}-rain-delay" - ) - async_create_issue( - hass, - DOMAIN, - "deprecated_raindelay", - breaks_in_ha_version="2023.4.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_raindelay", - translation_placeholders={ - "alternate_target": next(entity_ids, "unknown"), - }, - ) - - await coordinator.controller.set_rain_delay(duration) - - hass.services.async_register( - DOMAIN, - SERVICE_SET_RAIN_DELAY, - set_rain_delay, - schema=SERVICE_SCHEMA_RAIN_DELAY, - ) - return True @@ -163,12 +50,4 @@ 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) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: - hass.services.async_remove(DOMAIN, SERVICE_SET_RAIN_DELAY) - return unload_ok diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index 3ade67c79f..0409d0ff56 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -16,7 +16,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_FRIENDLY_NAME, CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv, selector @@ -24,9 +24,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( ATTR_DURATION, - CONF_IMPORTED_NAMES, CONF_SERIAL_NUMBER, - CONF_ZONES, DEFAULT_TRIGGER_TIME_MINUTES, DOMAIN, TIMEOUT_SECONDS, @@ -121,36 +119,6 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): "cannot_connect", ) from err - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) - try: - serial_number = await self._test_connection( - config[CONF_HOST], config[CONF_PASSWORD] - ) - except ConfigFlowError as err: - _LOGGER.error("Error during config import: %s", err) - return self.async_abort(reason=err.error_code) - - data = { - CONF_HOST: config[CONF_HOST], - CONF_PASSWORD: config[CONF_PASSWORD], - CONF_SERIAL_NUMBER: serial_number, - } - names: dict[str, str] = {} - for zone, zone_config in config.get(CONF_ZONES, {}).items(): - if name := zone_config.get(CONF_FRIENDLY_NAME): - names[str(zone)] = name - if names: - data[CONF_IMPORTED_NAMES] = names - return await self.async_finish( - serial_number, - data=data, - options={ - ATTR_DURATION: config.get(ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES), - }, - ) - async def async_finish( self, serial_number: str, diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py index 162e3a16b6..8055074f39 100644 --- a/homeassistant/components/rainbird/const.py +++ b/homeassistant/components/rainbird/const.py @@ -4,7 +4,6 @@ DOMAIN = "rainbird" MANUFACTURER = "Rain Bird" DEFAULT_TRIGGER_TIME_MINUTES = 6 -CONF_ZONES = "zones" CONF_SERIAL_NUMBER = "serial_number" CONF_IMPORTED_NAMES = "imported_names" diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index f950146f16..3b5ae332db 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -27,22 +27,5 @@ } } } - }, - "issues": { - "deprecated_yaml": { - "title": "The Rain Bird YAML configuration is being removed", - "description": "Configuring Rain Bird in configuration.yaml is being removed in Home Assistant 2023.4.\n\nYour configuration has been imported into the UI automatically, however default per-zone irrigation times are no longer supported. Remove the Rain Bird YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - }, - "deprecated_raindelay": { - "title": "The Rain Bird Rain Delay Service is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "The Rain Bird Rain Delay Service is being removed", - "description": "The Rain Bird service `rainbird.set_rain_delay` is being removed and replaced by a Number entity for managing the rain delay. Any existing automations or scripts will need to be updated to use `number.set_value` with a target of `{alternate_target}` instead." - } - } - } - } } } diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 5815c0ce12..33650cfc2f 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -1,4 +1,4 @@ -"""This platform provides binary sensors for key RainMachine data.""" +"""Binary sensors for key RainMachine data.""" from dataclasses import dataclass from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index ff35b24cc9..574ca3d7f4 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -10,7 +10,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["regenmaschine"], - "requirements": ["regenmaschine==2022.11.0"], + "requirements": ["regenmaschine==2023.05.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 3d56ff59fc..22943d73fc 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -1,4 +1,4 @@ -"""This platform provides support for sensor data from RainMachine.""" +"""Support for sensor data from RainMachine.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index ae445d8278..60db508595 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -1,4 +1,4 @@ -"""This component provides support for RainMachine programs and zones.""" +"""Component providing support for RainMachine programs and zones.""" from __future__ import annotations import asyncio diff --git a/homeassistant/components/rainmachine/util.py b/homeassistant/components/rainmachine/util.py index 67ffc83d5b..d4131fdb02 100644 --- a/homeassistant/components/rainmachine/util.py +++ b/homeassistant/components/rainmachine/util.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -55,7 +55,7 @@ def async_finish_entity_domain_replacements( entity_replacement_strategies: Iterable[EntityDomainReplacementStrategy], ) -> None: """Remove old entities and create a repairs issue with info on their replacement.""" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) for strategy in entity_replacement_strategies: try: [registry_entry] = [ 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..d3eab0641a --- /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.1"] +} 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..0b5640fe3a 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.1"] } diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 71795bfa66..7b43abd8dd 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.integration_platform import ( from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from . import statistics, websocket_api +from . import entity_registry, websocket_api from .const import ( # noqa: F401 CONF_DB_INTEGRITY_CHECK, DATA_INSTANCE, @@ -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 @@ -142,12 +146,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass_config_path=hass.config.path(DEFAULT_DB_FILE) ) exclude = conf[CONF_EXCLUDE] - exclude_t = exclude.get(CONF_EVENT_TYPES, []) - if EVENT_STATE_CHANGED in exclude_t: - _LOGGER.warning( - "State change events are excluded, recorder will not record state changes." - "This will become an error in Home Assistant Core 2022.2" - ) + exclude_event_types: set[str] = set(exclude.get(CONF_EVENT_TYPES, [])) + if EVENT_STATE_CHANGED in exclude_event_types: + _LOGGER.error("State change events cannot be excluded, use a filter instead") + exclude_event_types.remove(EVENT_STATE_CHANGED) instance = hass.data[DATA_INSTANCE] = Recorder( hass=hass, auto_purge=auto_purge, @@ -158,23 +160,49 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: db_max_retries=db_max_retries, db_retry_wait=db_retry_wait, entity_filter=entity_filter, - exclude_t=exclude_t, + exclude_event_types=exclude_event_types, exclude_attributes_by_domain=exclude_attributes_by_domain, ) instance.async_initialize() instance.async_register() instance.start() async_register_services(hass, instance) - statistics.async_setup(hass) websocket_api.async_setup(hass) - await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) + entity_registry.async_setup(hass) + + 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/__init__.py b/homeassistant/components/recorder/auto_repairs/__init__.py new file mode 100644 index 0000000000..aa3880bf1d --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/__init__.py @@ -0,0 +1 @@ +"""Repairs for Recorder.""" 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..3cc2e74f95 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/events/schema.py @@ -0,0 +1,36 @@ +"""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_has_correct_collation, + 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.""" + schema_errors = validate_table_schema_supports_utf8( + instance, EventData, (EventData.shared_data,) + ) | validate_db_schema_precision(instance, Events) + for table in (Events, EventData): + schema_errors |= validate_table_schema_has_correct_collation(instance, table) + return schema_errors + + +def correct_db_schema( + instance: Recorder, + schema_errors: set[str], +) -> None: + """Correct issues detected by validate_db_schema.""" + for table in (Events, EventData): + correct_db_schema_utf8(instance, table, schema_errors) + correct_db_schema_precision(instance, Events, schema_errors) diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py new file mode 100644 index 0000000000..aa036f3399 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -0,0 +1,276 @@ +"""Schema repairs.""" +from __future__ import annotations + +from collections.abc import Iterable, Mapping +import logging +from typing import TYPE_CHECKING + +from sqlalchemy import MetaData +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm.attributes import InstrumentedAttribute + +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_has_correct_collation( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Verify the table has the correct collation.""" + schema_errors: set[str] = set() + # Lack of full utf8 support is only an issue for MySQL / MariaDB + if instance.dialect_name != SupportedDialect.MYSQL: + return schema_errors + + try: + schema_errors = _validate_table_schema_has_correct_collation( + instance, table_object + ) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + + _log_schema_errors(table_object, schema_errors) + return schema_errors + + +def _validate_table_schema_has_correct_collation( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Ensure the table has the correct collation to avoid union errors with mixed collations.""" + schema_errors: set[str] = set() + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=instance.get_session(), read_only=True) as session: + table = table_object.__tablename__ + metadata_obj = MetaData() + connection = session.connection() + metadata_obj.reflect(bind=connection) + dialect_kwargs = metadata_obj.tables[table].dialect_kwargs + # Check if the table has a collation set, if its not set than its + # using the server default collation for the database + + collate = ( + dialect_kwargs.get("mysql_collate") + or dialect_kwargs.get( + "mariadb_collate" + ) # pylint: disable-next=protected-access + or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] + ) + if collate and collate != "utf8mb4_unicode_ci": + _LOGGER.debug( + "Database %s collation is not utf8mb4_unicode_ci", + table, + ) + schema_errors.add(f"{table}.utf8mb4_unicode_ci") + return schema_errors + + +def _validate_table_schema_supports_utf8( + instance: Recorder, + table_object: type[DeclarativeBase], + 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 + or f"{table_name}.utf8mb4_unicode_ci" 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..3c0daef452 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/states/schema.py @@ -0,0 +1,42 @@ +"""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_has_correct_collation, + 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) + for table in (States, StateAttributes): + schema_errors |= validate_table_schema_has_correct_collation(instance, table) + 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/__init__.py b/homeassistant/components/recorder/auto_repairs/statistics/__init__.py new file mode 100644 index 0000000000..64bfd4fbb2 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/statistics/__init__.py @@ -0,0 +1 @@ +"""Statistics repairs for Recorder.""" diff --git a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py new file mode 100644 index 0000000000..8a24dcbf92 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py @@ -0,0 +1,261 @@ +"""Statistics duplication repairs.""" +from __future__ import annotations + +import json +import logging +import os +from typing import TYPE_CHECKING + +from sqlalchemy import func +from sqlalchemy.engine.row import Row +from sqlalchemy.orm.session import Session +from sqlalchemy.sql.expression import literal_column + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.storage import STORAGE_DIR +from homeassistant.util import dt as dt_util + +from ...const import SQLITE_MAX_BIND_VARS +from ...db_schema import Statistics, StatisticsBase, StatisticsMeta, StatisticsShortTerm +from ...util import database_job_retry_wrapper, execute + +if TYPE_CHECKING: + from ... import Recorder + +_LOGGER = logging.getLogger(__name__) + + +def _find_duplicates( + session: Session, table: type[StatisticsBase] +) -> tuple[list[int], list[dict]]: + """Find duplicated statistics.""" + subquery = ( + session.query( + table.start, + table.metadata_id, + literal_column("1").label("is_duplicate"), + ) + .group_by(table.metadata_id, table.start) + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + .having(func.count() > 1) + .subquery() + ) + query = ( + session.query( + table.id, + table.metadata_id, + table.created, + table.start, + table.mean, + table.min, + table.max, + table.last_reset, + table.state, + table.sum, + ) + .outerjoin( + subquery, + (subquery.c.metadata_id == table.metadata_id) + & (subquery.c.start == table.start), + ) + .filter(subquery.c.is_duplicate == 1) + .order_by(table.metadata_id, table.start, table.id.desc()) + .limit(1000 * SQLITE_MAX_BIND_VARS) + ) + duplicates = execute(query) + original_as_dict = {} + start = None + metadata_id = None + duplicate_ids: list[int] = [] + non_identical_duplicates_as_dict: list[dict] = [] + + if not duplicates: + return (duplicate_ids, non_identical_duplicates_as_dict) + + def columns_to_dict(duplicate: Row) -> dict: + """Convert a SQLAlchemy row to dict.""" + dict_ = {} + for key in ( + "id", + "metadata_id", + "start", + "created", + "mean", + "min", + "max", + "last_reset", + "state", + "sum", + ): + dict_[key] = getattr(duplicate, key) + return dict_ + + def compare_statistic_rows(row1: dict, row2: dict) -> bool: + """Compare two statistics rows, ignoring id and created.""" + ignore_keys = {"id", "created"} + keys1 = set(row1).difference(ignore_keys) + keys2 = set(row2).difference(ignore_keys) + return keys1 == keys2 and all(row1[k] == row2[k] for k in keys1) + + for duplicate in duplicates: + if start != duplicate.start or metadata_id != duplicate.metadata_id: + original_as_dict = columns_to_dict(duplicate) + start = duplicate.start + metadata_id = duplicate.metadata_id + continue + duplicate_as_dict = columns_to_dict(duplicate) + duplicate_ids.append(duplicate.id) + if not compare_statistic_rows(original_as_dict, duplicate_as_dict): + non_identical_duplicates_as_dict.append( + {"duplicate": duplicate_as_dict, "original": original_as_dict} + ) + + return (duplicate_ids, non_identical_duplicates_as_dict) + + +def _delete_duplicates_from_table( + session: Session, table: type[StatisticsBase] +) -> tuple[int, list[dict]]: + """Identify and delete duplicated statistics from a specified table.""" + all_non_identical_duplicates: list[dict] = [] + total_deleted_rows = 0 + while True: + duplicate_ids, non_identical_duplicates = _find_duplicates(session, table) + if not duplicate_ids: + break + all_non_identical_duplicates.extend(non_identical_duplicates) + for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): + deleted_rows = ( + session.query(table) + .filter(table.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS])) + .delete(synchronize_session=False) + ) + total_deleted_rows += deleted_rows + return (total_deleted_rows, all_non_identical_duplicates) + + +@database_job_retry_wrapper("delete statistics duplicates", 3) +def delete_statistics_duplicates( + instance: Recorder, hass: HomeAssistant, session: Session +) -> None: + """Identify and delete duplicated statistics. + + A backup will be made of duplicated statistics before it is deleted. + """ + deleted_statistics_rows, non_identical_duplicates = _delete_duplicates_from_table( + session, Statistics + ) + if deleted_statistics_rows: + _LOGGER.info("Deleted %s duplicated statistics rows", deleted_statistics_rows) + + if non_identical_duplicates: + isotime = dt_util.utcnow().isoformat() + backup_file_name = f"deleted_statistics.{isotime}.json" + backup_path = hass.config.path(STORAGE_DIR, backup_file_name) + + os.makedirs(os.path.dirname(backup_path), exist_ok=True) + with open(backup_path, "w", encoding="utf8") as backup_file: + json.dump( + non_identical_duplicates, + backup_file, + indent=4, + sort_keys=True, + cls=JSONEncoder, + ) + _LOGGER.warning( + ( + "Deleted %s non identical duplicated %s rows, a backup of the deleted" + " rows has been saved to %s" + ), + len(non_identical_duplicates), + Statistics.__tablename__, + backup_path, + ) + + deleted_short_term_statistics_rows, _ = _delete_duplicates_from_table( + session, StatisticsShortTerm + ) + if deleted_short_term_statistics_rows: + _LOGGER.warning( + "Deleted duplicated short term statistic rows, please report at %s", + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+recorder%22", + ) + + +def _find_statistics_meta_duplicates(session: Session) -> list[int]: + """Find duplicated statistics_meta.""" + # When querying the database, be careful to only explicitly query for columns + # which were present in schema version 29. If querying the table, SQLAlchemy + # will refer to future columns. + subquery = ( + session.query( + StatisticsMeta.statistic_id, + literal_column("1").label("is_duplicate"), + ) + .group_by(StatisticsMeta.statistic_id) + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + .having(func.count() > 1) + .subquery() + ) + query = ( + session.query(StatisticsMeta.statistic_id, StatisticsMeta.id) + .outerjoin( + subquery, + (subquery.c.statistic_id == StatisticsMeta.statistic_id), + ) + .filter(subquery.c.is_duplicate == 1) + .order_by(StatisticsMeta.statistic_id, StatisticsMeta.id.desc()) + .limit(1000 * SQLITE_MAX_BIND_VARS) + ) + duplicates = execute(query) + statistic_id = None + duplicate_ids: list[int] = [] + + if not duplicates: + return duplicate_ids + + for duplicate in duplicates: + if statistic_id != duplicate.statistic_id: + statistic_id = duplicate.statistic_id + continue + duplicate_ids.append(duplicate.id) + + return duplicate_ids + + +def _delete_statistics_meta_duplicates(session: Session) -> int: + """Identify and delete duplicated statistics from a specified table.""" + total_deleted_rows = 0 + while True: + duplicate_ids = _find_statistics_meta_duplicates(session) + if not duplicate_ids: + break + for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): + deleted_rows = ( + session.query(StatisticsMeta) + .filter( + StatisticsMeta.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS]) + ) + .delete(synchronize_session=False) + ) + total_deleted_rows += deleted_rows + return total_deleted_rows + + +@database_job_retry_wrapper("delete statistics meta duplicates", 3) +def delete_statistics_meta_duplicates(instance: Recorder, session: Session) -> None: + """Identify and delete duplicated statistics_meta. + + This is used when migrating from schema version 28 to schema version 29. + """ + deleted_statistics_rows = _delete_statistics_meta_duplicates(session) + if deleted_statistics_rows: + statistics_meta_manager = instance.statistics_meta_manager + statistics_meta_manager.reset() + statistics_meta_manager.load(session) + _LOGGER.info( + "Deleted %s duplicated statistics_meta rows", deleted_statistics_rows + ) diff --git a/homeassistant/components/recorder/auto_repairs/statistics/schema.py b/homeassistant/components/recorder/auto_repairs/statistics/schema.py new file mode 100644 index 0000000000..607935bd6f --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/statistics/schema.py @@ -0,0 +1,46 @@ +"""Statistics schema repairs.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +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_has_correct_collation, + validate_table_schema_supports_utf8, +) + +if TYPE_CHECKING: + from ... import Recorder + +_LOGGER = logging.getLogger(__name__) + + +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() + schema_errors |= validate_table_schema_supports_utf8( + instance, StatisticsMeta, (StatisticsMeta.statistic_id,) + ) + for table in (Statistics, StatisticsShortTerm): + schema_errors |= validate_db_schema_precision(instance, table) + schema_errors |= validate_table_schema_has_correct_collation(instance, table) + if schema_errors: + _LOGGER.debug( + "Detected statistics schema errors: %s", ", ".join(sorted(schema_errors)) + ) + return schema_errors + + +def correct_db_schema( + instance: Recorder, + schema_errors: set[str], +) -> None: + """Correct issues detected by validate_db_schema.""" + correct_db_schema_utf8(instance, StatisticsMeta, schema_errors) + for table in (Statistics, StatisticsShortTerm): + correct_db_schema_precision(instance, table, schema_errors) + correct_db_schema_utf8(instance, table, schema_errors) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 379185bec7..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 @@ -27,7 +29,7 @@ MAX_QUEUE_BACKLOG = 65000 # in https://github.com/sqlite/sqlite/commit/efdba1a8b3c6c967e7fae9c1989c40d420ce64cc # We can increase this back to 1000 once most # have upgraded their sqlite version -MAX_ROWS_TO_PURGE = 998 +SQLITE_MAX_BIND_VARS = 998 DB_WORKER_PREFIX = "DbWorker" @@ -43,6 +45,27 @@ KEEPALIVE_TIME = 30 EXCLUDE_ATTRIBUTES = f"{DOMAIN}_exclude_attributes_by_domain" +STATISTICS_ROWS_SCHEMA_VERSION = 23 +CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36 +EVENT_TYPE_IDS_SCHEMA_VERSION = 37 +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 7070df6f94..43915c0187 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -14,9 +14,10 @@ import time from typing import Any, TypeVar, cast import async_timeout -from lru import LRU # pylint: disable=no-name-in-module -from sqlalchemy import create_engine, event as sqlalchemy_event, exc, func, select +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 @@ -24,13 +25,12 @@ 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, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers.entity import entity_sources from homeassistant.helpers.event import ( async_track_time_change, async_track_time_interval, @@ -39,58 +39,83 @@ from homeassistant.helpers.event import ( from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import UNDEFINED, UndefinedType import homeassistant.util.dt as dt_util -from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS +from homeassistant.util.enum import try_parse_enum from . import migration, statistics 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, Base, EventData, Events, + EventTypes, StateAttributes, States, + StatesMeta, Statistics, - StatisticsRuns, StatisticsShortTerm, ) from .executor import DBInterruptibleThreadPoolExecutor -from .models import ( - DatabaseEngine, - StatisticData, - StatisticMetaData, - UnsupportedDialect, - process_timestamp, -) +from .models import DatabaseEngine, StatisticData, StatisticMetaData, UnsupportedDialect from .pool import POOL_SIZE, MutexPool, RecorderPool -from .queries import find_shared_attributes_id, find_shared_data_id -from .run_history import RunHistory +from .queries import ( + has_entity_ids_to_migrate, + has_event_type_to_migrate, + has_events_context_ids_to_migrate, + has_states_context_ids_to_migrate, +) +from .table_managers.event_data import EventDataManager +from .table_managers.event_types import EventTypeManager +from .table_managers.recorder_runs import RecorderRunsManager +from .table_managers.state_attributes import StateAttributesManager +from .table_managers.states import StatesManager +from .table_managers.states_meta import StatesMetaManager +from .table_managers.statistics_meta import StatisticsMetaManager from .tasks import ( + AdjustLRUSizeTask, AdjustStatisticsTask, ChangeStatisticsUnitTask, ClearStatisticsTask, CommitTask, + CompileMissingStatisticsTask, DatabaseLockTask, + EntityIDMigrationTask, + EntityIDPostMigrationTask, + EventIdMigrationTask, + EventsContextIDMigrationTask, EventTask, + EventTypeIDMigrationTask, ImportStatisticsTask, KeepAliveTask, PerodicCleanupTask, PurgeTask, RecorderTask, + StatesContextIDMigrationTask, StatisticsTask, StopTask, SynchronizeTask, + UpdateStatesMetadataTask, UpdateStatisticsMetadataTask, WaitTask, ) @@ -98,6 +123,8 @@ from .util import ( build_mysqldb_conv, dburl_to_path, end_incomplete_runs, + execute_stmt_lambda_element, + get_index_by_name, is_second_sunday, move_away_broken_database, session_scope, @@ -116,23 +143,15 @@ DEFAULT_URL = "sqlite:///{hass_config_path}" # States and Events objects EXPIRE_AFTER_COMMITS = 120 -# The number of attribute ids to cache in memory -# -# Based on: -# - The number of overlapping attributes -# - How frequently states with overlapping attributes will change -# - How much memory our low end hardware has -STATE_ATTRIBUTES_ID_CACHE_SIZE = 2048 -EVENT_DATA_ID_CACHE_SIZE = 2048 - SHUTDOWN_TASK = object() COMMIT_TASK = CommitTask() KEEP_ALIVE_TASK = KeepAliveTask() 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" @@ -158,13 +177,14 @@ class Recorder(threading.Thread): db_max_retries: int, db_retry_wait: int, entity_filter: Callable[[str], bool], - exclude_t: list[str], + exclude_event_types: set[str], exclude_attributes_by_domain: dict[str, set[str]], ) -> None: """Initialize the recorder.""" threading.Thread.__init__(self, name="Recorder") self.hass = hass + self.thread_id: int | None = None self.auto_purge = auto_purge self.auto_repack = auto_repack self.keep_days = keep_days @@ -184,32 +204,37 @@ class Recorder(threading.Thread): self.async_recorder_ready = asyncio.Event() self._queue_watch = threading.Event() self.engine: Engine | None = None - self.run_history = RunHistory() - self._entity_sources = entity_sources(hass) + 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 # by is_entity_recorder and the sensor recorder. self.entity_filter = entity_filter - self.exclude_t = set(exclude_t) + self.exclude_event_types = exclude_event_types self.schema_version = 0 self._commits_without_expire = 0 - self._old_states: dict[str | None, States] = {} - self._state_attributes_ids: LRU = LRU(STATE_ATTRIBUTES_ID_CACHE_SIZE) - self._event_data_ids: LRU = LRU(EVENT_DATA_ID_CACHE_SIZE) - self._pending_state_attributes: dict[str, StateAttributes] = {} - self._pending_event_data: dict[str, EventData] = {} - self._pending_expunge: list[States] = [] + + self.recorder_runs_manager = RecorderRunsManager() + self.states_manager = StatesManager() + self.event_data_manager = EventDataManager(self) + self.event_type_manager = EventTypeManager(self) + self.states_meta_manager = StatesMetaManager(self) + self.state_attributes_manager = StateAttributesManager( + self, exclude_attributes_by_domain + ) + self.statistics_meta_manager = StatisticsMetaManager(self) + self.event_session: Session | None = None self._get_session: Callable[[], Session] | None = None self._completed_first_database_setup: bool | None = None self.async_migration_event = asyncio.Event() self.migration_in_progress = False self.migration_is_live = False + self.use_legacy_events_index = False self._database_lock_task: DatabaseLockTask | None = None self._db_executor: DBInterruptibleThreadPoolExecutor | None = None - self._exclude_attributes_by_domain = exclude_attributes_by_domain self._event_listener: CALLBACK_TYPE | None = None self._queue_watcher: CALLBACK_TYPE | None = None @@ -217,6 +242,7 @@ class Recorder(threading.Thread): self._commit_listener: CALLBACK_TYPE | None = None self._periodic_listener: CALLBACK_TYPE | None = None self._nightly_listener: CALLBACK_TYPE | None = None + self._dialect_name: SupportedDialect | None = None self.enabled = True @property @@ -227,9 +253,7 @@ class Recorder(threading.Thread): @property def dialect_name(self) -> SupportedDialect | None: """Return the dialect the recorder uses.""" - with contextlib.suppress(ValueError): - return SupportedDialect(self.engine.dialect.name) if self.engine else None - return None + return self._dialect_name @property def _using_file_sqlite(self) -> bool: @@ -280,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 @@ -321,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( ( @@ -330,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.""" @@ -364,7 +414,7 @@ class Recorder(threading.Thread): @callback def _async_event_filter(self, event: Event) -> bool: """Filter events.""" - if event.event_type in self.exclude_t: + if event.event_type in self.exclude_event_types: return False if (entity_id := event.data.get(ATTR_ENTITY_ID)) is None: @@ -382,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 @@ -399,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()) @@ -417,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)", @@ -475,7 +532,24 @@ class Recorder(threading.Thread): self.queue_task(PerodicCleanupTask()) @callback - def async_periodic_statistics(self, now: datetime) -> None: + def _async_five_minute_tasks(self, now: datetime) -> None: + """Run tasks every five minutes.""" + self.queue_task(ADJUST_LRU_SIZE_TASK) + self.async_periodic_statistics() + + def _adjust_lru_size(self) -> None: + """Trigger the LRU adjustment. + + If the number of entities has increased, increase the size of the LRU + cache to avoid thrashing. + """ + new_size = self.hass.states.async_entity_ids_count() * 2 + self.state_attributes_manager.adjust_lru_size(new_size) + self.states_meta_manager.adjust_lru_size(new_size) + self.statistics_meta_manager.adjust_lru_size(new_size) + + @callback + def async_periodic_statistics(self) -> None: """Trigger the statistics run. Short term statistics run every 5 minutes @@ -518,6 +592,15 @@ class Recorder(threading.Thread): ) ) + @callback + def async_update_states_metadata( + self, + entity_id: str, + new_entity_id: str, + ) -> None: + """Update states metadata for an entity_id.""" + self.queue_task(UpdateStatesMetadataTask(entity_id, new_entity_id)) + @callback def async_change_statistics_unit( self, @@ -554,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 @@ -570,7 +659,7 @@ class Recorder(threading.Thread): # Compile short term statistics every 5 minutes self._periodic_listener = async_track_utc_time_change( - self.hass, self.async_periodic_statistics, minute=range(0, 60, 5), second=10 + self.hass, self._async_five_minute_tasks, minute=range(0, 60, 5), second=10 ) async def _async_wait_for_started(self) -> object | None: @@ -591,18 +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 @@ -613,12 +710,13 @@ class Recorder(threading.Thread): self.migration_is_live = migration.live_migration(schema_status) self.hass.add_job(self.async_connection_success) + database_was_ready = self.migration_is_live or schema_status.valid - if self.migration_is_live or schema_status.valid: + if database_was_ready: # If the migrate is live or the schema is valid, we need to # wait for startup to complete. If its not live, we need to continue # on. - self.hass.add_job(self.async_set_db_ready) + self._activate_and_set_db_ready() # We wait to start a live migration until startup has finished # since it can be cpu intensive and we do not want it to compete @@ -628,8 +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() - self.hass.add_job(self.async_set_db_ready) return if not schema_status.valid: @@ -637,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( @@ -647,35 +743,141 @@ class Recorder(threading.Thread): "Database Migration Failed", "recorder_database_migration", ) - self.hass.add_job(self.async_set_db_ready) - self._shutdown() return - self.hass.add_job(self.async_set_db_ready) + if not database_was_ready: + self._activate_and_set_db_ready() # Catch up with missed statistics - with session_scope(session=self.get_session()) as session: - self._schedule_compile_missing_statistics(session) - + self._schedule_compile_missing_statistics() _LOGGER.debug("Recorder processing the queue") + self._adjust_lru_size() self.hass.add_job(self._async_set_recorder_ready_migration_done) self._run_event_loop() + def _activate_and_set_db_ready(self) -> None: + """Activate the table managers or schedule migrations and mark the db as ready.""" + with session_scope(session=self.get_session(), read_only=True) as session: + # Prime the statistics meta manager as soon as possible + # since we want the frontend queries to avoid a thundering + # herd of queries to find the statistics meta data if + # there are a lot of statistics graphs on the frontend. + if self.schema_version >= STATISTICS_ROWS_SCHEMA_VERSION: + self.statistics_meta_manager.load(session) + + if ( + self.schema_version < CONTEXT_ID_AS_BINARY_SCHEMA_VERSION + or execute_stmt_lambda_element( + session, has_states_context_ids_to_migrate() + ) + ): + self.queue_task(StatesContextIDMigrationTask()) + + if ( + self.schema_version < CONTEXT_ID_AS_BINARY_SCHEMA_VERSION + or execute_stmt_lambda_element( + session, has_events_context_ids_to_migrate() + ) + ): + self.queue_task(EventsContextIDMigrationTask()) + + if ( + self.schema_version < EVENT_TYPE_IDS_SCHEMA_VERSION + or execute_stmt_lambda_element(session, has_event_type_to_migrate()) + ): + self.queue_task(EventTypeIDMigrationTask()) + else: + _LOGGER.debug("Activating event_types manager as all data is migrated") + self.event_type_manager.active = True + + if ( + self.schema_version < STATES_META_SCHEMA_VERSION + or execute_stmt_lambda_element(session, has_entity_ids_to_migrate()) + ): + self.queue_task(EntityIDMigrationTask()) + 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): + # If the index of event_ids on the states table is still present + # we need to queue a task to remove it. + if get_index_by_name( + session, TABLE_STATES, LEGACY_STATES_EVENT_ID_INDEX + ): + self.queue_task(EventIdMigrationTask()) + self.use_legacy_events_index = True + + # We must only set the db ready after we have set the table managers + # to active if there is no data to migrate. + # + # This ensures that the history queries will use the new tables + # and not the old ones as soon as the API is available. + self.hass.add_job(self.async_set_db_ready) + def _run_event_loop(self) -> None: """Run the event loop for the recorder.""" # Use a session for the event read loop # with a commit every time the event time # has changed. This reduces the disk io. + queue_ = self._queue + startup_tasks: list[RecorderTask] = [] + while not queue_.empty() and (task := queue_.get_nowait()): + startup_tasks.append(task) + self._pre_process_startup_tasks(startup_tasks) + 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: - task = self._queue.get() - _LOGGER.debug("Processing task: %s", task) - try: - self._process_one_task_or_recover(task) - except Exception as err: # pylint: disable=broad-except - _LOGGER.exception("Error while processing event %s: %s", task, err) + self._guarded_process_one_task_or_recover(queue_.get()) - self._shutdown() + def _pre_process_startup_tasks(self, startup_tasks: list[RecorderTask]) -> None: + """Pre process startup tasks.""" + # Prime all the state_attributes and event_data caches + # before we start processing events + state_change_events: list[Event] = [] + non_state_change_events: list[Event] = [] + + for task in startup_tasks: + if isinstance(task, EventTask): + event_ = task.event + if event_.event_type == EVENT_STATE_CHANGED: + state_change_events.append(event_) + else: + non_state_change_events.append(event_) + + assert self.event_session is not None + session = self.event_session + self.event_data_manager.load(non_state_change_events, session) + self.event_type_manager.load(non_state_change_events, session) + self.states_meta_manager.load(state_change_events, session) + self.state_attributes_manager.load(state_change_events, session) + + def _guarded_process_one_task_or_recover(self, task: RecorderTask) -> None: + """Process a task, guarding against exceptions to ensure the loop does not collapse.""" + _LOGGER.debug("Processing task: %s", task) + try: + self._process_one_task_or_recover(task) + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Error while processing event %s: %s", task, err) def _process_one_task_or_recover(self, task: RecorderTask) -> None: """Process an event, reconnect, or recover a malformed database.""" @@ -772,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 @@ -797,133 +1001,124 @@ class Recorder(threading.Thread): if not self.commit_interval: self._commit_event_session_or_retry() - def _find_shared_attr_in_db(self, attr_hash: int, shared_attrs: str) -> int | None: - """Find shared attributes in the db from the hash and shared_attrs.""" - # - # Avoid the event session being flushed since it will - # commit all the pending events and states to the database. - # - # The lookup has already have checked to see if the data is cached - # or going to be written in the next commit so there is no - # need to flush before checking the database. - # - assert self.event_session is not None - with self.event_session.no_autoflush: - if attributes_id := self.event_session.execute( - find_shared_attributes_id(attr_hash, shared_attrs) - ).first(): - return cast(int, attributes_id[0]) - return None - - def _find_shared_data_in_db(self, data_hash: int, shared_data: str) -> int | None: - """Find shared event data in the db from the hash and shared_attrs.""" - # - # Avoid the event session being flushed since it will - # commit all the pending events and states to the database. - # - # The lookup has already have checked to see if the data is cached - # or going to be written in the next commit so there is no - # need to flush before checking the database. - # - assert self.event_session is not None - with self.event_session.no_autoflush: - if data_id := self.event_session.execute( - find_shared_data_id(data_hash, shared_data) - ).first(): - return cast(int, data_id[0]) - return None - def _process_non_state_changed_event_into_session(self, event: Event) -> None: """Process any event into the session except state changed.""" - assert self.event_session is not None + session = self.event_session + assert session is not None dbevent = Events.from_event(event) + + # Map the event_type to the EventTypes table + 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, True): + dbevent.event_type_id = event_type_id + else: + event_types = EventTypes(event_type=event.event_type) + event_type_manager.add_pending(event_types) + session.add(event_types) + dbevent.event_type_rel = event_types + if not event.data: - self.event_session.add(dbevent) + session.add(dbevent) return - try: - shared_data_bytes = EventData.shared_data_bytes_from_event( - event, self.dialect_name - ) - except JSON_ENCODE_EXCEPTIONS as ex: - _LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex) + event_data_manager = self.event_data_manager + if not (shared_data_bytes := event_data_manager.serialize_from_event(event)): return + # Map the event data to the EventData table shared_data = shared_data_bytes.decode("utf-8") # Matching attributes found in the pending commit - if pending_event_data := self._pending_event_data.get(shared_data): + if pending_event_data := event_data_manager.get_pending(shared_data): dbevent.event_data_rel = pending_event_data # Matching attributes id found in the cache - elif data_id := self._event_data_ids.get(shared_data): + elif (data_id := event_data_manager.get_from_cache(shared_data)) or ( + (hash_ := EventData.hash_shared_data_bytes(shared_data_bytes)) + and (data_id := event_data_manager.get(shared_data, hash_, session)) + ): dbevent.data_id = data_id else: - data_hash = EventData.hash_shared_data_bytes(shared_data_bytes) - # Matching attributes found in the database - if data_id := self._find_shared_data_in_db(data_hash, shared_data): - self._event_data_ids[shared_data] = dbevent.data_id = data_id # No matching attributes found, save them in the DB - else: - dbevent_data = EventData(shared_data=shared_data, hash=data_hash) - dbevent.event_data_rel = self._pending_event_data[ - shared_data - ] = dbevent_data - self.event_session.add(dbevent_data) + dbevent_data = EventData(shared_data=shared_data, hash=hash_) + event_data_manager.add_pending(dbevent_data) + session.add(dbevent_data) + dbevent.event_data_rel = dbevent_data - self.event_session.add(dbevent) + session.add(dbevent) def _process_state_changed_event_into_session(self, event: Event) -> None: """Process a state_changed event into the session.""" - assert self.event_session is not None - try: - dbstate = States.from_event(event) - shared_attrs_bytes = StateAttributes.shared_attrs_bytes_from_event( - event, - self._entity_sources, - self._exclude_attributes_by_domain, - self.dialect_name, - ) - except JSON_ENCODE_EXCEPTIONS as ex: - _LOGGER.warning( - "State is not JSON serializable: %s: %s", - event.data.get("new_state"), - ex, - ) + state_attributes_manager = self.state_attributes_manager + states_meta_manager = self.states_meta_manager + entity_removed = not event.data.get("new_state") + entity_id = event.data["entity_id"] + + dbstate = States.from_event(event) + + states_manager = self.states_manager + if old_state := states_manager.pop_pending(entity_id): + dbstate.old_state = old_state + elif old_state_id := states_manager.pop_committed(entity_id): + dbstate.old_state_id = old_state_id + if entity_removed: + dbstate.state = None + else: + states_manager.add_pending(entity_id, dbstate) + + if states_meta_manager.active: + dbstate.entity_id = None + + if entity_id is None or not ( + shared_attrs_bytes := state_attributes_manager.serialize_from_event(event) + ): return + assert self.event_session is not None + session = self.event_session + # Map the entity_id to the StatesMeta table + if pending_states_meta := states_meta_manager.get_pending(entity_id): + dbstate.states_meta_rel = pending_states_meta + elif metadata_id := states_meta_manager.get(entity_id, session, True): + dbstate.metadata_id = metadata_id + elif states_meta_manager.active and entity_removed: + # If the entity was removed, we don't need to add it to the + # StatesMeta table or record it in the pending commit + # if it does not have a metadata_id allocated to it as + # it either never existed or was just renamed. + return + else: + states_meta = StatesMeta(entity_id=entity_id) + states_meta_manager.add_pending(states_meta) + session.add(states_meta) + dbstate.states_meta_rel = states_meta + + # Map the event data to the StateAttributes table shared_attrs = shared_attrs_bytes.decode("utf-8") dbstate.attributes = None # Matching attributes found in the pending commit - if pending_attributes := self._pending_state_attributes.get(shared_attrs): - dbstate.state_attributes = pending_attributes + if pending_event_data := state_attributes_manager.get_pending(shared_attrs): + dbstate.state_attributes = pending_event_data # Matching attributes id found in the cache - elif attributes_id := self._state_attributes_ids.get(shared_attrs): + elif ( + attributes_id := state_attributes_manager.get_from_cache(shared_attrs) + ) or ( + (hash_ := StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes)) + and ( + attributes_id := state_attributes_manager.get( + shared_attrs, hash_, session + ) + ) + ): dbstate.attributes_id = attributes_id else: - attr_hash = StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) - # Matching attributes found in the database - if attributes_id := self._find_shared_attr_in_db(attr_hash, shared_attrs): - dbstate.attributes_id = attributes_id - self._state_attributes_ids[shared_attrs] = attributes_id # No matching attributes found, save them in the DB - else: - dbstate_attributes = StateAttributes( - shared_attrs=shared_attrs, hash=attr_hash - ) - dbstate.state_attributes = dbstate_attributes - self._pending_state_attributes[shared_attrs] = dbstate_attributes - self.event_session.add(dbstate_attributes) + dbstate_attributes = StateAttributes(shared_attrs=shared_attrs, hash=hash_) + state_attributes_manager.add_pending(dbstate_attributes) + session.add(dbstate_attributes) + dbstate.state_attributes = dbstate_attributes - if old_state := self._old_states.pop(dbstate.entity_id, None): - if old_state.state_id: - dbstate.old_state_id = old_state.state_id - else: - dbstate.old_state = old_state - if event.data.get("new_state"): - self._old_states[dbstate.entity_id] = dbstate - self._pending_expunge.append(dbstate) - else: - dbstate.state = None - self.event_session.add(dbstate) + session.add(dbstate) def _handle_database_error(self, err: Exception) -> bool: """Handle a database error that may result in moving away the corrupt db.""" @@ -936,9 +1131,9 @@ class Recorder(threading.Thread): return False def _event_session_has_pending_writes(self) -> bool: - return bool( - self.event_session and (self.event_session.new or self.event_session.dirty) - ) + """Return True if there are pending writes in the event session.""" + session = self.event_session + return bool(session and (session.new or session.dirty)) def _commit_event_session_or_retry(self) -> None: """Commit the event session if there is work to do.""" @@ -964,36 +1159,26 @@ class Recorder(threading.Thread): def _commit_event_session(self) -> None: assert self.event_session is not None + session = self.event_session self._commits_without_expire += 1 - self.event_session.commit() - if self._pending_expunge: - for dbstate in self._pending_expunge: - # Expunge the state so its not expired - # until we use it later for dbstate.old_state - if dbstate in self.event_session: - self.event_session.expunge(dbstate) - self._pending_expunge = [] - + session.commit() # We just committed the state attributes to the database # and we now know the attributes_ids. We can save # many selects for matching attributes by loading them - # into the LRU cache now. - for state_attr in self._pending_state_attributes.values(): - self._state_attributes_ids[ - state_attr.shared_attrs - ] = state_attr.attributes_id - self._pending_state_attributes = {} - for event_data in self._pending_event_data.values(): - self._event_data_ids[event_data.shared_data] = event_data.data_id - self._pending_event_data = {} + # into the LRU or committed now. + self.states_manager.post_commit_pending() + self.state_attributes_manager.post_commit_pending() + self.event_data_manager.post_commit_pending() + self.event_type_manager.post_commit_pending() + self.states_meta_manager.post_commit_pending() # Expire is an expensive operation (frequently more expensive # than the flush and commit itself) so we only # do it after EXPIRE_AFTER_COMMITS commits if self._commits_without_expire >= EXPIRE_AFTER_COMMITS: self._commits_without_expire = 0 - self.event_session.expire_all() + session.expire_all() def _handle_sqlite_corruption(self) -> None: """Handle the sqlite3 database being corrupt.""" @@ -1002,17 +1187,18 @@ class Recorder(threading.Thread): finally: self._close_connection() move_away_broken_database(dburl_to_path(self.db_url)) - self.run_history.reset() + self.recorder_runs_manager.reset() self._setup_recorder() self._setup_run() def _close_event_session(self) -> None: """Close the event session.""" - self._old_states = {} - self._state_attributes_ids = {} - self._event_data_ids = {} - self._pending_state_attributes = {} - self._pending_event_data = {} + self.states_manager.reset() + self.state_attributes_manager.reset() + self.event_data_manager.reset() + self.event_type_manager.reset() + self.states_meta_manager.reset() + self.statistics_meta_manager.reset() if not self.event_session: return @@ -1039,6 +1225,30 @@ class Recorder(threading.Thread): """Run post schema migration tasks.""" migration.post_schema_migration(self, old_version, new_version) + def _migrate_states_context_ids(self) -> bool: + """Migrate states context ids if needed.""" + return migration.migrate_states_context_ids(self) + + def _migrate_events_context_ids(self) -> bool: + """Migrate events context ids if needed.""" + return migration.migrate_events_context_ids(self) + + def _migrate_event_type_ids(self) -> bool: + """Migrate event type ids if needed.""" + return migration.migrate_event_type_ids(self) + + def _migrate_entity_ids(self) -> bool: + """Migrate entity_ids if needed.""" + return migration.migrate_entity_ids(self) + + def _post_migrate_entity_ids(self) -> bool: + """Post migrate entity_ids if needed.""" + return migration.post_migrate_entity_ids(self) + + def _cleanup_legacy_states_event_ids(self) -> bool: + """Cleanup legacy event_ids if needed.""" + return migration.cleanup_legacy_states_event_ids(self) + def _send_keep_alive(self) -> None: """Send a keep alive to keep the db connection open.""" assert self.event_session is not None @@ -1124,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 @@ -1176,8 +1386,8 @@ class Recorder(threading.Thread): validate_or_move_away_sqlite_database(self.db_url) self.engine = create_engine(self.db_url, **kwargs, future=True) - - sqlalchemy_event.listen(self.engine, "connect", setup_recorder_connection) + self._dialect_name = try_parse_enum(SupportedDialect, self.engine.dialect.name) + 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)) @@ -1185,59 +1395,53 @@ 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: """Log the start of the current run and schedule any needed jobs.""" with session_scope(session=self.get_session()) as session: - end_incomplete_runs(session, self.run_history.recording_start) - self.run_history.start(session) + end_incomplete_runs(session, self.recorder_runs_manager.recording_start) + self.recorder_runs_manager.start(session) self._open_event_session() - def _schedule_compile_missing_statistics(self, session: Session) -> None: + def _schedule_compile_missing_statistics(self) -> None: """Add tasks for missing statistics runs.""" - now = dt_util.utcnow() - last_period_minutes = now.minute - now.minute % 5 - last_period = now.replace(minute=last_period_minutes, second=0, microsecond=0) - start = now - timedelta(days=self.keep_days) - start = start.replace(minute=0, second=0, microsecond=0) - - # Find the newest statistics run, if any - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - if last_run := session.query(func.max(StatisticsRuns.start)).scalar(): - start = max(start, process_timestamp(last_run) + timedelta(minutes=5)) - - # Add tasks - while start < last_period: - end = start + timedelta(minutes=5) - _LOGGER.debug("Compiling missing statistics for %s-%s", start, end) - self.queue_task(StatisticsTask(start, end >= last_period)) - start = end + self.queue_task(CompileMissingStatisticsTask()) def _end_session(self) -> None: """End the recorder session.""" if self.event_session is None: return - if self.run_history.active: - self.run_history.end(self.event_session) + if self.recorder_runs_manager.active: + self.recorder_runs_manager.end(self.event_session) try: self._commit_event_session_or_retry() except Exception as err: # pylint: disable=broad-except _LOGGER.exception("Error saving the event session during shutdown: %s", err) self.event_session.close() - self.run_history.clear() + self.recorder_runs_manager.clear() def _shutdown(self) -> None: """Save end time for current run.""" - self.hass.add_job(self._async_stop_listeners) - self._stop_executor() + _LOGGER.debug("Shutting down recorder") + 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 1cd130bbf9..0743864aaf 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -8,8 +8,9 @@ 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, @@ -20,23 +21,22 @@ from sqlalchemy import ( Identity, Index, Integer, + LargeBinary, SmallInteger, String, Text, - distinct, + 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.orm.query import RowReturningQuery -from sqlalchemy.orm.session import Session +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, ) @@ -54,8 +54,12 @@ from .models import ( StatisticData, StatisticDataTimestamp, StatisticMetaData, + bytes_to_ulid_or_none, + bytes_to_uuid_hex_or_none, datetime_to_timestamp_or_none, process_timestamp, + ulid_to_bytes_or_none, + uuid_hex_to_bytes_or_none, ) @@ -65,14 +69,16 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 35 +SCHEMA_VERSION = 41 _LOGGER = logging.getLogger(__name__) TABLE_EVENTS = "events" TABLE_EVENT_DATA = "event_data" +TABLE_EVENT_TYPES = "event_types" TABLE_STATES = "states" TABLE_STATE_ATTRIBUTES = "state_attributes" +TABLE_STATES_META = "states_meta" TABLE_RECORDER_RUNS = "recorder_runs" TABLE_SCHEMA_CHANGES = "schema_changes" TABLE_STATISTICS = "statistics" @@ -83,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 = [ @@ -90,8 +98,10 @@ ALL_TABLES = [ TABLE_STATE_ATTRIBUTES, TABLE_EVENTS, TABLE_EVENT_DATA, + TABLE_EVENT_TYPES, TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, + TABLE_STATES_META, TABLE_STATISTICS, TABLE_STATISTICS_META, TABLE_STATISTICS_RUNS, @@ -106,9 +116,46 @@ TABLES_TO_CHECK = [ ] LAST_UPDATED_INDEX_TS = "ix_states_last_updated_ts" -ENTITY_ID_LAST_UPDATED_INDEX_TS = "ix_states_entity_id_last_updated_ts" -EVENTS_CONTEXT_ID_INDEX = "ix_events_context_id" -STATES_CONTEXT_ID_INDEX = "ix_states_context_id" +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": 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): @@ -119,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] ) @@ -127,15 +187,22 @@ JSONB_VARIANT_CAST = Text().with_variant( ) DATETIME_TYPE = ( DateTime(timezone=True) - .with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql") # type: ignore[no-untyped-call] + .with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql", "mariadb") # type: ignore[no-untyped-call] .with_variant(FAST_PYSQLITE_DATETIME(), "sqlite") # type: ignore[no-untyped-call] ) DOUBLE_TYPE = ( Float() - .with_variant(mysql.DOUBLE(asdecimal=False), "mysql") # type: ignore[no-untyped-call] + .with_variant(mysql.DOUBLE(asdecimal=False), "mysql", "mariadb") # type: ignore[no-untyped-call] .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 @@ -163,42 +230,45 @@ class Events(Base): __table_args__ = ( # Used for fetching events at a specific time # see logbook - Index("ix_events_event_type_time_fired_ts", "event_type", "time_fired_ts"), - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + Index( + "ix_events_event_type_id_time_fired_ts", "event_type_id", "time_fired_ts" + ), + Index( + EVENTS_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), + _DEFAULT_TABLE_ARGS, ) __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)) - event_data: Mapped[str | None] = mapped_column( - Text().with_variant(mysql.LONGTEXT, "mysql") - ) - 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( - String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True - ) - context_user_id: Mapped[str | None] = mapped_column( - String(MAX_LENGTH_EVENT_CONTEXT_ID) - ) - context_parent_id: Mapped[str | None] = mapped_column( - 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(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") + ) event_data_rel: Mapped[EventData | None] = relationship("EventData") + event_type_rel: Mapped[EventTypes | None] = relationship("EventTypes") def __repr__(self) -> str: """Return string representation of instance for debugging.""" return ( "" ) @@ -219,22 +289,25 @@ class Events(Base): def from_event(event: Event) -> Events: """Create an event database object from a native event.""" return Events( - event_type=event.event_type, + event_type=None, event_data=None, origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), time_fired=None, time_fired_ts=dt_util.utc_to_timestamp(event.time_fired), - context_id=event.context.id, - context_user_id=event.context.user_id, - context_parent_id=event.context.parent_id, + context_id=None, + context_id_bin=ulid_to_bytes_or_none(event.context.id), + context_user_id=None, + context_user_id_bin=uuid_hex_to_bytes_or_none(event.context.user_id), + context_parent_id=None, + context_parent_id_bin=ulid_to_bytes_or_none(event.context.parent_id), ) def to_native(self, validate_entity_id: bool = True) -> Event | None: """Convert to a native HA Event.""" context = Context( - id=self.context_id, - user_id=self.context_user_id, - parent_id=self.context_parent_id, + id=bytes_to_ulid_or_none(self.context_id_bin), + 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: return Event( @@ -255,15 +328,13 @@ class Events(Base): class EventData(Base): """Event data history.""" - __table_args__ = ( - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) + __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") + Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") ) def __repr__(self) -> str: @@ -280,13 +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 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.""" @@ -300,32 +381,49 @@ class EventData(Base): return {} +class EventTypes(Base): + """Event type history.""" + + __table_args__ = (_DEFAULT_TABLE_ARGS,) + __tablename__ = TABLE_EVENT_TYPES + event_type_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + event_type: Mapped[str | None] = mapped_column( + String(MAX_LENGTH_EVENT_EVENT_TYPE), index=True, unique=True + ) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + "" + ) + + class States(Base): """State change history.""" __table_args__ = ( # Used for fetching the state of entities at a specific time # (get_states in history.py) - Index(ENTITY_ID_LAST_UPDATED_INDEX_TS, "entity_id", "last_updated_ts"), - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + Index(METADATA_ID_LAST_UPDATED_INDEX_TS, "metadata_id", "last_updated_ts"), + Index( + STATES_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), + _DEFAULT_TABLE_ARGS, ) __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)) + 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") - ) # no longer used for new rows - event_id: Mapped[int | None] = mapped_column( # no longer used for new rows - Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True - ) - 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 ) @@ -335,25 +433,27 @@ 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( - String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True - ) - context_user_id: Mapped[str | None] = mapped_column( - String(MAX_LENGTH_EVENT_CONTEXT_ID) - ) - context_parent_id: Mapped[str | None] = mapped_column( - 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(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") + ) + states_meta_rel: Mapped[StatesMeta | None] = relationship("StatesMeta") def __repr__(self) -> str: """Return string representation of instance for debugging.""" return ( - f"" @@ -379,9 +479,12 @@ class States(Base): dbstate = States( entity_id=entity_id, attributes=None, - context_id=event.context.id, - context_user_id=event.context.user_id, - context_parent_id=event.context.parent_id, + context_id=None, + context_id_bin=ulid_to_bytes_or_none(event.context.id), + context_user_id=None, + context_user_id_bin=uuid_hex_to_bytes_or_none(event.context.user_id), + context_parent_id=None, + context_parent_id_bin=ulid_to_bytes_or_none(event.context.parent_id), origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), last_updated=None, last_changed=None, @@ -405,9 +508,9 @@ class States(Base): def to_native(self, validate_entity_id: bool = True) -> State | None: """Convert to an HA state object.""" context = Context( - id=self.context_id, - user_id=self.context_user_id, - parent_id=self.context_parent_id, + id=bytes_to_ulid_or_none(self.context_id_bin), + 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: attrs = json_loads_object(self.attributes) if self.attributes else {} @@ -438,15 +541,13 @@ class States(Base): class StateAttributes(Base): """State attribute change history.""" - __table_args__ = ( - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) + __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") + Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") ) def __repr__(self) -> str: @@ -494,7 +595,7 @@ class StateAttributes(Base): @staticmethod 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.""" @@ -509,27 +610,41 @@ class StateAttributes(Base): return {} +class StatesMeta(Base): + """Metadata for states.""" + + __table_args__ = (_DEFAULT_TABLE_ARGS,) + __tablename__ = TABLE_STATES_META + metadata_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + entity_id: Mapped[str | None] = mapped_column( + String(MAX_LENGTH_STATE_ENTITY_ID), index=True, unique=True + ) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + "" + ) + + class StatisticsBase: """Statistics base class.""" id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - created: Mapped[datetime] = mapped_column( - DATETIME_TYPE, default=dt_util.utcnow - ) # No longer used - created_ts: Mapped[float] = mapped_column(TIMESTAMP_TYPE, default=time.time) + 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"), - index=True, ) - 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) @@ -610,9 +725,7 @@ class StatisticsShortTerm(Base, StatisticsBase): class StatisticsMeta(Base): """Statistics meta data.""" - __table_args__ = ( - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) + __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_STATISTICS_META id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) statistic_id: Mapped[str | None] = mapped_column( @@ -653,27 +766,6 @@ class RecorderRuns(Base): f" created='{self.created.isoformat(sep=' ', timespec='seconds')}')>" ) - def entity_ids(self, point_in_time: datetime | None = None) -> list[str]: - """Return the entity ids that existed in this run. - - Specify point_in_time if you want to know which existed at that point - in time inside the run. - """ - session = Session.object_session(self) - - assert session is not None, "RecorderRuns need to be persisted" - - query: RowReturningQuery[tuple[str]] = session.query(distinct(States.entity_id)) - - query = query.filter(States.last_updated >= self.start) - - if point_in_time is not None: - query = query.filter(States.last_updated < point_in_time) - elif self.end is not None: - query = query.filter(States.last_updated < self.end) - - return [row[0] for row in query] - def to_native(self, validate_entity_id: bool = True) -> Self: """Return self, native format is this model.""" return self @@ -730,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/entity_registry.py b/homeassistant/components/recorder/entity_registry.py new file mode 100644 index 0000000000..fbf6e69177 --- /dev/null +++ b/homeassistant/components/recorder/entity_registry.py @@ -0,0 +1,71 @@ +"""Recorder entity registry helper.""" +import logging + +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.start import async_at_start + +from .core import Recorder +from .util import get_instance, session_scope + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the entity hooks.""" + + @callback + def _async_entity_id_changed(event: Event) -> None: + instance = get_instance(hass) + old_entity_id: str = event.data["old_entity_id"] + new_entity_id: str = event.data["entity_id"] + instance.async_update_statistics_metadata( + old_entity_id, new_statistic_id=new_entity_id + ) + instance.async_update_states_metadata( + old_entity_id, new_entity_id=new_entity_id + ) + + @callback + def entity_registry_changed_filter(event: Event) -> bool: + """Handle entity_id changed filter.""" + return event.data["action"] == "update" and "old_entity_id" in event.data + + @callback + def _setup_entity_registry_event_handler(hass: HomeAssistant) -> None: + """Subscribe to event registry events.""" + hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, + _async_entity_id_changed, + event_filter=entity_registry_changed_filter, + run_immediately=True, + ) + + async_at_start(hass, _setup_entity_registry_event_handler) + + +def update_states_metadata( + instance: Recorder, + entity_id: str, + new_entity_id: str, +) -> None: + """Update the states metadata table when an entity is renamed.""" + states_meta_manager = instance.states_meta_manager + if not states_meta_manager.active: + _LOGGER.warning( + "Cannot rename entity_id `%s` to `%s` " + "because the states meta manager is not yet active", + entity_id, + new_entity_id, + ) + return + + with session_scope(session=instance.get_session()) as session: + if not states_meta_manager.update_metadata(session, entity_id, new_entity_id): + _LOGGER.warning( + "Cannot migrate history for entity_id `%s` to `%s` " + "because the new entity_id is already in use", + entity_id, + new_entity_id, + ) diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 90f7d8c0a0..24d22704a8 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable, Collection, Iterable -import json from typing import Any from sqlalchemy import Column, Text, cast, not_, or_ @@ -10,13 +9,14 @@ from sqlalchemy.sql.elements import ColumnElement from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS +from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType -from .db_schema import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States +from .db_schema import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States, StatesMeta DOMAIN = "history" HISTORY_FILTERS = "history_filters" -JSON_NULL = json.dumps(None) +JSON_NULL = json_dumps(None) GLOB_TO_SQL_CHARS = { ord("*"): "%", @@ -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,8 +206,11 @@ class Filters: # - Otherwise: exclude return i_entities - def states_entity_filter(self) -> ColumnElement | None: - """Generate the entity filter query.""" + def states_entity_filter(self) -> ColumnElement: + """Generate the States.entity_id filter query. + + This is no longer used except by the legacy queries. + """ def _encoder(data: Any) -> Any: """Nothing to encode for states since there is no json.""" @@ -203,9 +219,19 @@ 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: + """Generate the StatesMeta.entity_id filter query.""" + + def _encoder(data: Any) -> Any: + """Nothing to encode for states since there is no json.""" + return data + + # The type annotation should be improved so the type ignore can be removed + return self._generate_filter_for_columns((StatesMeta.entity_id,), _encoder) # type: ignore[arg-type] + def events_entity_filter(self) -> ColumnElement: """Generate the entity filter query.""" - _encoder = json.dumps + _encoder = json_dumps return or_( # sqlalchemy's SQLite json implementation always # wraps everything with JSON_QUOTE so it resolves to 'null' @@ -219,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/__init__.py b/homeassistant/components/recorder/history/__init__.py new file mode 100644 index 0000000000..7a569e70b1 --- /dev/null +++ b/homeassistant/components/recorder/history/__init__.py @@ -0,0 +1,183 @@ +"""Provide pre-made queries on top of the recorder component.""" +from __future__ import annotations + +from collections.abc import MutableMapping +from datetime import datetime +from typing import Any + +from sqlalchemy.orm.session import Session + +from homeassistant.core import HomeAssistant, State + +from ... import recorder +from ..filters import Filters +from .const import NEED_ATTRIBUTE_DOMAINS, SIGNIFICANT_DOMAINS +from .modern import ( + get_full_significant_states_with_session as _modern_get_full_significant_states_with_session, + get_last_state_changes as _modern_get_last_state_changes, + get_significant_states as _modern_get_significant_states, + get_significant_states_with_session as _modern_get_significant_states_with_session, + state_changes_during_period as _modern_state_changes_during_period, +) + +# These are the APIs of this package +__all__ = [ + "NEED_ATTRIBUTE_DOMAINS", + "SIGNIFICANT_DOMAINS", + "get_full_significant_states_with_session", + "get_last_state_changes", + "get_significant_states", + "get_significant_states_with_session", + "state_changes_during_period", +] + + +def get_full_significant_states_with_session( + hass: HomeAssistant, + session: Session, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + no_attributes: bool = False, +) -> MutableMapping[str, list[State]]: + """Return a dict of significant states during a time period.""" + if not recorder.get_instance(hass).states_meta_manager.active: + from .legacy import ( # pylint: disable=import-outside-toplevel + get_full_significant_states_with_session as _legacy_get_full_significant_states_with_session, + ) + + _target = _legacy_get_full_significant_states_with_session + else: + _target = _modern_get_full_significant_states_with_session + return _target( + hass, + session, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + no_attributes, + ) + + +def get_last_state_changes( + hass: HomeAssistant, number_of_states: int, entity_id: str +) -> MutableMapping[str, list[State]]: + """Return the last number_of_states.""" + if not recorder.get_instance(hass).states_meta_manager.active: + from .legacy import ( # pylint: disable=import-outside-toplevel + get_last_state_changes as _legacy_get_last_state_changes, + ) + + _target = _legacy_get_last_state_changes + else: + _target = _modern_get_last_state_changes + return _target(hass, number_of_states, entity_id) + + +def get_significant_states( + hass: HomeAssistant, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + minimal_response: bool = False, + no_attributes: bool = False, + compressed_state_format: bool = False, +) -> MutableMapping[str, list[State | dict[str, Any]]]: + """Return a dict of significant states during a time period.""" + if not recorder.get_instance(hass).states_meta_manager.active: + from .legacy import ( # pylint: disable=import-outside-toplevel + get_significant_states as _legacy_get_significant_states, + ) + + _target = _legacy_get_significant_states + else: + _target = _modern_get_significant_states + return _target( + hass, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + compressed_state_format, + ) + + +def get_significant_states_with_session( + hass: HomeAssistant, + session: Session, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + minimal_response: bool = False, + no_attributes: bool = False, + compressed_state_format: bool = False, +) -> MutableMapping[str, list[State | dict[str, Any]]]: + """Return a dict of significant states during a time period.""" + if not recorder.get_instance(hass).states_meta_manager.active: + from .legacy import ( # pylint: disable=import-outside-toplevel + get_significant_states_with_session as _legacy_get_significant_states_with_session, + ) + + _target = _legacy_get_significant_states_with_session + else: + _target = _modern_get_significant_states_with_session + return _target( + hass, + session, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + compressed_state_format, + ) + + +def state_changes_during_period( + hass: HomeAssistant, + start_time: datetime, + end_time: datetime | None = None, + entity_id: str | None = None, + no_attributes: bool = False, + descending: bool = False, + limit: int | None = None, + include_start_time_state: bool = True, +) -> MutableMapping[str, list[State]]: + """Return a list of states that changed during a time period.""" + if not recorder.get_instance(hass).states_meta_manager.active: + from .legacy import ( # pylint: disable=import-outside-toplevel + state_changes_during_period as _legacy_state_changes_during_period, + ) + + _target = _legacy_state_changes_during_period + else: + _target = _modern_state_changes_during_period + return _target( + hass, + start_time, + end_time, + entity_id, + no_attributes, + descending, + limit, + include_start_time_state, + ) diff --git a/homeassistant/components/recorder/history/common.py b/homeassistant/components/recorder/history/common.py new file mode 100644 index 0000000000..6d0150925d --- /dev/null +++ b/homeassistant/components/recorder/history/common.py @@ -0,0 +1,10 @@ +"""Common functions for history.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from ... import recorder + + +def _schema_version(hass: HomeAssistant) -> int: + return recorder.get_instance(hass).schema_version diff --git a/homeassistant/components/recorder/history/const.py b/homeassistant/components/recorder/history/const.py new file mode 100644 index 0000000000..61a615a797 --- /dev/null +++ b/homeassistant/components/recorder/history/const.py @@ -0,0 +1,22 @@ +"""Constants for history.""" + + +STATE_KEY = "state" +LAST_CHANGED_KEY = "last_changed" + +SIGNIFICANT_DOMAINS = { + "climate", + "device_tracker", + "humidifier", + "thermostat", + "water_heater", +} +SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in SIGNIFICANT_DOMAINS] +IGNORE_DOMAINS = {"zone", "scene"} +NEED_ATTRIBUTE_DOMAINS = { + "climate", + "humidifier", + "input_datetime", + "thermostat", + "water_heater", +} diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history/legacy.py similarity index 72% rename from homeassistant/components/recorder/history.py rename to homeassistant/components/recorder/history/legacy.py index 387cd40575..74b17d9daa 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -5,60 +5,44 @@ 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 from sqlalchemy import Column, Text, and_, func, lambda_stmt, or_, select from sqlalchemy.engine.row import Row -from sqlalchemy.orm.query import Query +from sqlalchemy.orm.properties import MappedColumn from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal from sqlalchemy.sql.lambdas import StatementLambdaElement -from sqlalchemy.sql.selectable import Subquery 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 -from .filters import Filters -from .models import ( - LazyState, - LazyStatePreSchema31, +from ... import recorder +from ..db_schema import RecorderRuns, StateAttributes, States +from ..filters import Filters +from ..models import ( process_datetime_to_timestamp, process_timestamp, process_timestamp_to_utc_isoformat, - row_to_compressed_state, - row_to_compressed_state_pre_schema_31, ) -from .util import execute_stmt_lambda_element, session_scope - -_LOGGER = logging.getLogger(__name__) - -STATE_KEY = "state" -LAST_CHANGED_KEY = "last_changed" - -SIGNIFICANT_DOMAINS = { - "climate", - "device_tracker", - "humidifier", - "thermostat", - "water_heater", -} -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", - "input_datetime", - "thermostat", - "water_heater", -} - +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 ( + LAST_CHANGED_KEY, + NEED_ATTRIBUTE_DOMAINS, + SIGNIFICANT_DOMAINS, + SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE, + STATE_KEY, +) _BASE_STATES = ( States.entity_id, @@ -141,13 +125,17 @@ _QUERY_STATES_NO_LAST_CHANGED = ( States.attributes, StateAttributes.shared_attrs, ) +_FIELD_MAP = { + cast(MappedColumn, field).name: idx + for idx, field in enumerate(_QUERY_STATE_NO_ATTR) +} +_FIELD_MAP_PRE_SCHEMA_31 = { + cast(MappedColumn, field).name: idx + for idx, field in enumerate(_QUERY_STATES_PRE_SCHEMA_31) +} -def _schema_version(hass: HomeAssistant) -> int: - return recorder.get_instance(hass).schema_version - - -def lambda_stmt_and_join_attributes( +def _lambda_stmt_and_join_attributes( schema_version: int, no_attributes: bool, include_last_changed: bool = True ) -> tuple[StatementLambdaElement, bool]: """Return the lambda_stmt and if StateAttributes should be joined. @@ -222,7 +210,7 @@ def get_significant_states( compressed_state_format: bool = False, ) -> MutableMapping[str, list[State | dict[str, Any]]]: """Wrap get_significant_states_with_session with an sql session.""" - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return get_significant_states_with_session( hass, session, @@ -238,34 +226,20 @@ 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: """Query the database for significant state changes.""" - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( 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 ): @@ -274,9 +248,11 @@ def _significant_states_stmt( (States.last_changed_ts == States.last_updated_ts) | States.last_changed_ts.is_(None) ) - stmt += lambda q: q.filter( - (States.last_changed == States.last_updated) | States.last_changed.is_(None) - ) + else: + stmt += lambda q: q.filter( + (States.last_changed == States.last_updated) + | States.last_changed.is_(None) + ) elif significant_changes_only: if schema_version >= 31: stmt += lambda q: q.filter( @@ -304,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() @@ -364,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, @@ -427,12 +391,12 @@ 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, ) -> StatementLambdaElement: - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=False ) if schema_version >= 31: @@ -458,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 @@ -492,10 +455,10 @@ 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 - - with session_scope(hass=hass) as session: + 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), start_time, @@ -505,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( @@ -524,7 +485,7 @@ def state_changes_during_period( def _get_last_state_changes_stmt( schema_version: int, number_of_states: int, entity_id: str ) -> StatementLambdaElement: - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, False, include_last_changed=False ) if schema_version >= 31: @@ -565,7 +526,7 @@ def get_last_state_changes( entity_id_lower = entity_id.lower() entity_ids = [entity_id_lower] - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: stmt = _get_last_state_changes_stmt( _schema_version(hass), number_of_states, entity_id_lower ) @@ -583,7 +544,7 @@ def get_last_state_changes( ) -def _get_states_for_entites_stmt( +def _get_states_for_entities_stmt( schema_version: int, run_start: datetime, utc_point_in_time: datetime, @@ -591,7 +552,7 @@ def _get_states_for_entites_stmt( no_attributes: bool, ) -> StatementLambdaElement: """Baked query to get states for specific entities.""" - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) # We got an include-list of entities, accelerate the query by filtering already @@ -599,28 +560,40 @@ def _get_states_for_entites_stmt( 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.where( - States.state_id - == ( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - select(func.max(States.state_id).label("max_state_id")) - .filter( - (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < utc_point_in_time_ts) + stmt += lambda q: q.join( + ( + most_recent_states_for_entities_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) + ) + .filter(States.entity_id.in_(entity_ids)) + .group_by(States.entity_id) + .subquery() ) - .filter(States.entity_id.in_(entity_ids)) - .group_by(States.entity_id) - .subquery() - ).c.max_state_id + ), + and_( + States.entity_id + == most_recent_states_for_entities_by_date.c.max_entity_id, + States.last_updated_ts + == most_recent_states_for_entities_by_date.c.max_last_updated, + ), ) else: - stmt += lambda q: q.where( - States.state_id - == ( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - select(func.max(States.state_id).label("max_state_id")) + stmt += lambda q: q.join( + ( + most_recent_states_for_entities_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) @@ -628,7 +601,13 @@ def _get_states_for_entites_stmt( .filter(States.entity_id.in_(entity_ids)) .group_by(States.entity_id) .subquery() - ).c.max_state_id + ), + and_( + States.entity_id + == most_recent_states_for_entities_by_date.c.max_entity_id, + States.last_updated + == most_recent_states_for_entities_by_date.c.max_last_updated, + ), ) if join_attributes: stmt += lambda q: q.outerjoin( @@ -637,124 +616,17 @@ def _get_states_for_entites_stmt( return stmt -def _generate_most_recent_states_by_date( - schema_version: int, - run_start: datetime, - utc_point_in_time: datetime, -) -> Subquery: - """Generate the sub query for the most recent states by data.""" - 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) - return ( - 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() - ) - return ( - 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() - ) - - -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. - most_recent_states_by_date = _generate_most_recent_states_by_date( - schema_version, run_start, utc_point_in_time - ) - if schema_version >= 31: - stmt += lambda q: q.where( - States.state_id - == ( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - select(func.max(States.state_id).label("max_state_id")) - .join( - most_recent_states_by_date, - 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, - ), - ) - .group_by(States.entity_id) - .subquery() - ).c.max_state_id, - ) - else: - stmt += lambda q: q.where( - States.state_id - == ( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - select(func.max(States.state_id).label("max_state_id")) - .join( - most_recent_states_by_date, - 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, - ), - ) - .group_by(States.entity_id) - .subquery() - ).c.max_state_id, - ) - 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( @@ -763,7 +635,7 @@ def _get_rows_with_session( ) if run is None: - run = recorder.get_instance(hass).run_history.get(utc_point_in_time) + 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 @@ -771,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_entites_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) @@ -791,7 +657,7 @@ def _get_single_entity_states_stmt( ) -> StatementLambdaElement: # Use an entirely different (and extremely fast) query if we only # have a single entity id - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) if schema_version >= 31: @@ -825,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, @@ -845,35 +710,34 @@ def _sorted_states_to_dict( """ schema_version = _schema_version(hass) _process_timestamp: Callable[[datetime], float | str] - field_map: dict[str, int] + field_map = _FIELD_MAP if schema_version >= 31 else _FIELD_MAP_PRE_SCHEMA_31 state_class: Callable[ [Row, dict[str, dict[str, Any]], datetime | None], State | dict[str, Any] ] 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 = { @@ -883,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)), ) @@ -908,7 +767,6 @@ def _sorted_states_to_dict( if row := initial_states.pop(ent_id, None): prev_state = row.state ent_results.append(state_class(row, attr_cache, start_time)) - field_map = {key: idx for idx, key in enumerate(row._fields)} if not minimal_response or split_entity_id(ent_id)[0] in NEED_ATTRIBUTE_DOMAINS: ent_results.extend( @@ -925,7 +783,6 @@ def _sorted_states_to_dict( continue prev_state = first_state.state ent_results.append(state_class(first_state, attr_cache, None)) - field_map = {key: idx for idx, key in enumerate(first_state._fields)} state_idx = field_map["state"] diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py new file mode 100644 index 0000000000..5322074c20 --- /dev/null +++ b/homeassistant/components/recorder/history/modern.py @@ -0,0 +1,779 @@ +"""Provide pre-made queries on top of the recorder component.""" +from __future__ import annotations + +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 ( + 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.session import Session + +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 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 ( + LAST_CHANGED_KEY, + NEED_ATTRIBUTE_DOMAINS, + SIGNIFICANT_DOMAINS, + STATE_KEY, +) + +_FIELD_MAP = { + "metadata_id": 0, + "state": 1, + "last_updated_ts": 2, +} + + +CASTABLE_DOUBLE_TYPE = ( + # MySQL/MariaDB < 10.4+ does not support casting to DOUBLE so we have to use Integer instead but it doesn't + # matter because we don't use the value as its always set to NULL + # + # sqlalchemy.exc.SAWarning: Datatype DOUBLE does not support CAST on MySQL/MariaDb; the CAST will be skipped. + # + Integer().with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") +) + + +def _stmt_and_join_attributes( + no_attributes: bool, include_last_changed: bool +) -> Select: + """Return the statement and if StateAttributes should be joined.""" + _select = select(States.metadata_id, States.state, States.last_updated_ts) + if include_last_changed: + _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( + hass: HomeAssistant, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + minimal_response: bool = False, + no_attributes: bool = False, + compressed_state_format: bool = False, +) -> MutableMapping[str, list[State | dict[str, Any]]]: + """Wrap get_significant_states_with_session with an sql session.""" + with session_scope(hass=hass, read_only=True) as session: + return get_significant_states_with_session( + hass, + session, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + compressed_state_format, + ) + + +def _significant_states_stmt( + 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], + significant_changes_only: bool, + no_attributes: bool, + include_start_time_state: bool, + run_start_ts: float | None, +) -> Select | CompoundSelect: + """Query the database for significant state changes.""" + 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. + 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) + ) + else: + stmt = stmt.filter( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) + ) + 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 = 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( + hass: HomeAssistant, + session: Session, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + minimal_response: bool = False, + no_attributes: bool = False, + compressed_state_format: bool = False, +) -> MutableMapping[str, list[State | dict[str, Any]]]: + """Return states changes during UTC period start_time - end_time. + + entity_ids is an optional iterable of entities to include in the results. + + filters is an optional SQLAlchemy filter which will be applied to the database + queries unless entity_ids is given, in which case its ignored. + + Significant states are all states where there is a state change, + 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") + entity_id_to_metadata_id: dict[str, int | None] | None = None + metadata_ids_in_significant_domains: list[int] = [] + 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( + 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, + minimal_response, + compressed_state_format, + ) + + +def get_full_significant_states_with_session( + hass: HomeAssistant, + session: Session, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + no_attributes: bool = False, +) -> MutableMapping[str, list[State]]: + """Variant of get_significant_states_with_session. + + Difference with get_significant_states_with_session is that it does not + return minimal responses. + """ + return cast( + MutableMapping[str, list[State]], + get_significant_states_with_session( + hass=hass, + session=session, + start_time=start_time, + end_time=end_time, + entity_ids=entity_ids, + filters=filters, + include_start_time_state=include_start_time_state, + significant_changes_only=significant_changes_only, + minimal_response=False, + no_attributes=no_attributes, + ), + ) + + +def _state_changed_during_period_stmt( + start_time_ts: float, + end_time_ts: float | None, + single_metadata_id: int, + no_attributes: bool, + limit: int | 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) + ) + .filter(States.metadata_id == single_metadata_id) + ) + 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 limit: + 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( + hass: HomeAssistant, + start_time: datetime, + end_time: datetime | None = None, + entity_id: str | None = None, + no_attributes: bool = False, + descending: bool = False, + limit: int | None = None, + include_start_time_state: bool = True, +) -> MutableMapping[str, list[State]]: + """Return states changes during UTC period start_time - end_time.""" + 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: + 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( + 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, + descending=descending, + ), + ) + + +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()) + ) + + +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( + hass: HomeAssistant, number_of_states: int, entity_id: str +) -> MutableMapping[str, list[State]]: + """Return the last number_of_states.""" + 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 ( + 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} + 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( + reversed(states), + None, + entity_ids, + entity_id_to_metadata_id, + ), + ) + + +def _get_start_time_state_for_entities_stmt( + run_start_ts: float, + epoch_time: float, + metadata_ids: list[int], + no_attributes: bool, + include_last_changed: bool, +) -> Select: + """Baked query to get states for specific entities.""" + # We got an include-list of entities, accelerate the query by filtering already + # in the inner query. + stmt = _stmt_and_join_attributes_for_start_state( + no_attributes, include_last_changed + ).join( + ( + most_recent_states_for_entities_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 < epoch_time) + ) + .filter(States.metadata_id.in_(metadata_ids)) + .group_by(States.metadata_id) + .subquery() + ) + ), + and_( + States.metadata_id + == most_recent_states_for_entities_by_date.c.max_metadata_id, + States.last_updated_ts + == most_recent_states_for_entities_by_date.c.max_last_updated, + ), + ) + if no_attributes: + return stmt + return stmt.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) + + +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, + include_last_changed: bool, +) -> Select: + """Return the states at a specific point in time.""" + 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, + ) + # We have more than one entity to look at so we need to do a query on states + # since the last recorder run started. + return _get_start_time_state_for_entities_stmt( + run_start_ts, + epoch_time, + metadata_ids, + no_attributes, + include_last_changed, + ) + + +def _get_single_entity_start_time_stmt( + epoch_time: float, + metadata_id: int, + 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 = ( + _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 no_attributes: + return stmt + return stmt.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + + +def _sorted_states_to_dict( + states: Iterable[Row], + start_time_ts: float | None, + entity_ids: list[str], + entity_id_to_metadata_id: dict[str, int | None], + minimal_response: 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. + + This takes our state list and turns it into a JSON friendly data + structure {'entity_id': [list of states], 'entity_id2': [list of states]} + + States must be sorted by entity_id and last_updated + + We also need to go back and create a synthetic zero data point for + each list of states, otherwise our graphs won't start on the Y + axis correctly. + """ + field_map = _FIELD_MAP + state_class: Callable[ + [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 + attr_time = COMPRESSED_STATE_LAST_UPDATED + attr_state = COMPRESSED_STATE_STATE + else: + state_class = LazyState + attr_time = LAST_CHANGED_KEY + attr_state = STATE_KEY + + # Set all entity IDs to empty lists in result set to maintain the order + 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 + 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(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]] = {} + ent_results = result[entity_id] + if ( + not minimal_response + or split_entity_id(entity_id)[0] in NEED_ATTRIBUTE_DOMAINS + ): + ent_results.extend( + 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 + # "last_changed". + if not ent_results: + if (first_state := next(group, None)) is None: + continue + prev_state = first_state[state_idx] + ent_results.append( + state_class( + first_state, + attr_cache, + start_time_ts, + entity_id, + prev_state, # type: ignore[arg-type] + first_state[last_updated_ts_idx], + ) + ) + + # + # minimal_response only makes sense with last_updated == last_updated + # + # We use last_updated for for last_changed since its the same + # + # With minimal response we do not care about attribute + # changes so we can filter out duplicate states + if compressed_state_format: + # Compressed state format uses the timestamp directly + ent_results.extend( + { + attr_state: (prev_state := state), + attr_time: row[last_updated_ts_idx], + } + for row in group + if (state := row[state_idx]) != prev_state + ) + continue + + # Non-compressed state format returns an ISO formatted string + _utc_from_timestamp = dt_util.utc_from_timestamp + ent_results.extend( + { + attr_state: (prev_state := state), # noqa: F841 + attr_time: _utc_from_timestamp(row[last_updated_ts_idx]).isoformat(), + } + for row in group + if (state := row[state_idx]) != prev_state + ) + + 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 87c889b9c8..a932131cb0 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.3", "fnvhash==0.1.0"] + "requirements": [ + "sqlalchemy==2.0.13", + "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 a3a609a1b6..b8436da97d 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -6,13 +6,16 @@ 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 import sqlalchemy -from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text +from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text, update from sqlalchemy.engine import CursorResult, Engine from sqlalchemy.exc import ( DatabaseError, + IntegrityError, InternalError, OperationalError, ProgrammingError, @@ -23,43 +26,111 @@ from sqlalchemy.schema import AddConstraint, DropConstraint 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_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, +) +from .auto_repairs.statistics.schema import ( + correct_db_schema as statistics_correct_db_schema, + validate_db_schema as statistics_validate_db_schema, +) 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, Base, + Events, + EventTypes, SchemaChanges, + States, + StatesMeta, Statistics, StatisticsMeta, StatisticsRuns, StatisticsShortTerm, ) from .models import process_timestamp -from .statistics import ( - correct_db_schema as statistics_correct_db_schema, - delete_statistics_duplicates, - delete_statistics_meta_duplicates, - get_start_time, - validate_db_schema as statistics_validate_db_schema, +from .queries import ( + batch_cleanup_entity_ids, + find_entity_ids_to_migrate, + find_event_type_to_migrate, + find_events_context_ids_to_migrate, + find_states_context_ids_to_migrate, + has_used_states_event_ids, ) +from .statistics import get_start_time from .tasks import ( CommitTask, PostSchemaMigrationTask, StatisticsTimestampMigrationCleanupTask, ) -from .util import session_scope +from .util import ( + database_job_retry_wrapper, + get_index_by_name, + retryable_database_job, + session_scope, +) if TYPE_CHECKING: from . import Recorder LIVE_MIGRATION_MIN_SCHEMA_VERSION = 0 - +_EMPTY_ENTITY_ID = "missing.entity_id" +_EMPTY_EVENT_TYPE = "missing_event_type" _LOGGER = logging.getLogger(__name__) +@dataclass +class _ColumnTypesForDialect: + big_int_type: str + timestamp_type: str + context_bin_type: str + + +_MYSQL_COLUMN_TYPES = _ColumnTypesForDialect( + big_int_type="INTEGER(20)", + 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_TYPE_SQL, + context_bin_type="BYTEA", +) + +_SQLITE_COLUMN_TYPES = _ColumnTypesForDialect( + big_int_type="INTEGER", + timestamp_type="FLOAT", + context_bin_type="BLOB", +) + +_COLUMN_TYPES_FOR_DIALECT: dict[SupportedDialect | None, _ColumnTypesForDialect] = { + SupportedDialect.MYSQL: _MYSQL_COLUMN_TYPES, + SupportedDialect.POSTGRESQL: _POSTGRESQL_COLUMN_TYPES, + SupportedDialect.SQLITE: _SQLITE_COLUMN_TYPES, +} + + def raise_if_exception_missing_str(ex: Exception, match_substrs: Iterable[str]) -> None: """Raise if the exception and cause do not contain the match substrs.""" lower_ex_strs = [str(ex).lower(), str(ex.__cause__).lower()] @@ -73,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) @@ -92,7 +167,7 @@ class SchemaValidationStatus: """Store schema validation status.""" current_version: int - statistics_schema_errors: set[str] + schema_errors: set[str] valid: bool @@ -119,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 @@ -157,18 +243,24 @@ def migrate_schema( hass.add_job(instance.async_set_db_ready) new_version = version + 1 _LOGGER.info("Upgrading recorder db schema to version %s", new_version) - _apply_update(hass, engine, session_maker, new_version, current_version) + _apply_update( + instance, hass, engine, session_maker, new_version, current_version + ) with session_scope(session=session_maker()) as session: session.add(SchemaChanges(schema_version=new_version)) - _LOGGER.info("Upgrade to version %s done", new_version) + # Log at the same level as the long schema changes + # 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)) @@ -196,11 +288,12 @@ def _create_index( _LOGGER.debug("Creating %s index", index_name) _LOGGER.warning( ( - "Adding index `%s` to database. Note: this can take several " + "Adding index `%s` to table `%s`. Note: this can take several " "minutes on large databases and slow computers. Please " "be patient!" ), index_name, + table_name, ) with session_scope(session=session_maker()) as session: try: @@ -215,8 +308,24 @@ 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, index_name: str + session_maker: Callable[[], Session], + table_name: str, + index_name: str, + quiet: bool | None = None, ) -> None: """Drop an index from a specified table. @@ -228,83 +337,53 @@ def _drop_index( string here is generated from the method parameters without sanitizing. DO NOT USE THIS FUNCTION IN ANY OPERATION THAT TAKES USER INPUT. """ - _LOGGER.debug("Dropping index %s from table %s", index_name, table_name) - success = False - - # Engines like DB2/Oracle + _LOGGER.warning( + ( + "Dropping index `%s` from table `%s`. Note: this can take several " + "minutes on large databases and slow computers. Please " + "be patient!" + ), + index_name, + table_name, + ) + index_to_drop: str | None = None with session_scope(session=session_maker()) as session: - try: - connection = session.connection() - connection.execute(text(f"DROP INDEX {index_name}")) - except SQLAlchemyError: - pass - else: - success = True + index_to_drop = get_index_by_name(session, table_name, index_name) - # Engines like SQLite, SQL Server - if not success: - with session_scope(session=session_maker()) as session: - try: - connection = session.connection() - connection.execute( - text( - "DROP INDEX {table}.{index}".format( - index=index_name, table=table_name - ) - ) - ) - except SQLAlchemyError: - pass - else: - success = True - - if not success: - # Engines like MySQL, MS Access - with session_scope(session=session_maker()) as session: - try: - connection = session.connection() - connection.execute( - text( - "DROP INDEX {index} ON {table}".format( - index=index_name, table=table_name - ) - ) - ) - except SQLAlchemyError: - pass - else: - 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 ) - else: - if index_name in ( - "ix_states_entity_id", - "ix_states_context_parent_id", - "ix_statistics_short_term_statistic_id_start", - "ix_statistics_statistic_id_start", - ): - # ix_states_context_parent_id was only there on nightly so we do not want - # to generate log noise or issues about it. - # - # ix_states_entity_id was only there for users who upgraded from schema - # version 8 or earlier. Newer installs will not have it so we do not - # want to generate log noise or issues about it. - # - # ix_statistics_short_term_statistic_id_start and ix_statistics_statistic_id_start - # were only there for users who upgraded from schema version 23 or earlier. + 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 + if not quiet: _LOGGER.warning( ( - "Failed to drop index %s from table %s. Schema " + "Failed to drop index `%s` from table `%s`. Schema " "Migration will continue; this is not a " - "critical operation" + "critical operation: %s" ), index_name, table_name, + errors, ) @@ -482,11 +561,7 @@ def _drop_foreign_key_constraints( inspector = sqlalchemy.inspect(engine) drops = [] for foreign_key in inspector.get_foreign_keys(table): - if ( - foreign_key["name"] - and foreign_key.get("options", {}).get("ondelete") - and foreign_key["constrained_columns"] == columns - ): + if foreign_key["name"] and foreign_key["constrained_columns"] == columns: drops.append(ForeignKeyConstraint((), (), name=foreign_key["name"])) # Bind the ForeignKeyConstraints to the table @@ -507,7 +582,9 @@ def _drop_foreign_key_constraints( ) +@database_job_retry_wrapper("Apply migration update", 10) def _apply_update( # noqa: C901 + instance: Recorder, hass: HomeAssistant, engine: Engine, session_maker: Callable[[], Session], @@ -515,13 +592,9 @@ def _apply_update( # noqa: C901 old_version: int, ) -> None: """Perform operations to bring schema up to date.""" - dialect = engine.dialect.name - big_int = "INTEGER(20)" if dialect == SupportedDialect.MYSQL else "INTEGER" - if dialect in (SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL): - timestamp_type = "DOUBLE PRECISION" - else: - timestamp_type = "FLOAT" - + assert engine.dialect.name is not None, "Dialect name must be set" + dialect = try_parse_enum(SupportedDialect, engine.dialect.name) + _column_types = _COLUMN_TYPES_FOR_DIALECT.get(dialect, _SQLITE_COLUMN_TYPES) if new_version == 1: # This used to create ix_events_time_fired, but it was removed in version 32 pass @@ -551,7 +624,7 @@ def _apply_update( # noqa: C901 # but it was removed in version 32 elif new_version == 5: # Create supporting index for States.event_id foreign key - _create_index(session_maker, "states", "ix_states_event_id") + _create_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX) elif new_version == 6: _add_columns( session_maker, @@ -672,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 # @@ -778,16 +828,19 @@ def _apply_update( # noqa: C901 # Add name column to StatisticsMeta _add_columns(session_maker, "statistics_meta", ["name VARCHAR(255)"]) elif new_version == 24: - _LOGGER.debug("Deleting duplicated statistics entries") - with session_scope(session=session_maker()) as session: - delete_statistics_duplicates(hass, session) + # This used to create the unique indices for start and statistic_id + # but we changed the format in schema 34 which will now take care + # of removing any duplicate if they still exist. + pass elif new_version == 25: - _add_columns(session_maker, "states", [f"attributes_id {big_int}"]) + _add_columns( + session_maker, "states", [f"attributes_id {_column_types.big_int_type}"] + ) _create_index(session_maker, "states", "ix_states_attributes_id") elif new_version == 26: _create_index(session_maker, "statistics_runs", "ix_statistics_runs_start") elif new_version == 27: - _add_columns(session_maker, "events", [f"data_id {big_int}"]) + _add_columns(session_maker, "events", [f"data_id {_column_types.big_int_type}"]) _create_index(session_maker, "events", "ix_events_data_id") elif new_version == 28: _add_columns(session_maker, "events", ["origin_idx INTEGER"]) @@ -830,7 +883,7 @@ def _apply_update( # noqa: C901 # There may be duplicated statistics_meta entries, delete duplicates # and try again with session_scope(session=session_maker()) as session: - delete_statistics_meta_duplicates(session) + delete_statistics_meta_duplicates(instance, session) _create_index( session_maker, "statistics_meta", "ix_statistics_meta_statistic_id" ) @@ -846,17 +899,22 @@ def _apply_update( # noqa: C901 # ALTER TABLE events DROP COLUMN time_fired # ALTER TABLE states DROP COLUMN last_updated # ALTER TABLE states DROP COLUMN last_changed - _add_columns(session_maker, "events", [f"time_fired_ts {timestamp_type}"]) + _add_columns( + session_maker, "events", [f"time_fired_ts {_column_types.timestamp_type}"] + ) _add_columns( session_maker, "states", - [f"last_updated_ts {timestamp_type}", f"last_changed_ts {timestamp_type}"], + [ + f"last_updated_ts {_column_types.timestamp_type}", + f"last_changed_ts {_column_types.timestamp_type}", + ], ) _create_index(session_maker, "events", "ix_events_time_fired_ts") _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. @@ -868,7 +926,8 @@ def _apply_update( # noqa: C901 # This index is no longer used and can cause MySQL to use the wrong index # when querying the states table. # https://github.com/home-assistant/core/issues/83787 - _drop_index(session_maker, "states", "ix_states_entity_id") + # There was an index cleanup here but its now done in schema 39 + pass elif new_version == 34: # Once we require SQLite >= 3.35.5, we should drop the columns: # ALTER TABLE statistics DROP COLUMN created @@ -881,18 +940,18 @@ def _apply_update( # noqa: C901 session_maker, "statistics", [ - f"created_ts {timestamp_type}", - f"start_ts {timestamp_type}", - f"last_reset_ts {timestamp_type}", + f"created_ts {_column_types.timestamp_type}", + f"start_ts {_column_types.timestamp_type}", + f"last_reset_ts {_column_types.timestamp_type}", ], ) _add_columns( session_maker, "statistics_short_term", [ - f"created_ts {timestamp_type}", - f"start_ts {timestamp_type}", - f"last_reset_ts {timestamp_type}", + f"created_ts {_column_types.timestamp_type}", + f"start_ts {_column_types.timestamp_type}", + f"last_reset_ts {_column_types.timestamp_type}", ], ) _create_index(session_maker, "statistics", "ix_statistics_start_ts") @@ -907,22 +966,147 @@ def _apply_update( # noqa: C901 "statistics_short_term", "ix_statistics_short_term_statistic_id_start_ts", ) - _migrate_statistics_columns_to_timestamp(session_maker, engine) + try: + _migrate_statistics_columns_to_timestamp(instance, session_maker, engine) + except IntegrityError as ex: + _LOGGER.error( + "Statistics table contains duplicate entries: %s; " + "Cleaning up duplicates and trying again; " + "This will take a while; " + "Please be patient!", + ex, + ) + # There may be duplicated statistics entries, delete duplicates + # and try again + with session_scope(session=session_maker()) as session: + delete_statistics_duplicates(instance, hass, session) + _migrate_statistics_columns_to_timestamp(instance, session_maker, engine) + # Log at error level to ensure the user sees this message in the log + # since we logged the error above. + _LOGGER.error( + "Statistics migration successfully recovered after statistics table duplicate cleanup" + ) elif new_version == 35: # Migration is done in two steps to ensure we can start using # the new columns before we wipe the old ones. - _drop_index(session_maker, "statistics", "ix_statistics_statistic_id_start") + _drop_index( + session_maker, "statistics", "ix_statistics_statistic_id_start", quiet=True + ) _drop_index( session_maker, "statistics_short_term", "ix_statistics_short_term_statistic_id_start", + quiet=True, ) # ix_statistics_start and ix_statistics_statistic_id_start are still used # for the post migration cleanup and can be removed in a future version. + elif new_version == 36: + for table in ("states", "events"): + _add_columns( + session_maker, + table, + [ + f"context_id_bin {_column_types.context_bin_type}", + f"context_user_id_bin {_column_types.context_bin_type}", + f"context_parent_id_bin {_column_types.context_bin_type}", + ], + ) + _create_index(session_maker, "events", "ix_events_context_id_bin") + _create_index(session_maker, "states", "ix_states_context_id_bin") + elif new_version == 37: + _add_columns( + session_maker, "events", [f"event_type_id {_column_types.big_int_type}"] + ) + _create_index(session_maker, "events", "ix_events_event_type_id") + _drop_index(session_maker, "events", "ix_events_event_type_time_fired_ts") + _create_index(session_maker, "events", "ix_events_event_type_id_time_fired_ts") + elif new_version == 38: + _add_columns( + session_maker, "states", [f"metadata_id {_column_types.big_int_type}"] + ) + _create_index(session_maker, "states", "ix_states_metadata_id") + _create_index(session_maker, "states", "ix_states_metadata_id_last_updated_ts") + elif new_version == 39: + # Dropping indexes with PostgreSQL never worked correctly if there was a prefix + # so we need to cleanup leftover indexes. + _drop_index( + session_maker, "events", "ix_events_event_type_time_fired_ts", quiet=True + ) + _drop_index(session_maker, "events", "ix_events_event_type", quiet=True) + _drop_index( + session_maker, "events", "ix_events_event_type_time_fired", quiet=True + ) + _drop_index(session_maker, "events", "ix_events_time_fired", quiet=True) + _drop_index(session_maker, "events", "ix_events_context_user_id", quiet=True) + _drop_index(session_maker, "events", "ix_events_context_parent_id", quiet=True) + _drop_index( + session_maker, "states", "ix_states_entity_id_last_updated", quiet=True + ) + _drop_index(session_maker, "states", "ix_states_last_updated", quiet=True) + _drop_index(session_maker, "states", "ix_states_entity_id", quiet=True) + _drop_index(session_maker, "states", "ix_states_context_user_id", quiet=True) + _drop_index(session_maker, "states", "ix_states_context_parent_id", quiet=True) + _drop_index(session_maker, "states", "ix_states_created_domain", quiet=True) + _drop_index(session_maker, "states", "ix_states_entity_id_created", quiet=True) + _drop_index(session_maker, "states", "states__state_changes", quiet=True) + _drop_index(session_maker, "states", "states__significant_changes", quiet=True) + _drop_index(session_maker, "states", "ix_states_entity_id_created", quiet=True) + _drop_index( + session_maker, "statistics", "ix_statistics_statistic_id_start", quiet=True + ) + _drop_index( + session_maker, + "statistics_short_term", + "ix_statistics_short_term_statistic_id_start", + quiet=True, + ) + elif new_version == 40: + # ix_events_event_type_id is a left-prefix of ix_events_event_type_id_time_fired_ts + _drop_index(session_maker, "events", "ix_events_event_type_id") + # ix_states_metadata_id is a left-prefix of ix_states_metadata_id_last_updated_ts + _drop_index(session_maker, "states", "ix_states_metadata_id") + # ix_statistics_metadata_id is a left-prefix of ix_statistics_statistic_id_start_ts + _drop_index(session_maker, "statistics", "ix_statistics_metadata_id") + # ix_statistics_short_term_metadata_id is a left-prefix of ix_statistics_short_term_statistic_id_start_ts + _drop_index( + session_maker, + "statistics_short_term", + "ix_statistics_short_term_metadata_id", + ) + elif new_version == 41: + _create_index(session_maker, "event_types", "ix_event_types_event_type") + _create_index(session_maker, "states_meta", "ix_states_meta_entity_id") else: 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, @@ -944,7 +1128,7 @@ def post_schema_migration( # since they are no longer used and take up a significant amount of space. assert instance.event_session is not None assert instance.engine is not None - _wipe_old_string_time_columns(instance.engine, instance.event_session) + _wipe_old_string_time_columns(instance, instance.engine, instance.event_session) if old_version < 35 <= new_version: # In version 34 we migrated all the created, start, and last_reset # columns to be timestamps. In version 34 we need to wipe the old columns @@ -957,7 +1141,10 @@ def _wipe_old_string_statistics_columns(instance: Recorder) -> None: instance.queue_task(StatisticsTimestampMigrationCleanupTask()) -def _wipe_old_string_time_columns(engine: Engine, session: Session) -> None: +@database_job_retry_wrapper("Wipe old string time columns", 3) +def _wipe_old_string_time_columns( + instance: Recorder, engine: Engine, session: Session +) -> None: """Wipe old string time columns to save space.""" # Wipe Events.time_fired since its been replaced by Events.time_fired_ts # Wipe States.last_updated since its been replaced by States.last_updated_ts @@ -971,23 +1158,23 @@ def _wipe_old_string_time_columns(engine: Engine, session: Session) -> None: elif engine.dialect.name == SupportedDialect.MYSQL: # # Since this is only to save space we limit the number of rows we update - # to 10,000,000 per table since we do not want to block the database for too long + # to 100,000 per table since we do not want to block the database for too long # or run out of innodb_buffer_pool_size on MySQL. The old data will eventually # be cleaned up by the recorder purge if we do not do it now. # - session.execute(text("UPDATE events set time_fired=NULL LIMIT 10000000;")) + session.execute(text("UPDATE events set time_fired=NULL LIMIT 100000;")) session.commit() session.execute( text( "UPDATE states set last_updated=NULL, last_changed=NULL " - " LIMIT 10000000;" + " LIMIT 100000;" ) ) session.commit() elif engine.dialect.name == SupportedDialect.POSTGRESQL: # # Since this is only to save space we limit the number of rows we update - # to 250,000 per table since we do not want to block the database for too long + # to 100,000 per table since we do not want to block the database for too long # or run out ram with postgresql. The old data will eventually # be cleaned up by the recorder purge if we do not do it now. # @@ -995,7 +1182,7 @@ def _wipe_old_string_time_columns(engine: Engine, session: Session) -> None: text( "UPDATE events set time_fired=NULL " "where event_id in " - "(select event_id from events where time_fired_ts is NOT NULL LIMIT 250000);" + "(select event_id from events where time_fired_ts is NOT NULL LIMIT 100000);" ) ) session.commit() @@ -1003,14 +1190,15 @@ def _wipe_old_string_time_columns(engine: Engine, session: Session) -> None: text( "UPDATE states set last_updated=NULL, last_changed=NULL " "where state_id in " - "(select state_id from states where last_updated_ts is NOT NULL LIMIT 250000);" + "(select state_id from states where last_updated_ts is NOT NULL LIMIT 100000);" ) ) session.commit() +@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 @@ -1044,11 +1232,11 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE events set time_fired_ts=" - "IF(time_fired is NULL,0," + "IF(time_fired is NULL or UNIX_TIMESTAMP(time_fired) is NULL,0," "UNIX_TIMESTAMP(time_fired)" ") " "where time_fired_ts is NULL " - "LIMIT 250000;" + "LIMIT 100000;" ) ) result = None @@ -1057,13 +1245,13 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE states set last_updated_ts=" - "IF(last_updated is NULL,0," + "IF(last_updated is NULL or UNIX_TIMESTAMP(last_updated) is NULL,0," "UNIX_TIMESTAMP(last_updated) " "), " "last_changed_ts=" "UNIX_TIMESTAMP(last_changed) " "where last_updated_ts is NULL " - "LIMIT 250000;" + "LIMIT 100000;" ) ) elif engine.dialect.name == SupportedDialect.POSTGRESQL: @@ -1076,9 +1264,9 @@ def _migrate_columns_to_timestamp( text( "UPDATE events SET " "time_fired_ts= " - "(case when time_fired is NULL then 0 else EXTRACT(EPOCH FROM time_fired) end) " + "(case when time_fired is NULL then 0 else EXTRACT(EPOCH FROM time_fired::timestamptz) end) " "WHERE event_id IN ( " - "SELECT event_id FROM events where time_fired_ts is NULL LIMIT 250000 " + "SELECT event_id FROM events where time_fired_ts is NULL LIMIT 100000 " " );" ) ) @@ -1088,17 +1276,18 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE states set last_updated_ts=" - "(case when last_updated is NULL then 0 else EXTRACT(EPOCH FROM last_updated) end), " - "last_changed_ts=EXTRACT(EPOCH FROM last_changed) " + "(case when last_updated is NULL then 0 else EXTRACT(EPOCH FROM last_updated::timestamptz) end), " + "last_changed_ts=EXTRACT(EPOCH FROM last_changed::timestamptz) " "where state_id IN ( " - "SELECT state_id FROM states where last_updated_ts is NULL LIMIT 250000 " + "SELECT state_id FROM states where last_updated_ts is NULL LIMIT 100000 " " );" ) ) +@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 @@ -1133,7 +1322,7 @@ def _migrate_statistics_columns_to_timestamp( result = session.connection().execute( text( f"UPDATE {table} set start_ts=" - "IF(start is NULL,0," + "IF(start is NULL or UNIX_TIMESTAMP(start) is NULL,0," "UNIX_TIMESTAMP(start) " "), " "created_ts=" @@ -1141,7 +1330,7 @@ def _migrate_statistics_columns_to_timestamp( "last_reset_ts=" "UNIX_TIMESTAMP(last_reset) " "where start_ts is NULL " - "LIMIT 250000;" + "LIMIT 100000;" ) ) elif engine.dialect.name == SupportedDialect.POSTGRESQL: @@ -1155,16 +1344,298 @@ 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 250000 " - " );" + "(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" + ");" ) ) +def _context_id_to_bytes(context_id: str | None) -> bytes | None: + """Convert a context_id to bytes.""" + if context_id is None: + return None + 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 + 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.""" + _to_bytes = _context_id_to_bytes + session_maker = instance.get_session + _LOGGER.debug("Migrating states context_ids to binary format") + with session_scope(session=session_maker()) as session: + if states := session.execute(find_states_context_ids_to_migrate()).all(): + session.execute( + update(States), + [ + { + "state_id": state_id, + "context_id": None, + "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, last_updated_ts, context_id, context_user_id, context_parent_id in states + ], + ) + # If there is more work to do return False + # so that we can be called again + is_done = not states + + if is_done: + _drop_index(session_maker, "states", "ix_states_context_id") + + _LOGGER.debug("Migrating states context_ids to binary format: done=%s", is_done) + return is_done + + +@retryable_database_job("migrate events context_ids to binary format") +def migrate_events_context_ids(instance: Recorder) -> bool: + """Migrate events context_ids to use binary format.""" + _to_bytes = _context_id_to_bytes + session_maker = instance.get_session + _LOGGER.debug("Migrating context_ids to binary format") + with session_scope(session=session_maker()) as session: + if events := session.execute(find_events_context_ids_to_migrate()).all(): + session.execute( + update(Events), + [ + { + "event_id": event_id, + "context_id": None, + "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, time_fired_ts, context_id, context_user_id, context_parent_id in events + ], + ) + # If there is more work to do return False + # so that we can be called again + is_done = not events + + if is_done: + _drop_index(session_maker, "events", "ix_events_context_id") + + _LOGGER.debug("Migrating events context_ids to binary format: done=%s", is_done) + return is_done + + +@retryable_database_job("migrate events event_types to event_type_ids") +def migrate_event_type_ids(instance: Recorder) -> bool: + """Migrate event_type to event_type_ids.""" + session_maker = instance.get_session + _LOGGER.debug("Migrating event_types") + event_type_manager = instance.event_type_manager + with session_scope(session=session_maker()) as session: + if events := session.execute(find_event_type_to_migrate()).all(): + 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 := { + event_type + for event_type, event_id in event_type_to_id.items() + if event_id is None + }: + missing_db_event_types = [ + EventTypes(event_type=event_type) + for event_type in missing_event_types + ] + session.add_all(missing_db_event_types) + session.flush() # Assign ids + for db_event_type in missing_db_event_types: + # We cannot add the assigned ids to the event_type_manager + # because the commit could get rolled back + assert ( + db_event_type.event_type is not None + ), "event_type should never be None" + 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), + [ + { + "event_id": event_id, + "event_type": None, + "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 + ], + ) + + # If there is more work to do return False + # so that we can be called again + is_done = not events + + if is_done: + instance.event_type_manager.active = True + + _LOGGER.debug("Migrating event_types done=%s", is_done) + return is_done + + +@retryable_database_job("migrate states entity_ids to states_meta") +def migrate_entity_ids(instance: Recorder) -> bool: + """Migrate entity_ids to states_meta. + + We do this in two steps because we need the history queries to work + while we are migrating. + + 1. Link the states to the states_meta table + 2. Remove the entity_id column from the states table (in post_migrate_entity_ids) + """ + _LOGGER.debug("Migrating entity_ids") + states_meta_manager = instance.states_meta_manager + with session_scope(session=instance.get_session()) as session: + if states := session.execute(find_entity_ids_to_migrate()).all(): + 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 := { + entity_id + for entity_id, metadata_id in entity_id_to_metadata_id.items() + if metadata_id is None + }: + missing_states_metadata = [ + StatesMeta(entity_id=entity_id) for entity_id in missing_entity_ids + ] + session.add_all(missing_states_metadata) + session.flush() # Assign ids + for db_states_metadata in missing_states_metadata: + # We cannot add the assigned ids to the event_type_manager + # because the commit could get rolled back + assert ( + db_states_metadata.entity_id is not None + ), "entity_id should never be None" + entity_id_to_metadata_id[ + db_states_metadata.entity_id + ] = db_states_metadata.metadata_id + + session.execute( + update(States), + [ + { + "state_id": state_id, + # We cannot set "entity_id": None yet since + # 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[ + _EMPTY_ENTITY_ID if entity_id is None else entity_id + ], + } + for state_id, entity_id in states + ], + ) + + # If there is more work to do return False + # so that we can be called again + is_done = not states + + _LOGGER.debug("Migrating entity_ids done=%s", is_done) + return is_done + + +@retryable_database_job("post migrate states entity_ids to states_meta") +def post_migrate_entity_ids(instance: Recorder) -> bool: + """Remove old entity_id strings from states. + + We cannot do this in migrate_entity_ids since the history queries + still need to work while the migration is in progress. + """ + session_maker = instance.get_session + _LOGGER.debug("Cleanup legacy entity_ids") + with session_scope(session=session_maker()) as session: + cursor_result = session.connection().execute(batch_cleanup_entity_ids()) + is_done = not cursor_result or cursor_result.rowcount == 0 + # If there is more work to do return False + # so that we can be called again + + if is_done: + # Drop the old indexes since they are no longer needed + _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 + + +@retryable_database_job("cleanup_legacy_event_ids") +def cleanup_legacy_states_event_ids(instance: Recorder) -> bool: + """Remove old event_id index from states. + + We used to link states to events using the event_id column but we no + longer store state changed events in the events table. + + If all old states have been purged and existing states are in the new + format we can drop the index since it can take up ~10MB per 1M rows. + """ + session_maker = instance.get_session + _LOGGER.debug("Cleanup legacy entity_ids") + with session_scope(session=session_maker()) as session: + result = session.execute(has_used_states_event_ids()).scalar() + # In the future we may migrate existing states to the new format + # but in practice very few of these still exist in production and + # removing the index is the likely all that needs to happen. + all_gone = not result + + if all_gone: + # Only drop the index if there are no more event_ids in the states table + # ex all NULL + assert instance.engine is not None, "engine should never be None" + if instance.dialect_name != SupportedDialect.SQLITE: + # SQLite does not support dropping foreign key constraints + # so we can't drop the index at this time but we can avoid + # looking for legacy rows during purge + _drop_foreign_key_constraints( + session_maker, instance.engine, TABLE_STATES, ["event_id"] + ) + _drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX) + instance.use_legacy_events_index = False + + return True + + def _initialize_database(session: Session) -> bool: """Initialize a new database. diff --git a/homeassistant/components/recorder/models/__init__.py b/homeassistant/components/recorder/models/__init__.py new file mode 100644 index 0000000000..1a204e767e --- /dev/null +++ b/homeassistant/components/recorder/models/__init__.py @@ -0,0 +1,56 @@ +"""Models for Recorder.""" +from __future__ import annotations + +from .context import ( + bytes_to_ulid_or_none, + bytes_to_uuid_hex_or_none, + ulid_to_bytes_or_none, + 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, + FixedStatisticPeriod, + RollingWindowStatisticPeriod, + StatisticData, + StatisticDataTimestamp, + StatisticMetaData, + StatisticPeriod, + StatisticResult, +) +from .time import ( + datetime_to_timestamp_or_none, + process_datetime_to_timestamp, + process_timestamp, + process_timestamp_to_utc_isoformat, + timestamp_to_datetime_or_none, +) + +__all__ = [ + "CalendarStatisticPeriod", + "DatabaseEngine", + "DatabaseOptimizer", + "FixedStatisticPeriod", + "LazyState", + "RollingWindowStatisticPeriod", + "StatisticData", + "StatisticDataTimestamp", + "StatisticMetaData", + "StatisticPeriod", + "StatisticResult", + "UnsupportedDialect", + "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", + "process_timestamp_to_utc_isoformat", + "row_to_compressed_state", + "timestamp_to_datetime_or_none", + "ulid_to_bytes_or_none", + "uuid_hex_to_bytes_or_none", +] diff --git a/homeassistant/components/recorder/models/context.py b/homeassistant/components/recorder/models/context.py new file mode 100644 index 0000000000..f722c51983 --- /dev/null +++ b/homeassistant/components/recorder/models/context.py @@ -0,0 +1,55 @@ +"""Models for Recorder.""" +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 + 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 + 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) +def uuid_hex_to_bytes_or_none(uuid_hex: str | None) -> bytes | None: + """Convert a uuid hex to bytes.""" + if uuid_hex is None: + return None + with suppress(ValueError): + return UUID(hex=uuid_hex).bytes + return None + + +@lru_cache(maxsize=16) +def bytes_to_uuid_hex_or_none(_bytes: bytes | None) -> str | None: + """Convert bytes to a uuid hex.""" + if _bytes is None: + return None + with suppress(ValueError): + return UUID(bytes=_bytes).hex + return None diff --git a/homeassistant/components/recorder/models/database.py b/homeassistant/components/recorder/models/database.py new file mode 100644 index 0000000000..e39f05cd9c --- /dev/null +++ b/homeassistant/components/recorder/models/database.py @@ -0,0 +1,33 @@ +"""Models for the database in the Recorder.""" +from __future__ import annotations + +from dataclasses import dataclass + +from awesomeversion import AwesomeVersion + +from ..const import SupportedDialect + + +class UnsupportedDialect(Exception): + """The dialect or its version is not supported.""" + + +@dataclass +class DatabaseEngine: + """Properties of the database engine.""" + + dialect: SupportedDialect + optimizer: DatabaseOptimizer + version: AwesomeVersion | None + + +@dataclass +class DatabaseOptimizer: + """Properties of the database optimizer for the configured database engine.""" + + # Some MariaDB versions have a bug that causes a slow query when using + # a range in a select statement with an IN clause. + # + # https://jira.mariadb.org/browse/MDEV-25020 + # + slow_range_in_select: bool 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.py b/homeassistant/components/recorder/models/legacy.py similarity index 59% rename from homeassistant/components/recorder/models.py rename to homeassistant/components/recorder/models/legacy.py index 1109b1f43c..398ad773ba 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models/legacy.py @@ -1,12 +1,9 @@ """Models for Recorder.""" from __future__ import annotations -from dataclasses import dataclass -from datetime import datetime, timedelta -import logging -from typing import Any, Literal, TypedDict, overload +from datetime import datetime +from typing import Any -from awesomeversion import AwesomeVersion from sqlalchemy.engine.row import Row from homeassistant.const import ( @@ -17,145 +14,16 @@ from homeassistant.const import ( ) from homeassistant.core import Context, State import homeassistant.util.dt as dt_util -from homeassistant.util.json import json_loads_object -from .const import SupportedDialect - -# pylint: disable=invalid-name - -_LOGGER = logging.getLogger(__name__) - -DB_TIMEZONE = "+00:00" - -EMPTY_JSON_OBJECT = "{}" +from .state_attributes import decode_attributes_from_source +from .time import ( + process_datetime_to_timestamp, + process_timestamp, + process_timestamp_to_utc_isoformat, +) -class UnsupportedDialect(Exception): - """The dialect or its version is not supported.""" - - -class StatisticResult(TypedDict): - """Statistic result data class. - - Allows multiple datapoints for the same statistic_id. - """ - - meta: StatisticMetaData - stat: StatisticData - - -class StatisticDataTimestampBase(TypedDict): - """Mandatory fields for statistic data class with a timestamp.""" - - start_ts: float - - -class StatisticDataBase(TypedDict): - """Mandatory fields for statistic data class.""" - - start: datetime - - -class StatisticMixIn(TypedDict, total=False): - """Mandatory fields for statistic data class.""" - - state: float - sum: float - min: float - max: float - mean: float - - -class StatisticData(StatisticDataBase, StatisticMixIn, total=False): - """Statistic data class.""" - - last_reset: datetime | None - - -class StatisticDataTimestamp(StatisticDataTimestampBase, StatisticMixIn, total=False): - """Statistic data class with a timestamp.""" - - last_reset_ts: float | None - - -class StatisticMetaData(TypedDict): - """Statistic meta data class.""" - - has_mean: bool - has_sum: bool - name: str | None - source: str - statistic_id: str - unit_of_measurement: str | None - - -@overload -def process_timestamp(ts: None) -> None: - ... - - -@overload -def process_timestamp(ts: datetime) -> datetime: - ... - - -def process_timestamp(ts: datetime | None) -> datetime | None: - """Process a timestamp into datetime object.""" - if ts is None: - return None - if ts.tzinfo is None: - return ts.replace(tzinfo=dt_util.UTC) - - return dt_util.as_utc(ts) - - -@overload -def process_timestamp_to_utc_isoformat(ts: None) -> None: - ... - - -@overload -def process_timestamp_to_utc_isoformat(ts: datetime) -> str: - ... - - -def process_timestamp_to_utc_isoformat(ts: datetime | None) -> str | None: - """Process a timestamp into UTC isotime.""" - if ts is None: - return None - if ts.tzinfo == dt_util.UTC: - return ts.isoformat() - if ts.tzinfo is None: - return f"{ts.isoformat()}{DB_TIMEZONE}" - return ts.astimezone(dt_util.UTC).isoformat() - - -def process_datetime_to_timestamp(ts: datetime) -> float: - """Process a datebase datetime to epoch. - - Mirrors the behavior of process_timestamp_to_utc_isoformat - except it returns the epoch time. - """ - if ts.tzinfo is None or ts.tzinfo == dt_util.UTC: - return dt_util.utc_to_timestamp(ts) - return ts.timestamp() - - -def datetime_to_timestamp_or_none(dt: datetime | None) -> float | None: - """Convert a datetime to a timestamp.""" - if dt is None: - return None - return dt_util.utc_to_timestamp(dt) - - -def timestamp_to_datetime_or_none(ts: float | None) -> datetime | None: - """Convert a timestamp to a datetime.""" - if not ts: - return None - return dt_util.utc_from_timestamp(ts) - - -class LazyStatePreSchema31(State): +class LegacyLazyStatePreSchema31(State): """A lazy version of core State before schema 31.""" __slots__ = [ @@ -187,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 @@ -269,7 +139,33 @@ class LazyStatePreSchema31(State): } -class LazyState(State): +def legacy_row_to_compressed_state_pre_schema_31( + row: Row, + attr_cache: dict[str, dict[str, Any]], + start_time: datetime | None, +) -> dict[str, Any]: + """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_legacy(row, attr_cache), + } + if start_time: + comp_state[COMPRESSED_STATE_LAST_UPDATED] = start_time.timestamp() + else: + row_last_updated: datetime = row.last_updated + comp_state[COMPRESSED_STATE_LAST_UPDATED] = process_datetime_to_timestamp( + row_last_updated + ) + if ( + row_changed_changed := row.last_changed + ) and row_last_updated != row_changed_changed: + comp_state[COMPRESSED_STATE_LAST_CHANGED] = process_datetime_to_timestamp( + row_changed_changed + ) + return comp_state + + +class LegacyLazyState(State): """A lazy version of core State after schema 31.""" __slots__ = [ @@ -286,10 +182,11 @@ class LazyState(State): 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: str = self._row.entity_id + 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 ( @@ -305,7 +202,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_row_legacy( + self._row, self.attr_cache + ) return self._attributes @attributes.setter @@ -351,7 +250,6 @@ class LazyState(State): """Return a dict representation of the LazyState. Async friendly. - To be used for JSON serialization. """ last_updated_isoformat = self.last_updated.isoformat() @@ -368,32 +266,16 @@ class LazyState(State): } -def decode_attributes_from_row( - row: Row, attr_cache: dict[str, dict[str, Any]] -) -> dict[str, Any]: - """Decode attributes from a database row.""" - source: str = row.shared_attrs or row.attributes - if (attributes := attr_cache.get(source)) is not None: - return attributes - if not source or source == EMPTY_JSON_OBJECT: - return {} - try: - attr_cache[source] = attributes = json_loads_object(source) - except ValueError: - _LOGGER.exception("Error converting row to state attributes: %s", source) - attr_cache[source] = attributes = {} - return attributes - - -def row_to_compressed_state( +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(row, attr_cache), + 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) @@ -401,86 +283,17 @@ def row_to_compressed_state( 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_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 row_to_compressed_state_pre_schema_31( - row: Row, - attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, +def decode_attributes_from_row_legacy( + row: Row, attr_cache: dict[str, dict[str, Any]] ) -> dict[str, Any]: - """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), - } - if start_time: - comp_state[COMPRESSED_STATE_LAST_UPDATED] = start_time.timestamp() - else: - row_last_updated: datetime = row.last_updated - comp_state[COMPRESSED_STATE_LAST_UPDATED] = process_datetime_to_timestamp( - row_last_updated - ) - if ( - row_changed_changed := row.last_changed - ) and row_last_updated != row_changed_changed: - comp_state[COMPRESSED_STATE_LAST_CHANGED] = process_datetime_to_timestamp( - row_changed_changed - ) - return comp_state - - -class CalendarStatisticPeriod(TypedDict, total=False): - """Statistic period definition.""" - - period: Literal["hour", "day", "week", "month", "year"] - offset: int - - -class FixedStatisticPeriod(TypedDict, total=False): - """Statistic period definition.""" - - end_time: datetime - start_time: datetime - - -class RollingWindowStatisticPeriod(TypedDict, total=False): - """Statistic period definition.""" - - duration: timedelta - offset: timedelta - - -class StatisticPeriod(TypedDict, total=False): - """Statistic period definition.""" - - calendar: CalendarStatisticPeriod - fixed_period: FixedStatisticPeriod - rolling_window: RollingWindowStatisticPeriod - - -@dataclass -class DatabaseEngine: - """Properties of the database engine.""" - - dialect: SupportedDialect - optimizer: DatabaseOptimizer - version: AwesomeVersion | None - - -@dataclass -class DatabaseOptimizer: - """Properties of the database optimizer for the configured database engine.""" - - # Some MariaDB versions have a bug that causes a slow query when using - # a range in a select statement with an IN clause. - # - # https://jira.mariadb.org/browse/MDEV-25020 - # - # Historically, we have applied this logic to PostgreSQL as well, but - # it may not be necessary. We should revisit this in the future - # when we have more data. - slow_range_in_select: bool + """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 new file mode 100644 index 0000000000..523ffdf185 --- /dev/null +++ b/homeassistant/components/recorder/models/state.py @@ -0,0 +1,162 @@ +"""Models states in for Recorder.""" +from __future__ import annotations + +from datetime import datetime +import logging +from typing import Any + +from sqlalchemy.engine.row import Row + +from homeassistant.const import ( + COMPRESSED_STATE_ATTRIBUTES, + COMPRESSED_STATE_LAST_CHANGED, + COMPRESSED_STATE_LAST_UPDATED, + COMPRESSED_STATE_STATE, +) +from homeassistant.core import Context, State +import homeassistant.util.dt as dt_util + +from .state_attributes import decode_attributes_from_source +from .time import process_timestamp + +_LOGGER = logging.getLogger(__name__) + + +def extract_metadata_ids( + entity_id_to_metadata_id: dict[str, int | None], +) -> list[int]: + """Extract metadata ids from entity_id_to_metadata_id.""" + return [ + metadata_id + for metadata_id in entity_id_to_metadata_id.values() + if metadata_id is not None + ] + + +class LazyState(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_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 + self.state = state or "" + self._attributes: dict[str, Any] | None = None + 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 + + @property # type: ignore[override] + def attributes(self) -> dict[str, Any]: + """State attributes.""" + if self._attributes is None: + self._attributes = decode_attributes_from_source( + getattr(self._row, "attributes", None), 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.""" + 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 + 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 row_to_compressed_state( + row: Row, + attr_cache: dict[str, dict[str, Any]], + 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 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 + ), + } + 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 new file mode 100644 index 0000000000..c9cc110e1e --- /dev/null +++ b/homeassistant/components/recorder/models/state_attributes.py @@ -0,0 +1,27 @@ +"""State attributes models.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.util.json import json_loads_object + +EMPTY_JSON_OBJECT = "{}" +_LOGGER = logging.getLogger(__name__) + + +def decode_attributes_from_source( + source: Any, attr_cache: dict[str, dict[str, Any]] +) -> dict[str, Any]: + """Decode attributes from a row source.""" + if not source or source == EMPTY_JSON_OBJECT: + return {} + if (attributes := attr_cache.get(source)) is not None: + return attributes + try: + attr_cache[source] = attributes = json_loads_object(source) + except ValueError: + _LOGGER.exception("Error converting row to state attributes: %s", source) + attr_cache[source] = attributes = {} + return attributes diff --git a/homeassistant/components/recorder/models/statistics.py b/homeassistant/components/recorder/models/statistics.py new file mode 100644 index 0000000000..4cf465955c --- /dev/null +++ b/homeassistant/components/recorder/models/statistics.py @@ -0,0 +1,89 @@ +"""Models for statistics in the Recorder.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Literal, TypedDict + + +class StatisticResult(TypedDict): + """Statistic result data class. + + Allows multiple datapoints for the same statistic_id. + """ + + meta: StatisticMetaData + stat: StatisticData + + +class StatisticDataTimestampBase(TypedDict): + """Mandatory fields for statistic data class with a timestamp.""" + + start_ts: float + + +class StatisticDataBase(TypedDict): + """Mandatory fields for statistic data class.""" + + start: datetime + + +class StatisticMixIn(TypedDict, total=False): + """Mandatory fields for statistic data class.""" + + state: float + sum: float + min: float + max: float + mean: float + + +class StatisticData(StatisticDataBase, StatisticMixIn, total=False): + """Statistic data class.""" + + last_reset: datetime | None + + +class StatisticDataTimestamp(StatisticDataTimestampBase, StatisticMixIn, total=False): + """Statistic data class with a timestamp.""" + + last_reset_ts: float | None + + +class StatisticMetaData(TypedDict): + """Statistic meta data class.""" + + has_mean: bool + has_sum: bool + name: str | None + source: str + statistic_id: str + unit_of_measurement: str | None + + +class CalendarStatisticPeriod(TypedDict, total=False): + """Statistic period definition.""" + + period: Literal["hour", "day", "week", "month", "year"] + offset: int + + +class FixedStatisticPeriod(TypedDict, total=False): + """Statistic period definition.""" + + end_time: datetime + start_time: datetime + + +class RollingWindowStatisticPeriod(TypedDict, total=False): + """Statistic period definition.""" + + duration: timedelta + offset: timedelta + + +class StatisticPeriod(TypedDict, total=False): + """Statistic period definition.""" + + calendar: CalendarStatisticPeriod + fixed_period: FixedStatisticPeriod + rolling_window: RollingWindowStatisticPeriod diff --git a/homeassistant/components/recorder/models/time.py b/homeassistant/components/recorder/models/time.py new file mode 100644 index 0000000000..078a982d5a --- /dev/null +++ b/homeassistant/components/recorder/models/time.py @@ -0,0 +1,82 @@ +"""Models for Recorder.""" +from __future__ import annotations + +from datetime import datetime +import logging +from typing import overload + +import homeassistant.util.dt as dt_util + +# pylint: disable=invalid-name + +_LOGGER = logging.getLogger(__name__) + +DB_TIMEZONE = "+00:00" + +EMPTY_JSON_OBJECT = "{}" + + +@overload +def process_timestamp(ts: None) -> None: + ... + + +@overload +def process_timestamp(ts: datetime) -> datetime: + ... + + +def process_timestamp(ts: datetime | None) -> datetime | None: + """Process a timestamp into datetime object.""" + if ts is None: + return None + if ts.tzinfo is None: + return ts.replace(tzinfo=dt_util.UTC) + + return dt_util.as_utc(ts) + + +@overload +def process_timestamp_to_utc_isoformat(ts: None) -> None: + ... + + +@overload +def process_timestamp_to_utc_isoformat(ts: datetime) -> str: + ... + + +def process_timestamp_to_utc_isoformat(ts: datetime | None) -> str | None: + """Process a timestamp into UTC isotime.""" + if ts is None: + return None + if ts.tzinfo == dt_util.UTC: + return ts.isoformat() + if ts.tzinfo is None: + return f"{ts.isoformat()}{DB_TIMEZONE}" + return ts.astimezone(dt_util.UTC).isoformat() + + +def process_datetime_to_timestamp(ts: datetime) -> float: + """Process a datebase datetime to epoch. + + Mirrors the behavior of process_timestamp_to_utc_isoformat + except it returns the epoch time. + """ + if ts.tzinfo is None or ts.tzinfo == dt_util.UTC: + return dt_util.utc_to_timestamp(ts) + return ts.timestamp() + + +def datetime_to_timestamp_or_none(dt: datetime | None) -> float | None: + """Convert a datetime to a timestamp.""" + if dt is None: + return None + return dt_util.utc_to_timestamp(dt) + + +def timestamp_to_datetime_or_none(ts: float | None) -> datetime | None: + """Convert a timestamp to a datetime.""" + if not ts: + return None + return dt_util.utc_from_timestamp(ts) 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/purge.py b/homeassistant/components/recorder/purge.py index eb6413fa78..9dff59d1f5 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -1,22 +1,19 @@ """Purge old data helper.""" from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable from datetime import datetime -from functools import partial -from itertools import islice, zip_longest +from itertools import zip_longest import logging -from typing import TYPE_CHECKING, Any +import time +from typing import TYPE_CHECKING -from sqlalchemy.engine.row import Row from sqlalchemy.orm.session import Session -from sqlalchemy.sql.expression import distinct -from homeassistant.const import EVENT_STATE_CHANGED import homeassistant.util.dt as dt_util -from .const import MAX_ROWS_TO_PURGE -from .db_schema import Events, StateAttributes, States +from .const import SQLITE_MAX_BIND_VARS +from .db_schema import Events, States, StatesMeta from .models import DatabaseEngine from .queries import ( attributes_ids_exist_in_states, @@ -25,14 +22,19 @@ from .queries import ( data_ids_exist_in_events_with_fast_in_distinct, delete_event_data_rows, delete_event_rows, + delete_event_types_rows, delete_recorder_runs_rows, delete_states_attributes_rows, + delete_states_meta_rows, delete_states_rows, delete_statistics_runs_rows, delete_statistics_short_term_rows, disconnect_states_rows, + find_entity_ids_to_purge, + find_event_types_to_purge, find_events_to_purge, find_latest_statistics_runs_run_id, + find_legacy_detached_states_and_attributes_to_purge, find_legacy_event_state_and_attributes_and_data_ids_to_purge, find_legacy_row, find_short_term_statistics_to_purge, @@ -40,7 +42,7 @@ from .queries import ( find_statistics_runs_to_purge, ) from .repack import repack_database -from .util import retryable_database_job, session_scope +from .util import chunked, retryable_database_job, session_scope if TYPE_CHECKING: from . import Recorder @@ -52,22 +54,6 @@ DEFAULT_STATES_BATCHES_PER_PURGE = 20 # We expect ~95% de-dupe rate DEFAULT_EVENTS_BATCHES_PER_PURGE = 15 # We expect ~92% de-dupe rate -def take(take_num: int, iterable: Iterable) -> list[Any]: - """Return first n items of the iterable as a list. - - From itertools recipes - """ - return list(islice(iterable, take_num)) - - -def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: - """Break *iterable* into lists of length *n*. - - From more-itertools - """ - return iter(partial(take, chunked_num, iter(iterable)), []) - - @retryable_database_job("purge") def purge_old_data( instance: Recorder, @@ -86,9 +72,9 @@ def purge_old_data( purge_before.isoformat(sep=" ", timespec="seconds"), ) with session_scope(session=instance.get_session()) as session: - # Purge a max of MAX_ROWS_TO_PURGE, based on the oldest states or events record + # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states or events record has_more_to_purge = False - if _purging_legacy_format(session): + if instance.use_legacy_events_index and _purging_legacy_format(session): _LOGGER.debug( "Purge running in legacy format as there are states with event_id" " remaining" @@ -126,6 +112,14 @@ def purge_old_data( _LOGGER.debug("Cleanup filtered data hasn't fully completed yet") return False + # This purge cycle is finished, clean up old event types and + # recorder runs + if instance.event_type_manager.active: + _purge_old_event_types(instance, session) + + if instance.states_meta_manager.active: + _purge_old_entity_ids(instance, session) + _purge_old_recorder_runs(instance, session, purge_before) if repack: repack_database(instance) @@ -149,13 +143,32 @@ def _purge_legacy_format( ) = _select_legacy_event_state_and_attributes_and_data_ids_to_purge( session, purge_before ) - if state_ids: - _purge_state_ids(instance, session, state_ids) + _purge_state_ids(instance, session, state_ids) _purge_unused_attributes_ids(instance, session, attributes_ids) - if event_ids: - _purge_event_ids(session, event_ids) + _purge_event_ids(session, event_ids) _purge_unused_data_ids(instance, session, data_ids) - return bool(event_ids or state_ids or attributes_ids or data_ids) + + # The database may still have some rows that have an event_id but are not + # linked to any event. These rows are not linked to any event because the + # event was deleted. We need to purge these rows as well or we will never + # switch to the new format which will prevent us from purging any events + # that happened after the detached states. + ( + detached_state_ids, + detached_attributes_ids, + ) = _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( + session, purge_before + ) + _purge_state_ids(instance, session, detached_state_ids) + _purge_unused_attributes_ids(instance, session, detached_attributes_ids) + return bool( + event_ids + or state_ids + or attributes_ids + or data_ids + or detached_state_ids + or detached_attributes_ids + ) def _purge_states_and_attributes_ids( @@ -174,7 +187,7 @@ def _purge_states_and_attributes_ids( # There are more states relative to attributes_ids so # we purge enough state_ids to try to generate a full # size batch of attributes_ids that will be around the size - # MAX_ROWS_TO_PURGE + # SQLITE_MAX_BIND_VARS attributes_ids_batch: set[int] = set() for _ in range(states_batch_size): state_ids, attributes_ids = _select_state_attributes_ids_to_purge( @@ -208,7 +221,7 @@ def _purge_events_and_data_ids( # There are more events relative to data_ids so # we purge enough event_ids to try to generate a full # size batch of data_ids that will be around the size - # MAX_ROWS_TO_PURGE + # SQLITE_MAX_BIND_VARS data_ids_batch: set[int] = set() for _ in range(events_batch_size): event_ids, data_ids = _select_event_data_ids_to_purge(session, purge_before) @@ -232,12 +245,12 @@ def _select_state_attributes_ids_to_purge( """Return sets of state and attribute ids to purge.""" state_ids = set() attributes_ids = set() - for state in session.execute( + for state_id, attributes_id in session.execute( find_states_to_purge(dt_util.utc_to_timestamp(purge_before)) ).all(): - state_ids.add(state.state_id) - if state.attributes_id: - attributes_ids.add(state.attributes_id) + state_ids.add(state_id) + if attributes_id: + attributes_ids.add(attributes_id) _LOGGER.debug( "Selected %s state ids and %s attributes_ids to remove", len(state_ids), @@ -252,12 +265,12 @@ def _select_event_data_ids_to_purge( """Return sets of event and data ids to purge.""" event_ids = set() data_ids = set() - for event in session.execute( + for event_id, data_id in session.execute( find_events_to_purge(dt_util.utc_to_timestamp(purge_before)) ).all(): - event_ids.add(event.event_id) - if event.data_id: - data_ids.add(event.data_id) + event_ids.add(event_id) + if data_id: + data_ids.add(data_id) _LOGGER.debug( "Selected %s event ids and %s data_ids to remove", len(event_ids), len(data_ids) ) @@ -310,7 +323,7 @@ def _select_unused_attributes_ids( # # We used to generate a query based on how many attribute_ids to find but # that meant sqlalchemy Transparent SQL Compilation Caching was working against - # us by cached up to MAX_ROWS_TO_PURGE different statements which could be + # us by cached up to SQLITE_MAX_BIND_VARS different statements which could be # up to 500MB for large database due to the complexity of the ORM objects. # # We now break the query into groups of 100 and use a lambda_stmt to ensure @@ -399,7 +412,7 @@ def _select_statistics_runs_to_purge( Takes care to keep the newest run. """ statistic_runs = session.execute(find_statistics_runs_to_purge(purge_before)).all() - statistic_runs_list = [run.run_id for run in statistic_runs] + statistic_runs_list = [run_id for (run_id,) in statistic_runs] # Exclude the newest statistics run if ( last_run := session.execute(find_latest_statistics_runs_run_id()).scalar() @@ -418,7 +431,32 @@ def _select_short_term_statistics_to_purge( find_short_term_statistics_to_purge(purge_before) ).all() _LOGGER.debug("Selected %s short term statistics to remove", len(statistics)) - return [statistic.id for statistic in statistics] + return [statistic_id for (statistic_id,) in statistics] + + +def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( + session: Session, purge_before: datetime +) -> tuple[set[int], set[int]]: + """Return a list of state, and attribute ids to purge. + + We do not link these anymore since state_change events + do not exist in the events table anymore, however we + still need to be able to purge them. + """ + states = session.execute( + find_legacy_detached_states_and_attributes_to_purge( + dt_util.utc_to_timestamp(purge_before) + ) + ).all() + _LOGGER.debug("Selected %s state ids to remove", len(states)) + state_ids = set() + attributes_ids = set() + for state_id, attributes_id in states: + if state_id: + state_ids.add(state_id) + if attributes_id: + attributes_ids.add(attributes_id) + return state_ids, attributes_ids def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( @@ -440,19 +478,21 @@ def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( state_ids = set() attributes_ids = set() data_ids = set() - for event in events: - event_ids.add(event.event_id) - if event.state_id: - state_ids.add(event.state_id) - if event.attributes_id: - attributes_ids.add(event.attributes_id) - if event.data_id: - data_ids.add(event.data_id) + for event_id, data_id, state_id, attributes_id in events: + event_ids.add(event_id) + if state_id: + state_ids.add(state_id) + if attributes_id: + attributes_ids.add(attributes_id) + if data_id: + data_ids.add(data_id) return event_ids, state_ids, attributes_ids, data_ids def _purge_state_ids(instance: Recorder, session: Session, state_ids: set[int]) -> None: """Disconnect states and delete by state id.""" + if not state_ids: + return # Update old_state_id to NULL before deleting to ensure # the delete does not fail due to a foreign key constraint @@ -465,87 +505,33 @@ def _purge_state_ids(instance: Recorder, session: Session, state_ids: set[int]) _LOGGER.debug("Deleted %s states", deleted_rows) # Evict eny entries in the old_states cache referring to a purged state - _evict_purged_states_from_old_states_cache(instance, state_ids) - - -def _evict_purged_states_from_old_states_cache( - instance: Recorder, purged_state_ids: set[int] -) -> None: - """Evict purged states from the old states cache.""" - # Make a map from old_state_id to entity_id - old_states = instance._old_states # pylint: disable=protected-access - old_state_reversed = { - old_state.state_id: entity_id - for entity_id, old_state in old_states.items() - if old_state.state_id - } - - # Evict any purged state from the old states cache - for purged_state_id in purged_state_ids.intersection(old_state_reversed): - old_states.pop(old_state_reversed[purged_state_id], None) - - -def _evict_purged_data_from_data_cache( - instance: Recorder, purged_data_ids: set[int] -) -> None: - """Evict purged data ids from the data ids cache.""" - # Make a map from data_id to the data json - event_data_ids = instance._event_data_ids # pylint: disable=protected-access - event_data_ids_reversed = { - data_id: data for data, data_id in event_data_ids.items() - } - - # Evict any purged data from the event_data_ids cache - for purged_attribute_id in purged_data_ids.intersection(event_data_ids_reversed): - event_data_ids.pop(event_data_ids_reversed[purged_attribute_id], None) - - -def _evict_purged_attributes_from_attributes_cache( - instance: Recorder, purged_attributes_ids: set[int] -) -> None: - """Evict purged attribute ids from the attribute ids cache.""" - # Make a map from attributes_id to the attributes json - state_attributes_ids = ( - instance._state_attributes_ids # pylint: disable=protected-access - ) - state_attributes_ids_reversed = { - attributes_id: attributes - for attributes, attributes_id in state_attributes_ids.items() - } - - # Evict any purged attributes from the state_attributes_ids cache - for purged_attribute_id in purged_attributes_ids.intersection( - state_attributes_ids_reversed - ): - state_attributes_ids.pop( - state_attributes_ids_reversed[purged_attribute_id], None - ) + instance.states_manager.evict_purged_state_ids(state_ids) def _purge_batch_attributes_ids( instance: Recorder, session: Session, attributes_ids: set[int] ) -> None: - """Delete old attributes ids in batches of MAX_ROWS_TO_PURGE.""" - for attributes_ids_chunk in chunked(attributes_ids, MAX_ROWS_TO_PURGE): + """Delete old attributes ids in batches of SQLITE_MAX_BIND_VARS.""" + for attributes_ids_chunk in chunked(attributes_ids, SQLITE_MAX_BIND_VARS): deleted_rows = session.execute( delete_states_attributes_rows(attributes_ids_chunk) ) _LOGGER.debug("Deleted %s attribute states", deleted_rows) # Evict any entries in the state_attributes_ids cache referring to a purged state - _evict_purged_attributes_from_attributes_cache(instance, attributes_ids) + instance.state_attributes_manager.evict_purged(attributes_ids) def _purge_batch_data_ids( instance: Recorder, session: Session, data_ids: set[int] ) -> None: - """Delete old event data ids in batches of MAX_ROWS_TO_PURGE.""" - for data_ids_chunk in chunked(data_ids, MAX_ROWS_TO_PURGE): + """Delete old event data ids in batches of SQLITE_MAX_BIND_VARS.""" + for data_ids_chunk in chunked(data_ids, SQLITE_MAX_BIND_VARS): deleted_rows = session.execute(delete_event_data_rows(data_ids_chunk)) _LOGGER.debug("Deleted %s data events", deleted_rows) # Evict any entries in the event_data_ids cache referring to a purged state - _evict_purged_data_from_data_cache(instance, data_ids) + instance.event_data_manager.evict_purged(data_ids) def _purge_statistics_runs(session: Session, statistics_runs: list[int]) -> None: @@ -564,8 +550,10 @@ def _purge_short_term_statistics( _LOGGER.debug("Deleted %s short term statistics", deleted_rows) -def _purge_event_ids(session: Session, event_ids: Iterable[int]) -> None: +def _purge_event_ids(session: Session, event_ids: set[int]) -> None: """Delete by event id.""" + if not event_ids: + return deleted_rows = session.execute(delete_event_rows(event_ids)) _LOGGER.debug("Deleted %s events", deleted_rows) @@ -576,120 +564,211 @@ def _purge_old_recorder_runs( """Purge all old recorder runs.""" # Recorder runs is small, no need to batch run it deleted_rows = session.execute( - delete_recorder_runs_rows(purge_before, instance.run_history.current.run_id) + delete_recorder_runs_rows( + purge_before, instance.recorder_runs_manager.current.run_id + ) ) _LOGGER.debug("Deleted %s recorder_runs", deleted_rows) +def _purge_old_event_types(instance: Recorder, session: Session) -> None: + """Purge all old event types.""" + # Event types is small, no need to batch run it + purge_event_types = set() + event_type_ids = set() + for event_type_id, event_type in session.execute(find_event_types_to_purge()): + purge_event_types.add(event_type) + event_type_ids.add(event_type_id) + + if not event_type_ids: + return + + deleted_rows = session.execute(delete_event_types_rows(event_type_ids)) + _LOGGER.debug("Deleted %s event types", deleted_rows) + + # Evict any entries in the event_type cache referring to a purged state + instance.event_type_manager.evict_purged(purge_event_types) + + +def _purge_old_entity_ids(instance: Recorder, session: Session) -> None: + """Purge all old entity_ids.""" + # entity_ids are small, no need to batch run it + purge_entity_ids = set() + states_metadata_ids = set() + for metadata_id, entity_id in session.execute(find_entity_ids_to_purge()): + purge_entity_ids.add(entity_id) + states_metadata_ids.add(metadata_id) + + if not states_metadata_ids: + return + + deleted_rows = session.execute(delete_states_meta_rows(states_metadata_ids)) + _LOGGER.debug("Deleted %s states meta", deleted_rows) + + # Evict any entries in the event_type cache referring to a purged state + instance.states_meta_manager.evict_purged(purge_entity_ids) + instance.states_manager.evict_purged_entity_ids(purge_entity_ids) + + def _purge_filtered_data(instance: Recorder, session: Session) -> bool: """Remove filtered states and events that shouldn't be in the database.""" _LOGGER.debug("Cleanup filtered data") database_engine = instance.database_engine assert database_engine is not None + now_timestamp = time.time() # Check if excluded entity_ids are in database - excluded_entity_ids: list[str] = [ - entity_id - for (entity_id,) in session.query(distinct(States.entity_id)).all() - if not instance.entity_filter(entity_id) + entity_filter = instance.entity_filter + has_more_states_to_purge = False + excluded_metadata_ids: list[str] = [ + metadata_id + for (metadata_id, entity_id) in session.query( + StatesMeta.metadata_id, StatesMeta.entity_id + ).all() + if not entity_filter(entity_id) ] - if len(excluded_entity_ids) > 0: - _purge_filtered_states(instance, session, excluded_entity_ids, database_engine) - return False + if excluded_metadata_ids: + has_more_states_to_purge = _purge_filtered_states( + instance, session, excluded_metadata_ids, database_engine, now_timestamp + ) # Check if excluded event_types are in database - excluded_event_types: list[str] = [ - event_type - for (event_type,) in session.query(distinct(Events.event_type)).all() - if event_type in instance.exclude_t - ] - if len(excluded_event_types) > 0: - _purge_filtered_events(instance, session, excluded_event_types) - return False + has_more_events_to_purge = False + if ( + event_type_to_event_type_ids := instance.event_type_manager.get_many( + instance.exclude_event_types, session + ) + ) and ( + excluded_event_type_ids := [ + event_type_id + for event_type_id in event_type_to_event_type_ids.values() + if event_type_id is not None + ] + ): + has_more_events_to_purge = _purge_filtered_events( + instance, session, excluded_event_type_ids, now_timestamp + ) - return True + # Purge has completed if there are not more state or events to purge + return not (has_more_states_to_purge or has_more_events_to_purge) def _purge_filtered_states( instance: Recorder, session: Session, - excluded_entity_ids: list[str], + metadata_ids_to_purge: list[str], database_engine: DatabaseEngine, -) -> None: - """Remove filtered states and linked events.""" + purge_before_timestamp: float, +) -> bool: + """Remove filtered states and linked events. + + Return true if all states are purged + """ state_ids: tuple[int, ...] attributes_ids: tuple[int, ...] event_ids: tuple[int, ...] - state_ids, attributes_ids, event_ids = zip( - *( - session.query(States.state_id, States.attributes_id, States.event_id) - .filter(States.entity_id.in_(excluded_entity_ids)) - .limit(MAX_ROWS_TO_PURGE) - .all() - ) + to_purge = list( + session.query(States.state_id, States.attributes_id, States.event_id) + .filter(States.metadata_id.in_(metadata_ids_to_purge)) + .filter(States.last_updated_ts < purge_before_timestamp) + .limit(SQLITE_MAX_BIND_VARS) + .all() ) - filtered_event_ids = [id_ for id_ in event_ids if id_ is not None] + if not to_purge: + return True + state_ids, attributes_ids, event_ids = zip(*to_purge) + filtered_event_ids = {id_ for id_ in event_ids if id_ is not None} _LOGGER.debug( "Selected %s state_ids to remove that should be filtered", len(state_ids) ) _purge_state_ids(instance, session, set(state_ids)) + # These are legacy events that are linked to a state that are no longer + # created but since we did not remove them when we stopped adding new ones + # we will need to purge them here. _purge_event_ids(session, filtered_event_ids) unused_attribute_ids_set = _select_unused_attributes_ids( session, {id_ for id_ in attributes_ids if id_ is not None}, database_engine ) _purge_batch_attributes_ids(instance, session, unused_attribute_ids_set) + return False def _purge_filtered_events( - instance: Recorder, session: Session, excluded_event_types: list[str] -) -> None: - """Remove filtered events and linked states.""" + instance: Recorder, + session: Session, + excluded_event_type_ids: list[int], + purge_before_timestamp: float, +) -> bool: + """Remove filtered events and linked states. + + Return true if all events are purged. + """ database_engine = instance.database_engine assert database_engine is not None - event_ids, data_ids = zip( - *( - session.query(Events.event_id, Events.data_id) - .filter(Events.event_type.in_(excluded_event_types)) - .limit(MAX_ROWS_TO_PURGE) + to_purge = list( + session.query(Events.event_id, Events.data_id) + .filter(Events.event_type_id.in_(excluded_event_type_ids)) + .filter(Events.time_fired_ts < purge_before_timestamp) + .limit(SQLITE_MAX_BIND_VARS) + .all() + ) + if not to_purge: + return True + event_ids, data_ids = zip(*to_purge) + event_ids_set = set(event_ids) + _LOGGER.debug( + "Selected %s event_ids to remove that should be filtered", len(event_ids_set) + ) + if ( + instance.use_legacy_events_index + and ( + states := session.query(States.state_id) + .filter(States.event_id.in_(event_ids_set)) .all() ) - ) - _LOGGER.debug( - "Selected %s event_ids to remove that should be filtered", len(event_ids) - ) - states: list[Row[tuple[int]]] = ( - session.query(States.state_id).filter(States.event_id.in_(event_ids)).all() - ) - state_ids: set[int] = {state.state_id for state in states} - _purge_state_ids(instance, session, state_ids) - _purge_event_ids(session, event_ids) + and (state_ids := {state_id for (state_id,) in states}) + ): + # These are legacy states that are linked to an event that are no longer + # created but since we did not remove them when we stopped adding new ones + # we will need to purge them here. + _purge_state_ids(instance, session, state_ids) + _purge_event_ids(session, event_ids_set) if unused_data_ids_set := _select_unused_event_data_ids( session, set(data_ids), database_engine ): _purge_batch_data_ids(instance, session, unused_data_ids_set) - if EVENT_STATE_CHANGED in excluded_event_types: - session.query(StateAttributes).delete(synchronize_session=False) - instance._state_attributes_ids = {} # pylint: disable=protected-access + return False -@retryable_database_job("purge") -def purge_entity_data(instance: Recorder, entity_filter: Callable[[str], bool]) -> bool: +@retryable_database_job("purge_entity_data") +def purge_entity_data( + instance: Recorder, entity_filter: Callable[[str], bool], purge_before: datetime +) -> bool: """Purge states and events of specified entities.""" database_engine = instance.database_engine assert database_engine is not None + purge_before_timestamp = purge_before.timestamp() with session_scope(session=instance.get_session()) as session: - selected_entity_ids: list[str] = [ - entity_id - for (entity_id,) in session.query(distinct(States.entity_id)).all() + selected_metadata_ids: list[str] = [ + metadata_id + for (metadata_id, entity_id) in session.query( + StatesMeta.metadata_id, StatesMeta.entity_id + ).all() if entity_filter(entity_id) ] - _LOGGER.debug("Purging entity data for %s", selected_entity_ids) - if len(selected_entity_ids) > 0: - # Purge a max of MAX_ROWS_TO_PURGE, based on the oldest states - # or events record. - _purge_filtered_states( - instance, session, selected_entity_ids, database_engine - ) + _LOGGER.debug("Purging entity data for %s", selected_metadata_ids) + if not selected_metadata_ids: + return True + + # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states + # or events record. + if not _purge_filtered_states( + instance, + session, + selected_metadata_ids, + database_engine, + purge_before_timestamp, + ): _LOGGER.debug("Purging entity data hasn't fully completed yet") return False diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index d12b6409b7..49f66fdcd6 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -8,35 +8,69 @@ from sqlalchemy import delete, distinct, func, lambda_stmt, select, union_all, u from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import Select -from .const import MAX_ROWS_TO_PURGE +from .const import SQLITE_MAX_BIND_VARS from .db_schema import ( EventData, Events, + EventTypes, RecorderRuns, StateAttributes, States, + StatesMeta, StatisticsRuns, StatisticsShortTerm, ) -def find_shared_attributes_id( - data_hash: int, shared_attrs: str -) -> StatementLambdaElement: - """Find an attributes_id by hash and shared_attrs.""" - return lambda_stmt( - lambda: select(StateAttributes.attributes_id) - .filter(StateAttributes.hash == data_hash) - .filter(StateAttributes.shared_attrs == shared_attrs) +def select_event_type_ids(event_types: tuple[str, ...]) -> Select: + """Generate a select for event type ids. + + This query is intentionally not a lambda statement as it is used inside + other lambda statements. + """ + return select(EventTypes.event_type_id).where( + EventTypes.event_type.in_(event_types) ) -def find_shared_data_id(attr_hash: int, shared_data: str) -> StatementLambdaElement: - """Find a data_id by hash and shared_data.""" +def get_shared_attributes(hashes: list[int]) -> StatementLambdaElement: + """Load shared attributes from the database.""" return lambda_stmt( - lambda: select(EventData.data_id) - .filter(EventData.hash == attr_hash) - .filter(EventData.shared_data == shared_data) + lambda: select( + StateAttributes.attributes_id, StateAttributes.shared_attrs + ).where(StateAttributes.hash.in_(hashes)) + ) + + +def get_shared_event_datas(hashes: list[int]) -> StatementLambdaElement: + """Load shared event data from the database.""" + return lambda_stmt( + lambda: select(EventData.data_id, EventData.shared_data).where( + EventData.hash.in_(hashes) + ) + ) + + +def find_event_type_ids(event_types: Iterable[str]) -> StatementLambdaElement: + """Find an event_type id by event_type.""" + return lambda_stmt( + lambda: select(EventTypes.event_type_id, EventTypes.event_type).filter( + EventTypes.event_type.in_(event_types) + ) + ) + + +def find_all_states_metadata_ids() -> StatementLambdaElement: + """Find all metadata_ids and entity_ids.""" + return lambda_stmt(lambda: select(StatesMeta.metadata_id, StatesMeta.entity_id)) + + +def find_states_metadata_ids(entity_ids: Iterable[str]) -> StatementLambdaElement: + """Find metadata_ids by entity_ids.""" + return lambda_stmt( + lambda: select(StatesMeta.metadata_id, StatesMeta.entity_id).filter( + StatesMeta.entity_id.in_(entity_ids) + ) ) @@ -587,7 +621,7 @@ def find_events_to_purge(purge_before: float) -> StatementLambdaElement: return lambda_stmt( lambda: select(Events.event_id, Events.data_id) .filter(Events.time_fired_ts < purge_before) - .limit(MAX_ROWS_TO_PURGE) + .limit(SQLITE_MAX_BIND_VARS) ) @@ -596,7 +630,7 @@ def find_states_to_purge(purge_before: float) -> StatementLambdaElement: return lambda_stmt( lambda: select(States.state_id, States.attributes_id) .filter(States.last_updated_ts < purge_before) - .limit(MAX_ROWS_TO_PURGE) + .limit(SQLITE_MAX_BIND_VARS) ) @@ -608,7 +642,7 @@ def find_short_term_statistics_to_purge( return lambda_stmt( lambda: select(StatisticsShortTerm.id) .filter(StatisticsShortTerm.start_ts < purge_before_ts) - .limit(MAX_ROWS_TO_PURGE) + .limit(SQLITE_MAX_BIND_VARS) ) @@ -619,7 +653,7 @@ def find_statistics_runs_to_purge( return lambda_stmt( lambda: select(StatisticsRuns.run_id) .filter(StatisticsRuns.start < purge_before) - .limit(MAX_ROWS_TO_PURGE) + .limit(SQLITE_MAX_BIND_VARS) ) @@ -640,7 +674,23 @@ def find_legacy_event_state_and_attributes_and_data_ids_to_purge( ) .outerjoin(States, Events.event_id == States.event_id) .filter(Events.time_fired_ts < purge_before) - .limit(MAX_ROWS_TO_PURGE) + .limit(SQLITE_MAX_BIND_VARS) + ) + + +def find_legacy_detached_states_and_attributes_to_purge( + purge_before: float, +) -> StatementLambdaElement: + """Find states rows with event_id set but not linked event_id in Events.""" + return lambda_stmt( + lambda: select(States.state_id, States.attributes_id) + .outerjoin(Events, States.event_id == Events.event_id) + .filter(States.event_id.isnot(None)) + .filter( + (States.last_updated_ts < purge_before) | States.last_updated_ts.is_(None) + ) + .filter(Events.event_id.is_(None)) + .limit(SQLITE_MAX_BIND_VARS) ) @@ -649,3 +699,169 @@ def find_legacy_row() -> StatementLambdaElement: # https://github.com/sqlalchemy/sqlalchemy/issues/9189 # pylint: disable-next=not-callable return lambda_stmt(lambda: select(func.max(States.event_id))) + + +def find_events_context_ids_to_migrate() -> StatementLambdaElement: + """Find events context_ids to migrate.""" + return lambda_stmt( + lambda: select( + Events.event_id, + Events.time_fired_ts, + Events.context_id, + Events.context_user_id, + Events.context_parent_id, + ) + .filter(Events.context_id_bin.is_(None)) + .limit(SQLITE_MAX_BIND_VARS) + ) + + +def find_event_type_to_migrate() -> StatementLambdaElement: + """Find events event_type to migrate.""" + return lambda_stmt( + lambda: select( + Events.event_id, + Events.event_type, + ) + .filter(Events.event_type_id.is_(None)) + .limit(SQLITE_MAX_BIND_VARS) + ) + + +def find_entity_ids_to_migrate() -> StatementLambdaElement: + """Find entity_id to migrate.""" + return lambda_stmt( + lambda: select( + States.state_id, + States.entity_id, + ) + .filter(States.metadata_id.is_(None)) + .limit(SQLITE_MAX_BIND_VARS) + ) + + +def batch_cleanup_entity_ids() -> StatementLambdaElement: + """Find entity_id to cleanup.""" + # Self join because This version of MariaDB doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery' + return lambda_stmt( + lambda: update(States) + .where( + States.state_id.in_( + select(States.state_id) + .join( + states_with_entity_ids := select( + States.state_id.label("state_id_with_entity_id") + ) + .filter(States.entity_id.is_not(None)) + .limit(5000) + .subquery(), + States.state_id == states_with_entity_ids.c.state_id_with_entity_id, + ) + .alias("states_with_entity_ids") + .select() + ) + ) + .values(entity_id=None) + ) + + +def has_used_states_event_ids() -> StatementLambdaElement: + """Check if there are used event_ids in the states table.""" + return lambda_stmt( + lambda: select(States.state_id).filter(States.event_id.isnot(None)).limit(1) + ) + + +def has_events_context_ids_to_migrate() -> StatementLambdaElement: + """Check if there are events context ids to migrate.""" + return lambda_stmt( + lambda: select(Events.event_id).filter(Events.context_id_bin.is_(None)).limit(1) + ) + + +def has_states_context_ids_to_migrate() -> StatementLambdaElement: + """Check if there are states context ids to migrate.""" + return lambda_stmt( + lambda: select(States.state_id).filter(States.context_id_bin.is_(None)).limit(1) + ) + + +def has_event_type_to_migrate() -> StatementLambdaElement: + """Check if there are event_types to migrate.""" + return lambda_stmt( + lambda: select(Events.event_id).filter(Events.event_type_id.is_(None)).limit(1) + ) + + +def has_entity_ids_to_migrate() -> StatementLambdaElement: + """Check if there are entity_id to migrate.""" + return lambda_stmt( + lambda: select(States.state_id).filter(States.metadata_id.is_(None)).limit(1) + ) + + +def find_states_context_ids_to_migrate() -> StatementLambdaElement: + """Find events context_ids to migrate.""" + return lambda_stmt( + lambda: select( + States.state_id, + States.last_updated_ts, + States.context_id, + States.context_user_id, + States.context_parent_id, + ) + .filter(States.context_id_bin.is_(None)) + .limit(SQLITE_MAX_BIND_VARS) + ) + + +def find_event_types_to_purge() -> StatementLambdaElement: + """Find event_type_ids to purge.""" + return lambda_stmt( + lambda: select(EventTypes.event_type_id, EventTypes.event_type).where( + EventTypes.event_type_id.not_in( + select(EventTypes.event_type_id).join( + used_event_type_ids := select( + distinct(Events.event_type_id).label("used_event_type_id") + ).subquery(), + EventTypes.event_type_id + == used_event_type_ids.c.used_event_type_id, + ) + ) + ) + ) + + +def find_entity_ids_to_purge() -> StatementLambdaElement: + """Find entity_ids to purge.""" + return lambda_stmt( + lambda: select(StatesMeta.metadata_id, StatesMeta.entity_id).where( + StatesMeta.metadata_id.not_in( + select(StatesMeta.metadata_id).join( + used_states_metadata_id := select( + distinct(States.metadata_id).label("used_states_metadata_id") + ).subquery(), + StatesMeta.metadata_id + == used_states_metadata_id.c.used_states_metadata_id, + ) + ) + ) + ) + + +def delete_event_types_rows(event_type_ids: Iterable[int]) -> StatementLambdaElement: + """Delete EventTypes rows.""" + return lambda_stmt( + lambda: delete(EventTypes) + .where(EventTypes.event_type_id.in_(event_type_ids)) + .execution_options(synchronize_session=False) + ) + + +def delete_states_meta_rows(metadata_ids: Iterable[int]) -> StatementLambdaElement: + """Delete StatesMeta rows.""" + return lambda_stmt( + lambda: delete(StatesMeta) + .where(StatesMeta.metadata_id.in_(metadata_ids)) + .execution_options(synchronize_session=False) + ) diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py index 14337290c9..fb2cd1f0be 100644 --- a/homeassistant/components/recorder/services.py +++ b/homeassistant/components/recorder/services.py @@ -9,7 +9,10 @@ import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import generate_filter -from homeassistant.helpers.service import async_extract_entity_ids +from homeassistant.helpers.service import ( + async_extract_entity_ids, + async_register_admin_service, +) import homeassistant.util.dt as dt_util from .const import ATTR_APPLY_FILTER, ATTR_KEEP_DAYS, ATTR_REPACK, DOMAIN @@ -38,6 +41,7 @@ SERVICE_PURGE_ENTITIES_SCHEMA = vol.Schema( vol.Optional(ATTR_ENTITY_GLOBS, default=[]): vol.All( cv.ensure_list, [cv.string] ), + vol.Optional(ATTR_KEEP_DAYS, default=0): cv.positive_int, } ).extend(cv.ENTITY_SERVICE_FIELDS) @@ -56,8 +60,12 @@ def _async_register_purge_service(hass: HomeAssistant, instance: Recorder) -> No purge_before = dt_util.utcnow() - timedelta(days=keep_days) instance.queue_task(PurgeTask(purge_before, repack, apply_filter)) - hass.services.async_register( - DOMAIN, SERVICE_PURGE, async_handle_purge_service, schema=SERVICE_PURGE_SCHEMA + async_register_admin_service( + hass, + DOMAIN, + SERVICE_PURGE, + async_handle_purge_service, + schema=SERVICE_PURGE_SCHEMA, ) @@ -69,11 +77,14 @@ def _async_register_purge_entities_service( """Handle calls to the purge entities service.""" entity_ids = await async_extract_entity_ids(hass, service) domains = service.data.get(ATTR_DOMAINS, []) + keep_days = service.data.get(ATTR_KEEP_DAYS, 0) entity_globs = service.data.get(ATTR_ENTITY_GLOBS, []) entity_filter = generate_filter(domains, list(entity_ids), [], [], entity_globs) - instance.queue_task(PurgeEntitiesTask(entity_filter)) + purge_before = dt_util.utcnow() - timedelta(days=keep_days) + instance.queue_task(PurgeEntitiesTask(entity_filter, purge_before)) - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_PURGE_ENTITIES, async_handle_purge_entities_service, @@ -86,7 +97,8 @@ def _async_register_enable_service(hass: HomeAssistant, instance: Recorder) -> N async def async_handle_enable_service(service: ServiceCall) -> None: instance.set_enable(True) - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_ENABLE, async_handle_enable_service, @@ -99,7 +111,8 @@ def _async_register_disable_service(hass: HomeAssistant, instance: Recorder) -> async def async_handle_disable_service(service: ServiceCall) -> None: instance.set_enable(False) - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_DISABLE, async_handle_disable_service, diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml index 43ff7548dd..f099cede9f 100644 --- a/homeassistant/components/recorder/services.yaml +++ b/homeassistant/components/recorder/services.yaml @@ -51,6 +51,16 @@ purge_entities: selector: object: + keep_days: + name: Days to keep + description: Number of history days to keep in database of matching rows. The default of 0 days will remove all matching rows. + default: 0 + selector: + number: + min: 0 + max: 365 + unit_of_measurement: days + disable: name: Disable description: Stop the recording of events and state changes diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 59a12a6441..70f35d0349 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2,37 +2,28 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Callable, Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Sequence import contextlib import dataclasses from datetime import datetime, timedelta from functools import lru_cache, partial from itertools import chain, groupby -import json import logging from operator import itemgetter -import os import re from statistics import mean -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast -from sqlalchemy import bindparam, func, lambda_stmt, select, text -from sqlalchemy.engine import Engine +from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text from sqlalchemy.engine.row import Row -from sqlalchemy.exc import OperationalError, SQLAlchemyError, StatementError +from sqlalchemy.exc import SQLAlchemyError, StatementError from sqlalchemy.orm.session import Session -from sqlalchemy.sql.expression import literal_column, true from sqlalchemy.sql.lambdas import StatementLambdaElement -from sqlalchemy.sql.selectable import Subquery import voluptuous as vol from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT -from homeassistant.core import Event, HomeAssistant, callback, valid_entity_id +from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry -from homeassistant.helpers.json import JSONEncoder -from homeassistant.helpers.start import async_at_start -from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( @@ -56,14 +47,15 @@ from .const import ( DOMAIN, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, - MAX_ROWS_TO_PURGE, + INTEGRATION_PLATFORM_COMPILE_STATISTICS, + INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, + INTEGRATION_PLATFORM_VALIDATE_STATISTICS, SupportedDialect, ) from .db_schema import ( STATISTICS_TABLES, Statistics, StatisticsBase, - StatisticsMeta, StatisticsRuns, StatisticsShortTerm, ) @@ -73,6 +65,7 @@ from .models import ( StatisticMetaData, StatisticResult, datetime_to_timestamp_or_none, + process_timestamp, ) from .util import ( execute, @@ -132,16 +125,6 @@ QUERY_STATISTICS_SUMMARY_SUM = ( .label("rownum"), ) -QUERY_STATISTIC_META = ( - StatisticsMeta.id, - StatisticsMeta.statistic_id, - StatisticsMeta.source, - StatisticsMeta.unit_of_measurement, - StatisticsMeta.has_mean, - StatisticsMeta.has_sum, - StatisticsMeta.name, -) - STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, @@ -166,6 +149,25 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { _LOGGER = logging.getLogger(__name__) +class BaseStatisticsRow(TypedDict, total=False): + """A processed row of statistic data.""" + + start: float + + +class StatisticsRow(BaseStatisticsRow, total=False): + """A processed row of statistic data.""" + + end: float + last_reset: float | None + state: float | None + sum: float | None + min: float | None + max: float | None + mean: float | None + change: float | None + + def _get_unit_class(unit: str | None) -> str | None: """Get corresponding unit class from from the statistics unit.""" if converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(unit): @@ -317,35 +319,6 @@ class ValidationIssue: return dataclasses.asdict(self) -def async_setup(hass: HomeAssistant) -> None: - """Set up the history hooks.""" - - @callback - def _async_entity_id_changed(event: Event) -> None: - get_instance(hass).async_update_statistics_metadata( - event.data["old_entity_id"], new_statistic_id=event.data["entity_id"] - ) - - @callback - def entity_registry_changed_filter(event: Event) -> bool: - """Handle entity_id changed filter.""" - if event.data["action"] != "update" or "old_entity_id" not in event.data: - return False - - return True - - @callback - def setup_entity_registry_event_handler(hass: HomeAssistant) -> None: - """Subscribe to event registry events.""" - hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, - _async_entity_id_changed, - event_filter=entity_registry_changed_filter, - ) - - async_at_start(hass, setup_entity_registry_event_handler) - - def get_start_time() -> datetime: """Return start time.""" now = dt_util.utcnow() @@ -355,282 +328,6 @@ def get_start_time() -> datetime: return last_period -def _update_or_add_metadata( - session: Session, - new_metadata: StatisticMetaData, - old_metadata_dict: dict[str, tuple[int, StatisticMetaData]], -) -> int: - """Get metadata_id for a statistic_id. - - If the statistic_id is previously unknown, add it. If it's already known, update - metadata if needed. - - Updating metadata source is not possible. - """ - statistic_id = new_metadata["statistic_id"] - if statistic_id not in old_metadata_dict: - meta = StatisticsMeta.from_meta(new_metadata) - session.add(meta) - session.flush() # Flush to get the metadata id assigned - _LOGGER.debug( - "Added new statistics metadata for %s, new_metadata: %s", - statistic_id, - new_metadata, - ) - return meta.id - - metadata_id, old_metadata = old_metadata_dict[statistic_id] - if ( - old_metadata["has_mean"] != new_metadata["has_mean"] - or old_metadata["has_sum"] != new_metadata["has_sum"] - or old_metadata["name"] != new_metadata["name"] - or old_metadata["unit_of_measurement"] != new_metadata["unit_of_measurement"] - ): - session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update( - { - StatisticsMeta.has_mean: new_metadata["has_mean"], - StatisticsMeta.has_sum: new_metadata["has_sum"], - StatisticsMeta.name: new_metadata["name"], - StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"], - }, - synchronize_session=False, - ) - _LOGGER.debug( - "Updated statistics metadata for %s, old_metadata: %s, new_metadata: %s", - statistic_id, - old_metadata, - new_metadata, - ) - - return metadata_id - - -def _find_duplicates( - session: Session, table: type[StatisticsBase] -) -> tuple[list[int], list[dict]]: - """Find duplicated statistics.""" - subquery = ( - session.query( - table.start, - table.metadata_id, - literal_column("1").label("is_duplicate"), - ) - .group_by(table.metadata_id, table.start) - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - .having(func.count() > 1) - .subquery() - ) - query = ( - session.query( - table.id, - table.metadata_id, - table.created, - table.start, - table.mean, - table.min, - table.max, - table.last_reset, - table.state, - table.sum, - ) - .outerjoin( - subquery, - (subquery.c.metadata_id == table.metadata_id) - & (subquery.c.start == table.start), - ) - .filter(subquery.c.is_duplicate == 1) - .order_by(table.metadata_id, table.start, table.id.desc()) - .limit(1000 * MAX_ROWS_TO_PURGE) - ) - duplicates = execute(query) - original_as_dict = {} - start = None - metadata_id = None - duplicate_ids: list[int] = [] - non_identical_duplicates_as_dict: list[dict] = [] - - if not duplicates: - return (duplicate_ids, non_identical_duplicates_as_dict) - - def columns_to_dict(duplicate: Row) -> dict: - """Convert a SQLAlchemy row to dict.""" - dict_ = {} - for key in ( - "id", - "metadata_id", - "start", - "created", - "mean", - "min", - "max", - "last_reset", - "state", - "sum", - ): - dict_[key] = getattr(duplicate, key) - return dict_ - - def compare_statistic_rows(row1: dict, row2: dict) -> bool: - """Compare two statistics rows, ignoring id and created.""" - ignore_keys = {"id", "created"} - keys1 = set(row1).difference(ignore_keys) - keys2 = set(row2).difference(ignore_keys) - return keys1 == keys2 and all(row1[k] == row2[k] for k in keys1) - - for duplicate in duplicates: - if start != duplicate.start or metadata_id != duplicate.metadata_id: - original_as_dict = columns_to_dict(duplicate) - start = duplicate.start - metadata_id = duplicate.metadata_id - continue - duplicate_as_dict = columns_to_dict(duplicate) - duplicate_ids.append(duplicate.id) - if not compare_statistic_rows(original_as_dict, duplicate_as_dict): - non_identical_duplicates_as_dict.append( - {"duplicate": duplicate_as_dict, "original": original_as_dict} - ) - - return (duplicate_ids, non_identical_duplicates_as_dict) - - -def _delete_duplicates_from_table( - session: Session, table: type[StatisticsBase] -) -> tuple[int, list[dict]]: - """Identify and delete duplicated statistics from a specified table.""" - all_non_identical_duplicates: list[dict] = [] - total_deleted_rows = 0 - while True: - duplicate_ids, non_identical_duplicates = _find_duplicates(session, table) - if not duplicate_ids: - break - all_non_identical_duplicates.extend(non_identical_duplicates) - for i in range(0, len(duplicate_ids), MAX_ROWS_TO_PURGE): - deleted_rows = ( - session.query(table) - .filter(table.id.in_(duplicate_ids[i : i + MAX_ROWS_TO_PURGE])) - .delete(synchronize_session=False) - ) - total_deleted_rows += deleted_rows - return (total_deleted_rows, all_non_identical_duplicates) - - -def delete_statistics_duplicates(hass: HomeAssistant, session: Session) -> None: - """Identify and delete duplicated statistics. - - A backup will be made of duplicated statistics before it is deleted. - """ - deleted_statistics_rows, non_identical_duplicates = _delete_duplicates_from_table( - session, Statistics - ) - if deleted_statistics_rows: - _LOGGER.info("Deleted %s duplicated statistics rows", deleted_statistics_rows) - - if non_identical_duplicates: - isotime = dt_util.utcnow().isoformat() - backup_file_name = f"deleted_statistics.{isotime}.json" - backup_path = hass.config.path(STORAGE_DIR, backup_file_name) - - os.makedirs(os.path.dirname(backup_path), exist_ok=True) - with open(backup_path, "w", encoding="utf8") as backup_file: - json.dump( - non_identical_duplicates, - backup_file, - indent=4, - sort_keys=True, - cls=JSONEncoder, - ) - _LOGGER.warning( - ( - "Deleted %s non identical duplicated %s rows, a backup of the deleted" - " rows has been saved to %s" - ), - len(non_identical_duplicates), - Statistics.__tablename__, - backup_path, - ) - - deleted_short_term_statistics_rows, _ = _delete_duplicates_from_table( - session, StatisticsShortTerm - ) - if deleted_short_term_statistics_rows: - _LOGGER.warning( - "Deleted duplicated short term statistic rows, please report at %s", - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+recorder%22", - ) - - -def _find_statistics_meta_duplicates(session: Session) -> list[int]: - """Find duplicated statistics_meta.""" - # When querying the database, be careful to only explicitly query for columns - # which were present in schema version 29. If querying the table, SQLAlchemy - # will refer to future columns. - subquery = ( - session.query( - StatisticsMeta.statistic_id, - literal_column("1").label("is_duplicate"), - ) - .group_by(StatisticsMeta.statistic_id) - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - .having(func.count() > 1) - .subquery() - ) - query = ( - session.query(StatisticsMeta.statistic_id, StatisticsMeta.id) - .outerjoin( - subquery, - (subquery.c.statistic_id == StatisticsMeta.statistic_id), - ) - .filter(subquery.c.is_duplicate == 1) - .order_by(StatisticsMeta.statistic_id, StatisticsMeta.id.desc()) - .limit(1000 * MAX_ROWS_TO_PURGE) - ) - duplicates = execute(query) - statistic_id = None - duplicate_ids: list[int] = [] - - if not duplicates: - return duplicate_ids - - for duplicate in duplicates: - if statistic_id != duplicate.statistic_id: - statistic_id = duplicate.statistic_id - continue - duplicate_ids.append(duplicate.id) - - return duplicate_ids - - -def _delete_statistics_meta_duplicates(session: Session) -> int: - """Identify and delete duplicated statistics from a specified table.""" - total_deleted_rows = 0 - while True: - duplicate_ids = _find_statistics_meta_duplicates(session) - if not duplicate_ids: - break - for i in range(0, len(duplicate_ids), MAX_ROWS_TO_PURGE): - deleted_rows = ( - session.query(StatisticsMeta) - .filter(StatisticsMeta.id.in_(duplicate_ids[i : i + MAX_ROWS_TO_PURGE])) - .delete(synchronize_session=False) - ) - total_deleted_rows += deleted_rows - return total_deleted_rows - - -def delete_statistics_meta_duplicates(session: Session) -> None: - """Identify and delete duplicated statistics_meta. - - This is used when migrating from schema version 28 to schema version 29. - """ - deleted_statistics_rows = _delete_statistics_meta_duplicates(session) - if deleted_statistics_rows: - _LOGGER.info( - "Deleted %s duplicated statistics_meta rows", deleted_statistics_rows - ) - - def _compile_hourly_statistics_summary_mean_stmt( start_time_ts: float, end_time_ts: float ) -> StatementLambdaElement: @@ -644,27 +341,19 @@ def _compile_hourly_statistics_summary_mean_stmt( ) -def _compile_hourly_statistics_last_sum_stmt_subquery( - start_time_ts: float, end_time_ts: float -) -> Subquery: - """Generate the summary mean statement for hourly statistics.""" - return ( - select(*QUERY_STATISTICS_SUMMARY_SUM) - .filter(StatisticsShortTerm.start_ts >= start_time_ts) - .filter(StatisticsShortTerm.start_ts < end_time_ts) - .subquery() - ) - - def _compile_hourly_statistics_last_sum_stmt( start_time_ts: float, end_time_ts: float ) -> StatementLambdaElement: """Generate the summary mean statement for hourly statistics.""" - subquery = _compile_hourly_statistics_last_sum_stmt_subquery( - start_time_ts, end_time_ts - ) return lambda_stmt( - lambda: select(subquery) + lambda: select( + subquery := ( + select(*QUERY_STATISTICS_SUMMARY_SUM) + .filter(StatisticsShortTerm.start_ts >= start_time_ts) + .filter(StatisticsShortTerm.start_ts < end_time_ts) + .subquery() + ) + ) .filter(subquery.c.rownum == 1) .order_by(subquery.c.metadata_id) ) @@ -727,29 +416,108 @@ def _compile_hourly_statistics(session: Session, start: datetime) -> None: ) -@retryable_database_job("statistics") +@retryable_database_job("compile missing statistics") +def compile_missing_statistics(instance: Recorder) -> bool: + """Compile missing statistics.""" + now = dt_util.utcnow() + period_size = 5 + last_period_minutes = now.minute - now.minute % period_size + last_period = now.replace(minute=last_period_minutes, second=0, microsecond=0) + start = now - timedelta(days=instance.keep_days) + start = start.replace(minute=0, second=0, microsecond=0) + # Commit every 12 hours of data + commit_interval = 60 / period_size * 12 + + with session_scope( + session=instance.get_session(), + exception_filter=_filter_unique_constraint_integrity_error(instance), + ) as session: + # Find the newest statistics run, if any + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + if last_run := session.query(func.max(StatisticsRuns.start)).scalar(): + start = max(start, process_timestamp(last_run) + timedelta(minutes=5)) + + periods_without_commit = 0 + while start < last_period: + periods_without_commit += 1 + end = start + timedelta(minutes=period_size) + _LOGGER.debug("Compiling missing statistics for %s-%s", start, end) + modified_statistic_ids = _compile_statistics( + instance, session, start, end >= last_period + ) + if periods_without_commit == commit_interval or modified_statistic_ids: + session.commit() + session.expunge_all() + periods_without_commit = 0 + start = end + + return True + + +@retryable_database_job("compile statistics") def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) -> bool: """Compile 5-minute statistics for all integrations with a recorder platform. The actual calculation is delegated to the platforms. """ - start = dt_util.as_utc(start) + # Return if we already have 5-minute statistics for the requested period + with session_scope( + session=instance.get_session(), + exception_filter=_filter_unique_constraint_integrity_error(instance), + ) as session: + modified_statistic_ids = _compile_statistics( + instance, session, start, fire_events + ) + + if modified_statistic_ids: + # In the rare case that we have modified statistic_ids, we reload the modified + # statistics meta data into the cache in a fresh session to ensure that the + # cache is up to date and future calls to get statistics meta data will + # not have to hit the database again. + with session_scope(session=instance.get_session(), read_only=True) as session: + instance.statistics_meta_manager.get_many(session, modified_statistic_ids) + + 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]: + """Compile 5-minute statistics for all integrations with a recorder platform. + + This is a helper function for compile_statistics and compile_missing_statistics + that does not retry on database errors since both callers already retry. + + returns a set of modified statistic_ids if any were modified. + """ + assert start.tzinfo == dt_util.UTC, "start must be in UTC" end = start + timedelta(minutes=5) + statistics_meta_manager = instance.statistics_meta_manager + modified_statistic_ids: set[str] = set() # Return if we already have 5-minute statistics for the requested period - with session_scope(session=instance.get_session()) as session: - if session.query(StatisticsRuns).filter_by(start=start).first(): - _LOGGER.debug("Statistics already compiled for %s-%s", start, end) - return True + 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 _LOGGER.debug("Compiling statistics for %s-%s", start, end) platform_stats: list[StatisticResult] = [] 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( @@ -763,33 +531,31 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) - current_metadata.update(compiled.current_metadata) # Insert collected statistics in the database - with session_scope( - session=instance.get_session(), - exception_filter=_filter_unique_constraint_integrity_error(instance), - ) as session: - for stats in platform_stats: - metadata_id = _update_or_add_metadata( - session, stats["meta"], current_metadata - ) - _insert_statistics( - session, - StatisticsShortTerm, - metadata_id, - stats["stat"], - ) + for stats in platform_stats: + modified_statistic_id, metadata_id = statistics_meta_manager.update_or_add( + session, stats["meta"], current_metadata + ) + if modified_statistic_id is not None: + modified_statistic_ids.add(modified_statistic_id) + _insert_statistics( + session, + StatisticsShortTerm, + metadata_id, + stats["stat"], + ) - if start.minute == 55: - # A full hour is ready, summarize it - _compile_hourly_statistics(session, start) + if start.minute == 55: + # A full hour is ready, summarize it + _compile_hourly_statistics(session, start) - session.add(StatisticsRuns(start=start)) + session.add(StatisticsRuns(start=start)) if fire_events: instance.hass.bus.fire(EVENT_RECORDER_5MIN_STATISTICS_GENERATED) if start.minute == 55: instance.hass.bus.fire(EVENT_RECORDER_HOURLY_STATISTICS_GENERATED) - return True + return modified_statistic_ids def _adjust_sum_statistics( @@ -863,74 +629,39 @@ def _update_statistics( ) -def _generate_get_metadata_stmt( - statistic_ids: list[str] | None = None, - statistic_type: Literal["mean"] | Literal["sum"] | None = None, - statistic_source: str | None = None, -) -> StatementLambdaElement: - """Generate a statement to fetch metadata.""" - stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META)) - if statistic_ids: - stmt += lambda q: q.where( - # https://github.com/python/mypy/issues/2608 - StatisticsMeta.statistic_id.in_(statistic_ids) # type:ignore[arg-type] - ) - if statistic_source is not None: - stmt += lambda q: q.where(StatisticsMeta.source == statistic_source) - if statistic_type == "mean": - stmt += lambda q: q.where(StatisticsMeta.has_mean == true()) - elif statistic_type == "sum": - stmt += lambda q: q.where(StatisticsMeta.has_sum == true()) - return stmt - - def get_metadata_with_session( + instance: Recorder, session: Session, *, - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: """Fetch meta data. Returns a dict of (metadata_id, StatisticMetaData) tuples indexed by statistic_id. - If statistic_ids is given, fetch metadata only for the listed statistics_ids. If statistic_type is given, fetch metadata only for statistic_ids supporting it. """ - - # Fetch metatadata from the database - stmt = _generate_get_metadata_stmt(statistic_ids, statistic_type, statistic_source) - result = execute_stmt_lambda_element(session, stmt) - if not result: - return {} - - return { - meta.statistic_id: ( - meta.id, - { - "has_mean": meta.has_mean, - "has_sum": meta.has_sum, - "name": meta.name, - "source": meta.source, - "statistic_id": meta.statistic_id, - "unit_of_measurement": meta.unit_of_measurement, - }, - ) - for meta in result - } + return instance.statistics_meta_manager.get_many( + session, + statistic_ids=statistic_ids, + statistic_type=statistic_type, + statistic_source=statistic_source, + ) def get_metadata( hass: HomeAssistant, *, - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: """Return metadata for statistic_ids.""" - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return get_metadata_with_session( + get_instance(hass), session, statistic_ids=statistic_ids, statistic_type=statistic_type, @@ -938,17 +669,10 @@ def get_metadata( ) -def _clear_statistics_with_session(session: Session, statistic_ids: list[str]) -> None: - """Clear statistics for a list of statistic_ids.""" - session.query(StatisticsMeta).filter( - StatisticsMeta.statistic_id.in_(statistic_ids) - ).delete(synchronize_session=False) - - def clear_statistics(instance: Recorder, statistic_ids: list[str]) -> None: """Clear statistics for a list of statistic_ids.""" with session_scope(session=instance.get_session()) as session: - _clear_statistics_with_session(session, statistic_ids) + instance.statistics_meta_manager.delete(session, statistic_ids) def update_statistics_metadata( @@ -958,25 +682,25 @@ def update_statistics_metadata( new_unit_of_measurement: str | None | UndefinedType, ) -> None: """Update statistics metadata for a statistic_id.""" + statistics_meta_manager = instance.statistics_meta_manager if new_unit_of_measurement is not UNDEFINED: with session_scope(session=instance.get_session()) as session: - session.query(StatisticsMeta).filter( - StatisticsMeta.statistic_id == statistic_id - ).update({StatisticsMeta.unit_of_measurement: new_unit_of_measurement}) - if new_statistic_id is not UNDEFINED: + statistics_meta_manager.update_unit_of_measurement( + session, statistic_id, new_unit_of_measurement + ) + if new_statistic_id is not UNDEFINED and new_statistic_id is not None: with session_scope( session=instance.get_session(), exception_filter=_filter_unique_constraint_integrity_error(instance), ) as session: - session.query(StatisticsMeta).filter( - (StatisticsMeta.statistic_id == statistic_id) - & (StatisticsMeta.source == DOMAIN) - ).update({StatisticsMeta.statistic_id: new_statistic_id}) + statistics_meta_manager.update_statistic_id( + session, DOMAIN, statistic_id, new_statistic_id + ) -def list_statistic_ids( +async def async_list_statistic_ids( hass: HomeAssistant, - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, ) -> list[dict]: """Return all statistic_ids (or filtered one) and unit of measurement. @@ -985,51 +709,50 @@ def list_statistic_ids( a recorder platform for statistic_ids which will be added in the next statistics period. """ - result = {} + instance = get_instance(hass) - # Query the database - with session_scope(hass=hass) as session: - metadata = get_metadata_with_session( - session, statistic_type=statistic_type, statistic_ids=statistic_ids - ) + if statistic_ids is not None: + # Try to get the results from the cache since there is nearly + # always a cache hit. + statistics_meta_manager = instance.statistics_meta_manager + metadata = statistics_meta_manager.get_from_cache_threadsafe(statistic_ids) + if not statistic_ids.difference(metadata): + result = _statistic_by_id_from_metadata(hass, metadata) + return _flatten_list_statistic_ids_metadata_result(result) - result = { - meta["statistic_id"]: { - "display_unit_of_measurement": get_display_unit( - hass, meta["statistic_id"], meta["unit_of_measurement"] - ), - "has_mean": meta["has_mean"], - "has_sum": meta["has_sum"], - "name": meta["name"], - "source": meta["source"], - "unit_class": _get_unit_class(meta["unit_of_measurement"]), - "unit_of_measurement": meta["unit_of_measurement"], - } - for _, meta in metadata.values() + return await instance.async_add_executor_job( + list_statistic_ids, + hass, + statistic_ids, + statistic_type, + ) + + +def _statistic_by_id_from_metadata( + hass: HomeAssistant, + metadata: dict[str, tuple[int, StatisticMetaData]], +) -> dict[str, dict[str, Any]]: + """Return a list of results for a given metadata dict.""" + return { + meta["statistic_id"]: { + "display_unit_of_measurement": get_display_unit( + hass, meta["statistic_id"], meta["unit_of_measurement"] + ), + "has_mean": meta["has_mean"], + "has_sum": meta["has_sum"], + "name": meta["name"], + "source": meta["source"], + "unit_class": _get_unit_class(meta["unit_of_measurement"]), + "unit_of_measurement": meta["unit_of_measurement"], } + for _, meta in metadata.values() + } - # Query all integrations with a registered recorder platform - for platform in hass.data[DOMAIN].recorder_platforms.values(): - if not hasattr(platform, "list_statistic_ids"): - continue - platform_statistic_ids = platform.list_statistic_ids( - hass, statistic_ids=statistic_ids, statistic_type=statistic_type - ) - for key, meta in platform_statistic_ids.items(): - if key in result: - continue - result[key] = { - "display_unit_of_measurement": meta["unit_of_measurement"], - "has_mean": meta["has_mean"], - "has_sum": meta["has_sum"], - "name": meta["name"], - "source": meta["source"], - "unit_class": _get_unit_class(meta["unit_of_measurement"]), - "unit_of_measurement": meta["unit_of_measurement"], - } - - # Return a list of statistic_id + metadata +def _flatten_list_statistic_ids_metadata_result( + result: dict[str, dict[str, Any]] +) -> list[dict]: + """Return a flat dict of metadata.""" return [ { "statistic_id": _id, @@ -1045,15 +768,71 @@ def list_statistic_ids( ] +def list_statistic_ids( + hass: HomeAssistant, + statistic_ids: set[str] | None = None, + statistic_type: Literal["mean"] | Literal["sum"] | None = None, +) -> list[dict]: + """Return all statistic_ids (or filtered one) and unit of measurement. + + Queries the database for existing statistic_ids, as well as integrations with + a recorder platform for statistic_ids which will be added in the next statistics + period. + """ + result = {} + instance = get_instance(hass) + statistics_meta_manager = instance.statistics_meta_manager + + # Query the database + with session_scope(hass=hass, read_only=True) as session: + metadata = statistics_meta_manager.get_many( + session, statistic_type=statistic_type, statistic_ids=statistic_ids + ) + result = _statistic_by_id_from_metadata(hass, metadata) + + if not statistic_ids or statistic_ids.difference(result): + # If we want all statistic_ids, or some are missing, we need to query + # the integrations for the missing ones. + # + # Query all integrations with a registered recorder platform + for platform in hass.data[DOMAIN].recorder_platforms.values(): + if not ( + platform_list_statistic_ids := getattr( + platform, INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, None + ) + ): + continue + platform_statistic_ids = platform_list_statistic_ids( + hass, statistic_ids=statistic_ids, statistic_type=statistic_type + ) + + for key, meta in platform_statistic_ids.items(): + if key in result: + # The database has a higher priority than the integration + continue + result[key] = { + "display_unit_of_measurement": meta["unit_of_measurement"], + "has_mean": meta["has_mean"], + "has_sum": meta["has_sum"], + "name": meta["name"], + "source": meta["source"], + "unit_class": _get_unit_class(meta["unit_of_measurement"]), + "unit_of_measurement": meta["unit_of_measurement"], + } + + # Return a list of statistic_id + metadata + return _flatten_list_statistic_ids_metadata_result(result) + + def _reduce_statistics( - stats: dict[str, list[dict[str, Any]]], + stats: dict[str, list[StatisticsRow]], same_period: Callable[[float, float], bool], period_start_end: Callable[[float], tuple[float, float]], period: timedelta, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to daily or monthly statistics.""" - result: dict[str, list[dict[str, Any]]] = defaultdict(list) + result: dict[str, list[StatisticsRow]] = defaultdict(list) period_seconds = period.total_seconds() _want_mean = "mean" in types _want_min = "min" in types @@ -1065,25 +844,27 @@ def _reduce_statistics( max_values: list[float] = [] mean_values: list[float] = [] min_values: list[float] = [] - prev_stat: dict[str, Any] = stat_list[0] + prev_stat: StatisticsRow = stat_list[0] + fake_entry: StatisticsRow = {"start": stat_list[-1]["start"] + period_seconds} # Loop over the hourly statistics + a fake entry to end the period - for statistic in chain( - stat_list, ({"start": stat_list[-1]["start"] + period_seconds},) - ): + for statistic in chain(stat_list, (fake_entry,)): if not same_period(prev_stat["start"], statistic["start"]): start, end = period_start_end(prev_stat["start"]) # The previous statistic was the last entry of the period - row: dict[str, Any] = { + row: StatisticsRow = { "start": start, "end": end, } 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: @@ -1091,10 +872,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: @@ -1144,9 +921,9 @@ def reduce_day_ts_factory() -> ( def _reduce_statistics_per_day( - stats: dict[str, list[dict[str, Any]]], + stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to daily statistics.""" _same_day_ts, _day_start_end_ts = reduce_day_ts_factory() return _reduce_statistics( @@ -1194,9 +971,9 @@ def reduce_week_ts_factory() -> ( def _reduce_statistics_per_week( - stats: dict[str, list[dict[str, Any]]], + stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to weekly statistics.""" _same_week_ts, _week_start_end_ts = reduce_week_ts_factory() return _reduce_statistics( @@ -1204,6 +981,14 @@ def _reduce_statistics_per_week( ) +def _find_month_end_time(timestamp: datetime) -> datetime: + """Return the end of the month (midnight at the first day of the next month).""" + # We add 4 days to the end to make sure we are in the next month + return (timestamp.replace(day=28) + timedelta(days=4)).replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + + def reduce_month_ts_factory() -> ( tuple[ Callable[[float, float], bool], @@ -1230,10 +1015,7 @@ def reduce_month_ts_factory() -> ( start_local = _local_from_timestamp(time).replace( day=1, hour=0, minute=0, second=0, microsecond=0 ) - # We add 4 days to the end to make sure we are in the next month - end_local = (start_local.replace(day=28) + timedelta(days=4)).replace( - day=1, hour=0, minute=0, second=0, microsecond=0 - ) + end_local = _find_month_end_time(start_local) return ( start_local.astimezone(dt_util.UTC).timestamp(), end_local.astimezone(dt_util.UTC).timestamp(), @@ -1246,9 +1028,9 @@ def reduce_month_ts_factory() -> ( def _reduce_statistics_per_month( - stats: dict[str, list[dict[str, Any]]], + stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to monthly statistics.""" _same_month_ts, _month_start_end_ts = reduce_month_ts_factory() return _reduce_statistics( @@ -1256,7 +1038,7 @@ def _reduce_statistics_per_month( ) -def _statistics_during_period_stmt( +def _generate_statistics_during_period_stmt( start_time: datetime, end_time: datetime | None, metadata_ids: list[int] | None, @@ -1268,22 +1050,8 @@ def _statistics_during_period_stmt( This prepares a lambda_stmt query, so we don't insert the parameters yet. """ start_time_ts = start_time.timestamp() - - 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) - - 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) @@ -1296,6 +1064,23 @@ def _statistics_during_period_stmt( return stmt +def _generate_max_mean_min_statistic_in_sub_period_stmt( + columns: Select, + start_time: datetime | None, + end_time: datetime | None, + table: type[StatisticsBase], + metadata_id: int, +) -> StatementLambdaElement: + stmt = lambda_stmt(lambda: columns.filter(table.metadata_id == metadata_id)) + if start_time is not None: + start_time_ts = start_time.timestamp() + 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) + return stmt + + def _get_max_mean_min_statistic_in_sub_period( session: Session, result: dict[str, float], @@ -1321,13 +1106,9 @@ def _get_max_mean_min_statistic_in_sub_period( # https://github.com/sqlalchemy/sqlalchemy/issues/9189 # pylint: disable-next=not-callable columns = columns.add_columns(func.min(table.min)) - stmt = lambda_stmt(lambda: columns.filter(table.metadata_id == metadata_id)) - if start_time is not None: - start_time_ts = start_time.timestamp() - 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) + stmt = _generate_max_mean_min_statistic_in_sub_period_stmt( + columns, start_time, end_time, table, metadata_id + ) stats = cast(Sequence[Row[Any]], execute_stmt_lambda_element(session, stmt)) if not stats: return @@ -1588,14 +1369,16 @@ def statistic_during_period( result: dict[str, Any] = {} - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_id if not ( - metadata := get_metadata_with_session(session, statistic_ids=[statistic_id]) + metadata := get_instance(hass).statistics_meta_manager.get( + session, statistic_id + ) ): return result - metadata_id = metadata[statistic_id][0] + metadata_id = metadata[0] oldest_stat = _first_statistic(session, Statistics, metadata_id) oldest_5_min_stat = None @@ -1706,7 +1489,7 @@ def statistic_during_period( else: result["change"] = None - state_unit = unit = metadata[statistic_id][1]["unit_of_measurement"] + state_unit = unit = metadata[1]["unit_of_measurement"] if state := hass.states.get(statistic_id): state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) convert = _get_statistic_to_display_unit_converter(unit, state_unit, units) @@ -1714,55 +1497,185 @@ 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 _extract_metadata_and_discard_impossible_columns( + metadata: dict[str, tuple[int, StatisticMetaData]], + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], +) -> list[int]: + """Extract metadata ids from metadata and discard impossible columns.""" + metadata_ids = [] + has_mean = False + has_sum = False + for metadata_id, stats_metadata in metadata.values(): + metadata_ids.append(metadata_id) + has_mean |= stats_metadata["has_mean"] + has_sum |= stats_metadata["has_sum"] + if not has_mean: + types.discard("mean") + types.discard("min") + types.discard("max") + if not has_sum: + types.discard("sum") + types.discard("state") + return metadata_ids + + +def _augment_result_with_change( + hass: HomeAssistant, + session: Session, + start_time: datetime, + units: dict[str, str] | None, + _types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]], + table: type[Statistics | StatisticsShortTerm], + metadata: dict[str, tuple[int, StatisticMetaData]], + result: dict[str, list[StatisticsRow]], +) -> None: + """Add change to the result.""" + drop_sum = "sum" not in _types + prev_sums = {} + if tmp := _statistics_at_time( + session, + {metadata[statistic_id][0] for statistic_id in result}, + table, + start_time, + {"sum"}, + ): + _metadata = dict(metadata.values()) + for row in tmp: + metadata_by_id = _metadata[row.metadata_id] + statistic_id = metadata_by_id["statistic_id"] + + state_unit = unit = metadata_by_id["unit_of_measurement"] + if state := hass.states.get(statistic_id): + state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + convert = _get_statistic_to_display_unit_converter(unit, state_unit, units) + + if convert is not None: + prev_sums[statistic_id] = convert(row.sum) + else: + prev_sums[statistic_id] = row.sum + + for statistic_id, rows in result.items(): + prev_sum = prev_sums.get(statistic_id) or 0 + for statistics_row in rows: + if "sum" not in statistics_row: + continue + if drop_sum: + _sum = statistics_row.pop("sum") + else: + _sum = statistics_row["sum"] + if _sum is None: + statistics_row["change"] = None + continue + statistics_row["change"] = _sum - prev_sum + prev_sum = _sum + + def _statistics_during_period_with_session( hass: HomeAssistant, session: Session, start_time: datetime, end_time: datetime | None, - statistic_ids: list[str] | None, + statistic_ids: set[str] | None, period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str] | None, - types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: + _types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]], +) -> dict[str, list[StatisticsRow]]: """Return statistic data points during UTC period start_time - end_time. If end_time is omitted, returns statistics newer than or equal to start_time. If statistic_ids is omitted, returns statistics for all statistics ids. """ - metadata = None + if statistic_ids is not None and not isinstance(statistic_ids, set): + # This is for backwards compatibility to avoid a breaking change + # for custom integrations that call this method. + statistic_ids = set(statistic_ids) # type: ignore[unreachable] # Fetch metadata for the given (or all) statistic_ids - metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) + metadata = get_instance(hass).statistics_meta_manager.get_many( + session, statistic_ids=statistic_ids + ) if not metadata: return {} + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]] = set() + for stat_type in _types: + if stat_type == "change": + types.add("sum") + continue + types.add(stat_type) + metadata_ids = None if statistic_ids is not None: - metadata_ids = [metadata_id for metadata_id, _ in metadata.values()] + metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types) + + # Align start_time and end_time with the period + if period == "day": + start_time = dt_util.as_local(start_time).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + start_time = start_time.replace() + if end_time is not None: + end_local = dt_util.as_local(end_time) + end_time = end_local.replace( + hour=0, minute=0, second=0, microsecond=0 + ) + timedelta(days=1) + elif period == "week": + start_local = dt_util.as_local(start_time) + start_time = start_local.replace( + hour=0, minute=0, second=0, microsecond=0 + ) - timedelta(days=start_local.weekday()) + if end_time is not None: + end_local = dt_util.as_local(end_time) + end_time = ( + end_local.replace(hour=0, minute=0, second=0, microsecond=0) + - timedelta(days=end_local.weekday()) + + timedelta(days=7) + ) + elif period == "month": + start_time = dt_util.as_local(start_time).replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + if end_time is not None: + end_time = _find_month_end_time(dt_util.as_local(end_time)) table: type[Statistics | StatisticsShortTerm] = ( Statistics if period != "5minute" else StatisticsShortTerm ) - stmt = _statistics_during_period_stmt( + stmt = _generate_statistics_during_period_stmt( start_time, end_time, metadata_ids, table, types ) - 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 {} - # 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, @@ -1778,29 +1691,38 @@ def _statistics_during_period_with_session( ) if period == "day": - return _reduce_statistics_per_day(result, types) + result = _reduce_statistics_per_day(result, types) if period == "week": - return _reduce_statistics_per_week(result, types) + result = _reduce_statistics_per_week(result, types) - return _reduce_statistics_per_month(result, types) + if period == "month": + result = _reduce_statistics_per_month(result, types) + + if "change" in _types: + _augment_result_with_change( + hass, session, start_time, units, _types, table, metadata, result + ) + + # Return statistics combined with metadata + return result def statistics_during_period( hass: HomeAssistant, start_time: datetime, end_time: datetime | None, - statistic_ids: list[str] | None, + statistic_ids: set[str] | None, period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str] | None, - types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: + types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]], +) -> dict[str, list[StatisticsRow]]: """Return statistic data points during UTC period start_time - end_time. If end_time is omitted, returns statistics newer than or equal to start_time. If statistic_ids is omitted, returns statistics for all statistics ids. """ - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return _statistics_during_period_with_session( hass, session, @@ -1849,20 +1771,25 @@ def _get_last_statistics( convert_units: bool, table: type[StatisticsBase], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict]]: +) -> dict[str, list[StatisticsRow]]: """Return the last number_of_stats statistics for a given statistic_id.""" - statistic_ids = [statistic_id] - with session_scope(hass=hass) as session: + statistic_ids = {statistic_id} + with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_id - metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) + metadata = get_instance(hass).statistics_meta_manager.get_many( + session, statistic_ids=statistic_ids + ) if not metadata: return {} - metadata_id = metadata[statistic_id][0] + metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types) + metadata_id = metadata_ids[0] if table == 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 {} @@ -1888,7 +1815,7 @@ def get_last_statistics( statistic_id: str, convert_units: bool, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict]]: +) -> dict[str, list[StatisticsRow]]: """Return the last number_of_stats statistics for a statistic_id.""" return _get_last_statistics( hass, number_of_stats, statistic_id, convert_units, Statistics, types @@ -1901,64 +1828,57 @@ def get_last_short_term_statistics( statistic_id: str, convert_units: bool, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict]]: +) -> dict[str, list[StatisticsRow]]: """Return the last number_of_stats short term statistics for a statistic_id.""" return _get_last_statistics( hass, number_of_stats, statistic_id, convert_units, StatisticsShortTerm, types ) -def _generate_most_recent_statistic_row(metadata_ids: list[int]) -> Subquery: - """Generate the subquery to find the most recent statistic row.""" - return ( - select( - StatisticsShortTerm.metadata_id, - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(StatisticsShortTerm.start_ts).label("start_max"), - ) - .where(StatisticsShortTerm.metadata_id.in_(metadata_ids)) - .group_by(StatisticsShortTerm.metadata_id) - ).subquery() - - def _latest_short_term_statistics_stmt( metadata_ids: list[int], ) -> StatementLambdaElement: """Create the statement for finding the latest short term stat rows.""" - stmt = lambda_stmt(lambda: select(*QUERY_STATISTICS_SHORT_TERM)) - most_recent_statistic_row = _generate_most_recent_statistic_row(metadata_ids) - stmt += lambda s: s.join( - most_recent_statistic_row, - ( - StatisticsShortTerm.metadata_id # pylint: disable=comparison-with-callable - == most_recent_statistic_row.c.metadata_id + return lambda_stmt( + lambda: select(*QUERY_STATISTICS_SHORT_TERM).join( + ( + most_recent_statistic_row := ( + select( + StatisticsShortTerm.metadata_id, + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(StatisticsShortTerm.start_ts).label("start_max"), + ) + .where(StatisticsShortTerm.metadata_id.in_(metadata_ids)) + .group_by(StatisticsShortTerm.metadata_id) + ).subquery() + ), + (StatisticsShortTerm.metadata_id == most_recent_statistic_row.c.metadata_id) + & (StatisticsShortTerm.start_ts == most_recent_statistic_row.c.start_max), ) - & (StatisticsShortTerm.start_ts == most_recent_statistic_row.c.start_max), ) - return stmt def get_latest_short_term_statistics( hass: HomeAssistant, - statistic_ids: list[str], + statistic_ids: set[str], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], metadata: dict[str, tuple[int, StatisticMetaData]] | None = None, -) -> dict[str, list[dict]]: +) -> dict[str, list[StatisticsRow]]: """Return the latest short term statistics for a list of statistic_ids.""" - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_ids if not metadata: - metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) + metadata = get_instance(hass).statistics_meta_manager.get_many( + session, statistic_ids=statistic_ids + ) if not metadata: return {} - metadata_ids = [ - metadata[statistic_id][0] - for statistic_id in statistic_ids - if statistic_id in metadata - ] + metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types) 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 {} @@ -1977,6 +1897,37 @@ def get_latest_short_term_statistics( ) +def _generate_statistics_at_time_stmt( + 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.""" + 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"), + ) + .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( session: Session, metadata_ids: set[int], @@ -1985,58 +1936,54 @@ 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 = lambda_stmt(lambda: columns) - - most_recent_statistic_ids = ( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - lambda_stmt(lambda: select(func.max(table.id).label("max_id"))) - .filter(table.start_ts < start_time_ts) - .filter(table.metadata_id.in_(metadata_ids)) - .group_by(table.metadata_id) - .subquery() - ) - - stmt += lambda q: q.join( - most_recent_statistic_ids, - table.id == most_recent_statistic_ids.c.max_id, - ) + 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]], - statistic_ids: list[str] | None, + statistic_ids: set[str] | None, _metadata: dict[str, tuple[int, StatisticMetaData]], convert_units: bool, table: type[StatisticsBase], start_time: datetime | None, units: dict[str, str] | None, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict]]: +) -> dict[str, list[StatisticsRow]]: """Convert SQL results into JSON friendly data structure.""" assert stats, "stats must not be empty" # Guard against implementation error - result: dict = defaultdict(list) + result: dict[str, list[StatisticsRow]] = defaultdict(list) metadata = dict(_metadata.values()) - need_stat_at_start_time: set[int] = set() - start_time_ts = start_time.timestamp() if start_time else None # Identify metadata IDs for which no data was available at the requested start time field_map: dict[str, int] = {key: idx for idx, key in enumerate(stats[0]._fields)} metadata_id_idx = field_map["metadata_id"] @@ -2047,9 +1994,6 @@ def _sorted_statistics_to_dict( for meta_id, group in groupby(stats, key_func): stats_list = stats_by_meta_id[meta_id] = list(group) seen_statistic_ids.add(metadata[meta_id]["statistic_id"]) - first_start_time_ts = stats_list[0][start_ts_idx] - if start_time_ts and first_start_time_ts > start_time_ts: - need_stat_at_start_time.add(meta_id) # Set all statistic IDs to empty lists in result set to maintain the order if statistic_ids is not None: @@ -2060,15 +2004,6 @@ def _sorted_statistics_to_dict( if stat_id in seen_statistic_ids: result[stat_id] = [] - # Fetch last known statistics for the needed metadata IDs - if need_stat_at_start_time: - assert start_time # Can not be None if need_stat_at_start_time is not empty - if tmp := _statistics_at_time( - session, need_stat_at_start_time, table, start_time, types - ): - for stat in tmp: - stats_by_meta_id[stat[metadata_id_idx]].insert(0, stat) - # Figure out which fields we need to extract from the SQL result # and which indices they have in the result so we can avoid the overhead # of doing a dict lookup for each row @@ -2078,6 +2013,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(): @@ -2090,6 +2026,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 @@ -2099,7 +2052,7 @@ def _sorted_statistics_to_dict( # attribute lookups, and dict lookups as much as possible. # for db_state in stats_list: - row: dict[str, Any] = { + row: StatisticsRow = { "start": (start_ts := db_state[start_ts_idx]), "end": start_ts + table_duration_seconds, } @@ -2136,9 +2089,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 @@ -2277,16 +2231,20 @@ def _filter_unique_constraint_integrity_error( def _import_statistics_with_session( + instance: Recorder, session: Session, metadata: StatisticMetaData, statistics: Iterable[StatisticData], table: type[StatisticsBase], ) -> bool: """Import statistics to the database.""" - old_metadata_dict = get_metadata_with_session( - session, statistic_ids=[metadata["statistic_id"]] + statistics_meta_manager = instance.statistics_meta_manager + old_metadata_dict = statistics_meta_manager.get_many( + session, statistic_ids={metadata["statistic_id"]} + ) + _, metadata_id = statistics_meta_manager.update_or_add( + session, metadata, old_metadata_dict ) - metadata_id = _update_or_add_metadata(session, metadata, old_metadata_dict) for stat in statistics: if stat_id := _statistics_exists(session, table, metadata_id, stat["start"]): _update_statistics(session, table, stat_id, stat) @@ -2309,7 +2267,9 @@ def import_statistics( session=instance.get_session(), exception_filter=_filter_unique_constraint_integrity_error(instance), ) as session: - return _import_statistics_with_session(session, metadata, statistics, table) + return _import_statistics_with_session( + instance, session, metadata, statistics, table + ) @retryable_database_job("adjust_statistics") @@ -2323,7 +2283,9 @@ def adjust_statistics( """Process an add_statistics job.""" with session_scope(session=instance.get_session()) as session: - metadata = get_metadata_with_session(session, statistic_ids=[statistic_id]) + metadata = instance.statistics_meta_manager.get_many( + session, statistic_ids={statistic_id} + ) if statistic_id not in metadata: return True @@ -2382,10 +2344,9 @@ def change_statistics_unit( old_unit: str, ) -> None: """Change statistics unit for a statistic_id.""" + statistics_meta_manager = instance.statistics_meta_manager with session_scope(session=instance.get_session()) as session: - metadata = get_metadata_with_session(session, statistic_ids=[statistic_id]).get( - statistic_id - ) + metadata = statistics_meta_manager.get(session, statistic_id) # Guard against the statistics being removed or updated before the # change_statistics_unit job executes @@ -2406,9 +2367,10 @@ def change_statistics_unit( ) for table in tables: _change_statistics_unit_for_table(session, table, metadata_id, convert) - session.query(StatisticsMeta).filter( - StatisticsMeta.statistic_id == statistic_id - ).update({StatisticsMeta.unit_of_measurement: new_unit}) + + statistics_meta_manager.update_unit_of_measurement( + session, statistic_id, new_unit + ) @callback @@ -2432,247 +2394,6 @@ def async_change_statistics_unit( ) -def _validate_db_schema_utf8( - 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() - - # 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, - } - - # Try inserting some metadata which needs utfmb4 support - try: - with session_scope(session=session_maker()) as session: - old_metadata_dict = get_metadata_with_session( - session, statistic_ids=[statistic_id] - ) - try: - _update_or_add_metadata(session, metadata, old_metadata_dict) - _clear_statistics_with_session(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 _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() - - # 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 - precise_time = datetime(2020, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - - start_time = datetime(2020, 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.debug( - "Column %s in database table %s does not support %s (%s != %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, - ) - try: - with session_scope(session=session_maker()) as session: - for table in tables: - _import_statistics_with_session(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 - - check_columns( - schema_errors, - stored_statistic[0], - statistics, - ("max", "mean", "min", "state", "sum"), - table.__tablename__, - "double precision", - ) - assert statistics["last_reset"] - check_columns( - schema_errors, - stored_statistic[0], - { - "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", - ) - _clear_statistics_with_session(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) - if schema_errors: - _LOGGER.debug( - "Detected statistics schema errors: %s", ", ".join(sorted(schema_errors)) - ) - return schema_errors - - -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 datetime columns to µs precision - if instance.dialect_name == SupportedDialect.MYSQL: - datetime_type = "DATETIME(6)" - else: - datetime_type = "TIMESTAMP(6) WITH TIME ZONE" - _modify_columns( - session_maker, - engine, - table.__tablename__, - [ - f"last_reset {datetime_type}", - f"start {datetime_type}", - ], - ) - - def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: """Clean up the statistics migration from timestamp to datetime. @@ -2696,7 +2417,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: session.connection() .execute( text( - f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL where start is not NULL LIMIT 250000;" + f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL where start is not NULL LIMIT 100000;" ) ) .rowcount @@ -2712,7 +2433,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: .execute( text( f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL " # nosec - f"where id in (select id from {table} where start is not NULL LIMIT 250000)" + f"where id in (select id from {table} where start is not NULL LIMIT 100000)" ) ) .rowcount diff --git a/homeassistant/components/recorder/system_health/__init__.py b/homeassistant/components/recorder/system_health/__init__.py index da463d3861..a3545ec2c8 100644 --- a/homeassistant/components/recorder/system_health/__init__.py +++ b/homeassistant/components/recorder/system_health/__init__.py @@ -33,7 +33,7 @@ def async_register( def _get_db_stats(instance: Recorder, database_name: str) -> dict[str, Any]: """Get the stats about the database.""" db_stats: dict[str, Any] = {} - with session_scope(session=instance.get_session()) as session: + with session_scope(session=instance.get_session(), read_only=True) as session: if ( (dialect_name := instance.dialect_name) and (get_size := DIALECT_TO_GET_SIZE.get(dialect_name)) @@ -58,7 +58,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" instance = get_instance(hass) - run_history = instance.run_history + recorder_runs_manager = instance.recorder_runs_manager database_name = urlparse(instance.db_url).path.lstrip("/") db_engine_info = _async_get_db_engine_info(instance) db_stats: dict[str, Any] = {} @@ -68,7 +68,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: _get_db_stats, instance, database_name ) db_runs = { - "oldest_recorder_run": run_history.first.start, - "current_recorder_run": run_history.current.start, + "oldest_recorder_run": recorder_runs_manager.first.start, + "current_recorder_run": recorder_runs_manager.current.start, } return db_runs | db_stats | db_engine_info diff --git a/homeassistant/components/recorder/table_managers/__init__.py b/homeassistant/components/recorder/table_managers/__init__.py new file mode 100644 index 0000000000..e56ee4f341 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/__init__.py @@ -0,0 +1,75 @@ +"""Managers for each table.""" + +from collections.abc import MutableMapping +from typing import TYPE_CHECKING, Generic, TypeVar + +from lru import LRU # pylint: disable=no-name-in-module + +if TYPE_CHECKING: + from ..core import Recorder + +_DataT = TypeVar("_DataT") + + +class BaseTableManager(Generic[_DataT]): + """Base class for table managers.""" + + def __init__(self, recorder: "Recorder") -> None: + """Initialize the table manager. + + The table manager is responsible for managing the id mappings + for a table. When data is committed to the database, the + manager will move the data from the pending to the id map. + """ + self.active = False + self.recorder = recorder + self._pending: dict[str, _DataT] = {} + self._id_map: MutableMapping[str, int] = {} + + def get_from_cache(self, data: str) -> int | None: + """Resolve data to the id without accessing the underlying database. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self._id_map.get(data) + + def get_pending(self, shared_data: str) -> _DataT | None: + """Get pending data that have not be assigned ids yet. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self._pending.get(shared_data) + + def reset(self) -> None: + """Reset after the database has been reset or changed. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._id_map.clear() + self._pending.clear() + + +class BaseLRUTableManager(BaseTableManager[_DataT]): + """Base class for LRU table managers.""" + + def __init__(self, recorder: "Recorder", lru_size: int) -> None: + """Initialize the LRU table manager. + + We keep track of the most recently used items + and evict the least recently used items when the cache is full. + """ + super().__init__(recorder) + self._id_map: MutableMapping[str, int] = LRU(lru_size) + + def adjust_lru_size(self, new_size: int) -> None: + """Adjust the LRU cache size. + + This call is not thread-safe and must be called from the + recorder thread. + """ + lru: LRU = self._id_map + if new_size > lru.get_size(): + lru.set_size(new_size) diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py new file mode 100644 index 0000000000..4e08719e57 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -0,0 +1,140 @@ +"""Support managing EventData.""" +from __future__ import annotations + +from collections.abc import Iterable +import logging +from typing import TYPE_CHECKING, cast + +from sqlalchemy.orm.session import Session + +from homeassistant.core import Event +from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS + +from . import BaseLRUTableManager +from ..const import SQLITE_MAX_BIND_VARS +from ..db_schema import EventData +from ..queries import get_shared_event_datas +from ..util import chunked, execute_stmt_lambda_element + +if TYPE_CHECKING: + from ..core import Recorder + + +CACHE_SIZE = 2048 + +_LOGGER = logging.getLogger(__name__) + + +class EventDataManager(BaseLRUTableManager[EventData]): + """Manage the EventData table.""" + + def __init__(self, recorder: Recorder) -> None: + """Initialize the event type manager.""" + super().__init__(recorder, CACHE_SIZE) + self.active = True # always active + + def serialize_from_event(self, event: Event) -> bytes | None: + """Serialize event data.""" + try: + return EventData.shared_data_bytes_from_event( + event, self.recorder.dialect_name + ) + except JSON_ENCODE_EXCEPTIONS as ex: + _LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex) + return None + + def load(self, events: list[Event], session: Session) -> None: + """Load the shared_datas to data_ids mapping into memory from events. + + This call is not thread-safe and must be called from the + recorder thread. + """ + if hashes := { + EventData.hash_shared_data_bytes(shared_event_bytes) + for event in events + if (shared_event_bytes := self.serialize_from_event(event)) + }: + self._load_from_hashes(hashes, session) + + def get(self, shared_data: str, data_hash: int, session: Session) -> int | None: + """Resolve shared_datas to the data_id. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self.get_many(((shared_data, data_hash),), session)[shared_data] + + def get_many( + self, shared_data_data_hashs: Iterable[tuple[str, int]], session: Session + ) -> dict[str, int | None]: + """Resolve shared_datas to data_ids. + + This call is not thread-safe and must be called from the + recorder thread. + """ + results: dict[str, int | None] = {} + missing_hashes: set[int] = set() + for shared_data, data_hash in shared_data_data_hashs: + if (data_id := self._id_map.get(shared_data)) is None: + missing_hashes.add(data_hash) + + results[shared_data] = data_id + + if not missing_hashes: + return results + + return results | self._load_from_hashes(missing_hashes, session) + + def _load_from_hashes( + self, hashes: Iterable[int], session: Session + ) -> dict[str, int | None]: + """Load the shared_datas to data_ids mapping into memory from a list of hashes. + + This call is not thread-safe and must be called from the + recorder thread. + """ + results: dict[str, int | None] = {} + 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), orm_rows=False + ): + results[shared_data] = self._id_map[shared_data] = cast( + int, data_id + ) + + return results + + def add_pending(self, db_event_data: EventData) -> None: + """Add a pending EventData that will be committed at the next interval. + + This call is not thread-safe and must be called from the + recorder thread. + """ + assert db_event_data.shared_data is not None + shared_data: str = db_event_data.shared_data + self._pending[shared_data] = db_event_data + + def post_commit_pending(self) -> None: + """Call after commit to load the data_ids of the new EventData into the LRU. + + This call is not thread-safe and must be called from the + recorder thread. + """ + for shared_data, db_event_data in self._pending.items(): + self._id_map[shared_data] = db_event_data.data_id + self._pending.clear() + + def evict_purged(self, data_ids: set[int]) -> None: + """Evict purged data_ids from the cache when they are no longer used. + + This call is not thread-safe and must be called from the + recorder thread. + """ + id_map = self._id_map + event_data_ids_reversed = { + data_id: shared_data for shared_data, data_id in id_map.items() + } + # Evict any purged data from the cache + for purged_data_id in data_ids.intersection(event_data_ids_reversed): + id_map.pop(event_data_ids_reversed[purged_data_id], None) diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py new file mode 100644 index 0000000000..d5541c547d --- /dev/null +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -0,0 +1,141 @@ +"""Support managing EventTypes.""" +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 + +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: + from ..core import Recorder + + +CACHE_SIZE = 2048 + + +class EventTypeManager(BaseLRUTableManager[EventTypes]): + """Manage the EventTypes table.""" + + 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. + + This call is not thread-safe and must be called from the + recorder thread. + """ + 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, 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 + recorder thread. + """ + return self.get_many((event_type,), session)[event_type] + + def get_many( + self, event_types: Iterable[str], session: Session, from_recorder: bool = False + ) -> dict[str, int | None]: + """Resolve event_types to event_type_ids. + + This call is not thread-safe and must be called from the + recorder thread. + """ + 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: + if event_type in self._non_existent_event_types: + results[event_type] = None + else: + missing.append(event_type) + + results[event_type] = event_type_id + + if not missing: + return results + + 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), 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: + """Add a pending EventTypes that will be committed at the next interval. + + This call is not thread-safe and must be called from the + recorder thread. + """ + assert db_event_type.event_type is not None + event_type: str = db_event_type.event_type + self._pending[event_type] = db_event_type + + def post_commit_pending(self) -> None: + """Call after commit to load the event_type_ids of the new EventTypes into the LRU. + + This call is not thread-safe and must be called from the + recorder thread. + """ + 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. + + This call is not thread-safe and must be called from the + recorder thread. + """ + for event_type in event_types: + self._id_map.pop(event_type, None) diff --git a/homeassistant/components/recorder/run_history.py b/homeassistant/components/recorder/table_managers/recorder_runs.py similarity index 97% rename from homeassistant/components/recorder/run_history.py rename to homeassistant/components/recorder/table_managers/recorder_runs.py index b424c99999..455c8375b1 100644 --- a/homeassistant/components/recorder/run_history.py +++ b/homeassistant/components/recorder/table_managers/recorder_runs.py @@ -9,8 +9,8 @@ from sqlalchemy.orm.session import Session import homeassistant.util.dt as dt_util -from .db_schema import RecorderRuns -from .models import process_timestamp +from ..db_schema import RecorderRuns +from ..models import process_timestamp def _find_recorder_run_for_start_time( @@ -40,7 +40,7 @@ class _RecorderRunsHistory: runs_by_timestamp: dict[int, RecorderRuns] -class RunHistory: +class RecorderRunsManager: """Track recorder run history.""" def __init__(self) -> None: diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py new file mode 100644 index 0000000000..442277be96 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -0,0 +1,160 @@ +"""Support managing StateAttributes.""" +from __future__ import annotations + +from collections.abc import Iterable +import logging +from typing import TYPE_CHECKING, cast + +from sqlalchemy.orm.session import Session + +from homeassistant.core import Event +from homeassistant.helpers.entity import entity_sources +from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS + +from . import BaseLRUTableManager +from ..const import SQLITE_MAX_BIND_VARS +from ..db_schema import StateAttributes +from ..queries import get_shared_attributes +from ..util import chunked, execute_stmt_lambda_element + +if TYPE_CHECKING: + from ..core import Recorder + +# The number of attribute ids to cache in memory +# +# Based on: +# - The number of overlapping attributes +# - How frequently states with overlapping attributes will change +# - How much memory our low end hardware has +CACHE_SIZE = 2048 + +_LOGGER = logging.getLogger(__name__) + + +class StateAttributesManager(BaseLRUTableManager[StateAttributes]): + """Manage the StateAttributes table.""" + + def __init__( + self, recorder: Recorder, exclude_attributes_by_domain: dict[str, set[str]] + ) -> None: + """Initialize the event type manager.""" + super().__init__(recorder, CACHE_SIZE) + self.active = True # always active + self._exclude_attributes_by_domain = exclude_attributes_by_domain + self._entity_sources = entity_sources(recorder.hass) + + def serialize_from_event(self, event: Event) -> bytes | None: + """Serialize event data.""" + try: + return StateAttributes.shared_attrs_bytes_from_event( + event, + self._entity_sources, + self._exclude_attributes_by_domain, + self.recorder.dialect_name, + ) + except JSON_ENCODE_EXCEPTIONS as ex: + _LOGGER.warning( + "State is not JSON serializable: %s: %s", + event.data.get("new_state"), + ex, + ) + return None + + def load(self, events: list[Event], session: Session) -> None: + """Load the shared_attrs to attributes_ids mapping into memory from events. + + This call is not thread-safe and must be called from the + recorder thread. + """ + if hashes := { + StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) + for event in events + if (shared_attrs_bytes := self.serialize_from_event(event)) + }: + self._load_from_hashes(hashes, session) + + def get(self, shared_attr: str, data_hash: int, session: Session) -> int | None: + """Resolve shared_attrs to the attributes_id. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self.get_many(((shared_attr, data_hash),), session)[shared_attr] + + def get_many( + self, shared_attrs_data_hashes: Iterable[tuple[str, int]], session: Session + ) -> dict[str, int | None]: + """Resolve shared_attrs to attributes_ids. + + This call is not thread-safe and must be called from the + recorder thread. + """ + results: dict[str, int | None] = {} + missing_hashes: set[int] = set() + for shared_attrs, data_hash in shared_attrs_data_hashes: + if (attributes_id := self._id_map.get(shared_attrs)) is None: + missing_hashes.add(data_hash) + + results[shared_attrs] = attributes_id + + if not missing_hashes: + return results + + return results | self._load_from_hashes(missing_hashes, session) + + def _load_from_hashes( + self, hashes: Iterable[int], session: Session + ) -> dict[str, int | None]: + """Load the shared_attrs to attributes_ids mapping into memory from a list of hashes. + + This call is not thread-safe and must be called from the + recorder thread. + """ + results: dict[str, int | None] = {} + 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), orm_rows=False + ): + results[shared_attrs] = self._id_map[shared_attrs] = cast( + int, attributes_id + ) + + return results + + def add_pending(self, db_state_attributes: StateAttributes) -> None: + """Add a pending StateAttributes that will be committed at the next interval. + + This call is not thread-safe and must be called from the + recorder thread. + """ + assert db_state_attributes.shared_attrs is not None + shared_attrs: str = db_state_attributes.shared_attrs + self._pending[shared_attrs] = db_state_attributes + + def post_commit_pending(self) -> None: + """Call after commit to load the attributes_ids of the new StateAttributes into the LRU. + + This call is not thread-safe and must be called from the + recorder thread. + """ + for shared_attrs, db_state_attributes in self._pending.items(): + self._id_map[shared_attrs] = db_state_attributes.attributes_id + self._pending.clear() + + def evict_purged(self, attributes_ids: set[int]) -> None: + """Evict purged attributes_ids from the cache when they are no longer used. + + This call is not thread-safe and must be called from the + recorder thread. + """ + id_map = self._id_map + state_attributes_ids_reversed = { + attributes_id: shared_attrs + for shared_attrs, attributes_id in id_map.items() + } + # Evict any purged data from the cache + for purged_attributes_id in attributes_ids.intersection( + state_attributes_ids_reversed + ): + id_map.pop(state_attributes_ids_reversed[purged_attributes_id], None) diff --git a/homeassistant/components/recorder/table_managers/states.py b/homeassistant/components/recorder/table_managers/states.py new file mode 100644 index 0000000000..fcfdcef089 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/states.py @@ -0,0 +1,91 @@ +"""Support managing States.""" +from __future__ import annotations + +from ..db_schema import States + + +class StatesManager: + """Manage the states table.""" + + def __init__(self) -> None: + """Initialize the states manager for linking old_state_id.""" + self._pending: dict[str, States] = {} + self._last_committed_id: dict[str, int] = {} + + def pop_pending(self, entity_id: str) -> States | None: + """Pop a pending state. + + Pending states are states that are in the session but not yet committed. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self._pending.pop(entity_id, None) + + def pop_committed(self, entity_id: str) -> int | None: + """Pop a committed state. + + Committed states are states that have already been committed to the + database. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self._last_committed_id.pop(entity_id, None) + + def add_pending(self, entity_id: str, state: States) -> None: + """Add a pending state. + + Pending states are states that are in the session but not yet committed. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._pending[entity_id] = state + + def post_commit_pending(self) -> None: + """Call after commit to load the state_id of the new States into committed. + + This call is not thread-safe and must be called from the + recorder thread. + """ + for entity_id, db_states in self._pending.items(): + self._last_committed_id[entity_id] = db_states.state_id + self._pending.clear() + + def reset(self) -> None: + """Reset after the database has been reset or changed. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._last_committed_id.clear() + self._pending.clear() + + def evict_purged_state_ids(self, purged_state_ids: set[int]) -> None: + """Evict purged states from the committed states. + + When we purge states we need to make sure the next call to record a state + does not link the old_state_id to the purged state. + """ + # Make a map from the committed state_id to the entity_id + last_committed_ids = self._last_committed_id + last_committed_ids_reversed = { + state_id: entity_id for entity_id, state_id in last_committed_ids.items() + } + + # Evict any purged state from the old states cache + for purged_state_id in purged_state_ids.intersection( + last_committed_ids_reversed + ): + last_committed_ids.pop(last_committed_ids_reversed[purged_state_id], None) + + def evict_purged_entity_ids(self, purged_entity_ids: set[str]) -> None: + """Evict purged entity_ids from the committed states. + + When we purge states we need to make sure the next call to record a state + does not link the old_state_id to the purged state. + """ + last_committed_ids = self._last_committed_id + for entity_id in purged_entity_ids: + last_committed_ids.pop(entity_id, None) diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py new file mode 100644 index 0000000000..bc4a8cfd2d --- /dev/null +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -0,0 +1,163 @@ +"""Support managing StatesMeta.""" +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from typing import TYPE_CHECKING, cast + +from sqlalchemy.orm.session import Session + +from homeassistant.core import Event + +from . import BaseLRUTableManager +from ..const import SQLITE_MAX_BIND_VARS +from ..db_schema import StatesMeta +from ..queries import find_all_states_metadata_ids, find_states_metadata_ids +from ..util import chunked, execute_stmt_lambda_element + +if TYPE_CHECKING: + from ..core import Recorder + +CACHE_SIZE = 8192 + + +class StatesMetaManager(BaseLRUTableManager[StatesMeta]): + """Manage the StatesMeta table.""" + + def __init__(self, recorder: Recorder) -> None: + """Initialize the states meta manager.""" + self._did_first_load = False + super().__init__(recorder, CACHE_SIZE) + + def load(self, events: list[Event], session: Session) -> None: + """Load the entity_id to metadata_id mapping into memory. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._did_first_load = True + self.get_many( + { + event.data["new_state"].entity_id + for event in events + if event.data.get("new_state") is not None + }, + session, + True, + ) + + def get(self, entity_id: str, session: Session, from_recorder: bool) -> int | None: + """Resolve entity_id to the metadata_id. + + This call is not thread-safe after startup since + purge can remove all references to an entity_id. + + When calling this method from the recorder thread, set + from_recorder to True to ensure any missing entity_ids + are added to the cache. + """ + return self.get_many((entity_id,), session, from_recorder)[entity_id] + + def get_metadata_id_to_entity_id(self, session: Session) -> dict[int, str]: + """Resolve all entity_ids to metadata_ids. + + This call is always thread-safe. + """ + with session.no_autoflush: + return dict( + cast( + Sequence[tuple[int, str]], + execute_stmt_lambda_element( + session, find_all_states_metadata_ids(), orm_rows=False + ), + ) + ) + + def get_many( + self, entity_ids: Iterable[str], session: Session, from_recorder: bool + ) -> dict[str, int | None]: + """Resolve entity_id to metadata_id. + + This call is not thread-safe after startup since + purge can remove all references to an entity_id. + + When calling this method from the recorder thread, set + from_recorder to True to ensure any missing entity_ids + are added to the cache. + """ + results: dict[str, int | None] = {} + missing: list[str] = [] + for entity_id in entity_ids: + if (metadata_id := self._id_map.get(entity_id)) is None: + missing.append(entity_id) + + results[entity_id] = metadata_id + + if not missing: + return results + + # Only update the cache if we are in the recorder thread + # or the recorder event loop has not started yet since + # there is a chance that we could have just deleted all + # instances of an entity_id from the database via purge + # and we do not want to add it back to the cache from another + # thread (history query). + update_cache = from_recorder or not self._did_first_load + + with session.no_autoflush: + for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): + for metadata_id, entity_id in execute_stmt_lambda_element( + session, find_states_metadata_ids(missing_chunk) + ): + metadata_id = cast(int, metadata_id) + results[entity_id] = metadata_id + + if update_cache: + self._id_map[entity_id] = metadata_id + + return results + + def add_pending(self, db_states_meta: StatesMeta) -> None: + """Add a pending StatesMeta that will be committed at the next interval. + + This call is not thread-safe and must be called from the + recorder thread. + """ + assert db_states_meta.entity_id is not None + entity_id: str = db_states_meta.entity_id + self._pending[entity_id] = db_states_meta + + def post_commit_pending(self) -> None: + """Call after commit to load the metadata_ids of the new StatesMeta into the LRU. + + This call is not thread-safe and must be called from the + recorder thread. + """ + for entity_id, db_states_meta in self._pending.items(): + self._id_map[entity_id] = db_states_meta.metadata_id + self._pending.clear() + + def evict_purged(self, entity_ids: Iterable[str]) -> None: + """Evict purged event_types from the cache when they are no longer used. + + This call is not thread-safe and must be called from the + recorder thread. + """ + for entity_id in entity_ids: + self._id_map.pop(entity_id, None) + + def update_metadata( + self, + session: Session, + entity_id: str, + new_entity_id: str, + ) -> bool: + """Update states metadata for an entity_id.""" + if self.get(new_entity_id, session, True) is not None: + # If the new entity id already exists we have + # a collision and should not update. + return False + session.query(StatesMeta).filter(StatesMeta.entity_id == entity_id).update( + {StatesMeta.entity_id: new_entity_id} + ) + self._id_map.pop(entity_id, None) + return True diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py new file mode 100644 index 0000000000..75af59d7c7 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -0,0 +1,343 @@ +"""Support managing StatesMeta.""" +from __future__ import annotations + +import logging +import threading +from typing import TYPE_CHECKING, Literal, cast + +from lru import LRU # pylint: disable=no-name-in-module +from sqlalchemy import lambda_stmt, select +from sqlalchemy.orm.session import Session +from sqlalchemy.sql.expression import true +from sqlalchemy.sql.lambdas import StatementLambdaElement + +from ..db_schema import StatisticsMeta +from ..models import StatisticMetaData +from ..util import execute_stmt_lambda_element + +if TYPE_CHECKING: + from ..core import Recorder + +CACHE_SIZE = 8192 + +_LOGGER = logging.getLogger(__name__) + +QUERY_STATISTIC_META = ( + StatisticsMeta.id, + StatisticsMeta.statistic_id, + StatisticsMeta.source, + StatisticsMeta.unit_of_measurement, + StatisticsMeta.has_mean, + StatisticsMeta.has_sum, + StatisticsMeta.name, +) + + +def _generate_get_metadata_stmt( + statistic_ids: set[str] | None = None, + statistic_type: Literal["mean"] | Literal["sum"] | None = None, + statistic_source: str | None = None, +) -> StatementLambdaElement: + """Generate a statement to fetch metadata.""" + stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META)) + if statistic_ids: + stmt += lambda q: q.where( + # https://github.com/python/mypy/issues/2608 + StatisticsMeta.statistic_id.in_(statistic_ids) # type:ignore[arg-type] + ) + if statistic_source is not None: + stmt += lambda q: q.where(StatisticsMeta.source == statistic_source) + if statistic_type == "mean": + stmt += lambda q: q.where(StatisticsMeta.has_mean == true()) + elif statistic_type == "sum": + stmt += lambda q: q.where(StatisticsMeta.has_sum == true()) + return stmt + + +def _statistics_meta_to_id_statistics_metadata( + meta: StatisticsMeta, +) -> tuple[int, StatisticMetaData]: + """Convert StatisticsMeta tuple of metadata_id and StatisticMetaData.""" + return ( + meta.id, + { + "has_mean": meta.has_mean, # type: ignore[typeddict-item] + "has_sum": meta.has_sum, # type: ignore[typeddict-item] + "name": meta.name, + "source": meta.source, # type: ignore[typeddict-item] + "statistic_id": meta.statistic_id, # type: ignore[typeddict-item] + "unit_of_measurement": meta.unit_of_measurement, + }, + ) + + +class StatisticsMetaManager: + """Manage the StatisticsMeta table.""" + + def __init__(self, recorder: Recorder) -> None: + """Initialize the statistics meta manager.""" + self.recorder = recorder + self._stat_id_to_id_meta: dict[str, tuple[int, StatisticMetaData]] = LRU( + CACHE_SIZE + ) + + def _clear_cache(self, statistic_ids: list[str]) -> None: + """Clear the cache.""" + for statistic_id in statistic_ids: + self._stat_id_to_id_meta.pop(statistic_id, None) + + def _get_from_database( + self, + session: Session, + statistic_ids: set[str] | None = None, + statistic_type: Literal["mean"] | Literal["sum"] | None = None, + statistic_source: str | None = None, + ) -> dict[str, tuple[int, StatisticMetaData]]: + """Fetch meta data and process it into results and/or cache.""" + # Only update the cache if we are in the recorder thread and there are no + # new objects that are not yet committed to the database in the session. + update_cache = ( + not session.new + and not session.dirty + and self.recorder.thread_id == threading.get_ident() + ) + results: dict[str, tuple[int, StatisticMetaData]] = {} + with session.no_autoflush: + stat_id_to_id_meta = self._stat_id_to_id_meta + for row in execute_stmt_lambda_element( + session, + _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) + + statistic_id = cast(str, statistics_meta.statistic_id) + results[statistic_id] = id_meta + if update_cache: + stat_id_to_id_meta[statistic_id] = id_meta + return results + + def _assert_in_recorder_thread(self) -> None: + """Assert that we are in the recorder thread.""" + if self.recorder.thread_id != threading.get_ident(): + raise RuntimeError("Detected unsafe call not in recorder thread") + + def _add_metadata( + self, session: Session, statistic_id: str, new_metadata: StatisticMetaData + ) -> int: + """Add metadata to the database. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._assert_in_recorder_thread() + meta = StatisticsMeta.from_meta(new_metadata) + session.add(meta) + # Flush to assign an ID + session.flush() + _LOGGER.debug( + "Added new statistics metadata for %s, new_metadata: %s", + statistic_id, + new_metadata, + ) + return meta.id + + def _update_metadata( + self, + session: Session, + statistic_id: str, + new_metadata: StatisticMetaData, + old_metadata_dict: dict[str, tuple[int, StatisticMetaData]], + ) -> tuple[str | None, int]: + """Update metadata in the database. + + This call is not thread-safe and must be called from the + recorder thread. + """ + metadata_id, old_metadata = old_metadata_dict[statistic_id] + if not ( + old_metadata["has_mean"] != new_metadata["has_mean"] + or old_metadata["has_sum"] != new_metadata["has_sum"] + or old_metadata["name"] != new_metadata["name"] + or old_metadata["unit_of_measurement"] + != new_metadata["unit_of_measurement"] + ): + return None, metadata_id + + self._assert_in_recorder_thread() + session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update( + { + StatisticsMeta.has_mean: new_metadata["has_mean"], + StatisticsMeta.has_sum: new_metadata["has_sum"], + StatisticsMeta.name: new_metadata["name"], + StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"], + }, + synchronize_session=False, + ) + self._clear_cache([statistic_id]) + _LOGGER.debug( + "Updated statistics metadata for %s, old_metadata: %s, new_metadata: %s", + statistic_id, + old_metadata, + new_metadata, + ) + return statistic_id, metadata_id + + def load(self, session: Session) -> None: + """Load the statistic_id to metadata_id mapping into memory. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self.get_many(session) + + def get( + self, session: Session, statistic_id: str + ) -> tuple[int, StatisticMetaData] | None: + """Resolve statistic_id to the metadata_id.""" + return self.get_many(session, {statistic_id}).get(statistic_id) + + def get_many( + self, + session: Session, + statistic_ids: set[str] | None = None, + statistic_type: Literal["mean"] | Literal["sum"] | None = None, + statistic_source: str | None = None, + ) -> dict[str, tuple[int, StatisticMetaData]]: + """Fetch meta data. + + Returns a dict of (metadata_id, StatisticMetaData) tuples indexed by statistic_id. + + If statistic_ids is given, fetch metadata only for the listed statistics_ids. + If statistic_type is given, fetch metadata only for statistic_ids supporting it. + """ + if statistic_ids is None: + # Fetch metadata from the database + return self._get_from_database( + session, + statistic_type=statistic_type, + statistic_source=statistic_source, + ) + + if statistic_type is not None or statistic_source is not None: + # This was originally implemented but we never used it + # so the code was ripped out to reduce the maintenance + # burden. + raise ValueError( + "Providing statistic_type and statistic_source is mutually exclusive of statistic_ids" + ) + + results = self.get_from_cache_threadsafe(statistic_ids) + if not (missing_statistic_id := statistic_ids.difference(results)): + return results + + # Fetch metadata from the database + return results | self._get_from_database( + session, statistic_ids=missing_statistic_id + ) + + def get_from_cache_threadsafe( + self, statistic_ids: set[str] + ) -> dict[str, tuple[int, StatisticMetaData]]: + """Get metadata from cache. + + This call is thread safe and can be run in the event loop, + the database executor, or the recorder thread. + """ + return { + statistic_id: id_meta + for statistic_id in statistic_ids + # We must use a get call here and never iterate over the dict + # because the dict can be modified by the recorder thread + # while we are iterating over it. + if (id_meta := self._stat_id_to_id_meta.get(statistic_id)) + } + + def update_or_add( + self, + session: Session, + new_metadata: StatisticMetaData, + old_metadata_dict: dict[str, tuple[int, StatisticMetaData]], + ) -> tuple[str | None, int]: + """Get metadata_id for a statistic_id. + + If the statistic_id is previously unknown, add it. If it's already known, update + metadata if needed. + + Updating metadata source is not possible. + + Returns a tuple of (statistic_id | None, metadata_id). + + statistic_id is None if the metadata was not updated + + This call is not thread-safe and must be called from the + recorder thread. + """ + statistic_id = new_metadata["statistic_id"] + if statistic_id not in old_metadata_dict: + return statistic_id, self._add_metadata(session, statistic_id, new_metadata) + return self._update_metadata( + session, statistic_id, new_metadata, old_metadata_dict + ) + + def update_unit_of_measurement( + self, session: Session, statistic_id: str, new_unit: str | None + ) -> None: + """Update the unit of measurement for a statistic_id. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._assert_in_recorder_thread() + session.query(StatisticsMeta).filter( + StatisticsMeta.statistic_id == statistic_id + ).update({StatisticsMeta.unit_of_measurement: new_unit}) + self._clear_cache([statistic_id]) + + def update_statistic_id( + self, + session: Session, + source: str, + old_statistic_id: str, + new_statistic_id: str, + ) -> None: + """Update the statistic_id for a statistic_id. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._assert_in_recorder_thread() + session.query(StatisticsMeta).filter( + (StatisticsMeta.statistic_id == old_statistic_id) + & (StatisticsMeta.source == source) + ).update({StatisticsMeta.statistic_id: new_statistic_id}) + self._clear_cache([old_statistic_id, new_statistic_id]) + + def delete(self, session: Session, statistic_ids: list[str]) -> None: + """Clear statistics for a list of statistic_ids. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._assert_in_recorder_thread() + session.query(StatisticsMeta).filter( + StatisticsMeta.statistic_id.in_(statistic_ids) + ).delete(synchronize_session=False) + self._clear_cache(statistic_ids) + + def reset(self) -> None: + """Reset the cache.""" + self._stat_id_to_id_meta.clear() + + def adjust_lru_size(self, new_size: int) -> None: + """Adjust the LRU cache size. + + This call is not thread-safe and must be called from the + recorder thread. + """ + lru: LRU = self._stat_id_to_id_meta + if new_size > lru.get_size(): + lru.set_size(new_size) diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 4d12f6b343..07be6202a0 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -6,23 +6,28 @@ import asyncio from collections.abc import Callable, Iterable from dataclasses import dataclass from datetime import datetime +import logging import threading from typing import TYPE_CHECKING, Any from homeassistant.core import Event from homeassistant.helpers.typing import UndefinedType -from . import purge, statistics -from .const import DOMAIN, EXCLUDE_ATTRIBUTES +from . import entity_registry, purge, statistics +from .const import DOMAIN from .db_schema import Statistics, StatisticsShortTerm from .models import StatisticData, StatisticMetaData -from .util import periodic_db_cleanups +from .util import periodic_db_cleanups, session_scope + +_LOGGER = logging.getLogger(__name__) + if TYPE_CHECKING: from .core import Recorder -class RecorderTask(abc.ABC): +@dataclass(slots=True) +class RecorderTask: """ABC for recorder tasks.""" commit_before = True @@ -32,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.""" @@ -50,7 +55,7 @@ class ChangeStatisticsUnitTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class ClearStatisticsTask(RecorderTask): """Object to store statistics_ids which for which to remove statistics.""" @@ -61,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.""" @@ -79,7 +84,23 @@ class UpdateStatisticsMetadataTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) +class UpdateStatesMetadataTask(RecorderTask): + """Task to update states metadata.""" + + entity_id: str + new_entity_id: str + + def run(self, instance: Recorder) -> None: + """Handle the task.""" + entity_registry.update_states_metadata( + instance, + self.entity_id, + self.new_entity_id, + ) + + +@dataclass(slots=True) class PurgeTask(RecorderTask): """Object to store information about purge task.""" @@ -93,7 +114,7 @@ class PurgeTask(RecorderTask): instance, self.purge_before, self.repack, self.apply_filter ): with instance.get_session() as session: - instance.run_history.load_from_db(session) + instance.recorder_runs_manager.load_from_db(session) # We always need to do the db cleanups after a purge # is finished to ensure the WAL checkpoint and other # tasks happen after a vacuum. @@ -105,21 +126,22 @@ class PurgeTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class PurgeEntitiesTask(RecorderTask): """Object to store entity information about purge task.""" entity_filter: Callable[[str], bool] + purge_before: datetime def run(self, instance: Recorder) -> None: """Purge entities from the database.""" - if purge.purge_entity_data(instance, self.entity_filter): + if purge.purge_entity_data(instance, self.entity_filter, self.purge_before): return # Schedule a new purge task if this one didn't finish - instance.queue_task(PurgeEntitiesTask(self.entity_filter)) + 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. @@ -131,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.""" @@ -146,7 +168,19 @@ 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.""" + + def run(self, instance: Recorder) -> None: + """Run statistics task to compile missing statistics.""" + if statistics.compile_missing_statistics(instance): + return + # Schedule a new statistics task if this one didn't finish + instance.queue_task(CompileMissingStatisticsTask()) + + +@dataclass(slots=True) class ImportStatisticsTask(RecorderTask): """An object to insert into the recorder queue to run an import statistics task.""" @@ -166,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.""" @@ -196,7 +230,7 @@ class AdjustStatisticsTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class WaitTask(RecorderTask): """An object to insert into the recorder queue. @@ -210,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.""" @@ -223,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.""" @@ -234,7 +268,7 @@ class StopTask(RecorderTask): instance.stop_requested = True -@dataclass +@dataclass(slots=True) class EventTask(RecorderTask): """An event to be processed.""" @@ -247,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.""" @@ -259,7 +293,7 @@ class KeepAliveTask(RecorderTask): instance._send_keep_alive() -@dataclass +@dataclass(slots=True) class CommitTask(RecorderTask): """Commit the event session.""" @@ -271,7 +305,7 @@ class CommitTask(RecorderTask): instance._commit_event_session_or_retry() -@dataclass +@dataclass(slots=True) class AddRecorderPlatformTask(RecorderTask): """Add a recorder platform.""" @@ -284,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.""" @@ -305,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.""" @@ -319,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.""" @@ -328,3 +359,124 @@ class StatisticsTimestampMigrationCleanupTask(RecorderTask): if not statistics.cleanup_statistics_timestamp_migration(instance): # Schedule a new statistics migration task if this one didn't finish instance.queue_task(StatisticsTimestampMigrationCleanupTask()) + + +@dataclass(slots=True) +class AdjustLRUSizeTask(RecorderTask): + """An object to insert into the recorder queue to adjust the LRU size.""" + + commit_before = False + + def run(self, instance: Recorder) -> None: + """Handle the task to adjust the size.""" + instance._adjust_lru_size() # pylint: disable=[protected-access] + + +@dataclass(slots=True) +class StatesContextIDMigrationTask(RecorderTask): + """An object to insert into the recorder queue to migrate states context ids.""" + + commit_before = False + + def run(self, instance: Recorder) -> None: + """Run context id migration task.""" + if ( + not instance._migrate_states_context_ids() # pylint: disable=[protected-access] + ): + # Schedule a new migration task if this one didn't finish + instance.queue_task(StatesContextIDMigrationTask()) + + +@dataclass(slots=True) +class EventsContextIDMigrationTask(RecorderTask): + """An object to insert into the recorder queue to migrate events context ids.""" + + commit_before = False + + def run(self, instance: Recorder) -> None: + """Run context id migration task.""" + if ( + not instance._migrate_events_context_ids() # pylint: disable=[protected-access] + ): + # Schedule a new migration task if this one didn't finish + instance.queue_task(EventsContextIDMigrationTask()) + + +@dataclass(slots=True) +class EventTypeIDMigrationTask(RecorderTask): + """An object to insert into the recorder queue to migrate event type ids.""" + + commit_before = True + # We have to commit before to make sure there are + # no new pending event_types about to be added to + # the db since this happens live + + def run(self, instance: Recorder) -> None: + """Run event type id migration task.""" + if not instance._migrate_event_type_ids(): # pylint: disable=[protected-access] + # Schedule a new migration task if this one didn't finish + instance.queue_task(EventTypeIDMigrationTask()) + + +@dataclass(slots=True) +class EntityIDMigrationTask(RecorderTask): + """An object to insert into the recorder queue to migrate entity_ids to StatesMeta.""" + + commit_before = True + # We have to commit before to make sure there are + # no new pending states_meta about to be added to + # the db since this happens live + + def run(self, instance: Recorder) -> None: + """Run entity_id migration task.""" + if not instance._migrate_entity_ids(): # pylint: disable=[protected-access] + # Schedule a new migration task if this one didn't finish + instance.queue_task(EntityIDMigrationTask()) + else: + # The migration has finished, now we start the post migration + # to remove the old entity_id data from the states table + # at this point we can also start using the StatesMeta table + # so we set active to True + instance.states_meta_manager.active = True + instance.queue_task(EntityIDPostMigrationTask()) + + +@dataclass(slots=True) +class EntityIDPostMigrationTask(RecorderTask): + """An object to insert into the recorder queue to cleanup after entity_ids migration.""" + + def run(self, instance: Recorder) -> None: + """Run entity_id post migration task.""" + if ( + not instance._post_migrate_entity_ids() # pylint: disable=[protected-access] + ): + # Schedule a new migration task if this one didn't finish + instance.queue_task(EntityIDPostMigrationTask()) + + +@dataclass(slots=True) +class EventIdMigrationTask(RecorderTask): + """An object to insert into the recorder queue to cleanup legacy event_ids in the states table. + + This task should only be queued if the ix_states_event_id index exists + since it is used to scan the states table and it will be removed after this + task is run if its no longer needed. + """ + + 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 f8a4097230..1c50fd0a77 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -1,10 +1,12 @@ """SQLAlchemy util functions.""" from __future__ import annotations -from collections.abc import Callable, Generator, Sequence +from collections.abc import Callable, Generator, Iterable, Sequence from contextlib import contextmanager from datetime import date, datetime, timedelta import functools +from functools import partial +from itertools import islice import logging import os import time @@ -16,8 +18,9 @@ from awesomeversion import ( AwesomeVersionStrategy, ) import ciso8601 -from sqlalchemy import text +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 @@ -108,8 +111,14 @@ def session_scope( hass: HomeAssistant | None = None, session: Session | None = None, exception_filter: Callable[[Exception], bool] | None = None, + read_only: bool = False, ) -> Generator[Session, None, None]: - """Provide a transactional scope around a series of operations.""" + """Provide a transactional scope around a series of operations. + + read_only is used to indicate that the session is only used for reading + data and that no commit is required. It does not prevent the session + from writing and is not a security measure. + """ if session is None and hass is not None: session = get_instance(hass).get_session() @@ -119,11 +128,11 @@ def session_scope( need_rollback = False try: yield session - if session.get_transaction(): + if session.get_transaction() and not read_only: need_rollback = True session.commit() except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Error executing query: %s", err) + _LOGGER.error("Error executing query: %s", err, exc_info=True) if need_rollback: session.rollback() if not exception_filter or not exception_filter(err): @@ -190,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. @@ -202,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) @@ -336,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) @@ -449,19 +462,18 @@ 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.""" version: AwesomeVersion | None = None - slow_range_in_select = True + slow_range_in_select = False if dialect_name == SupportedDialect.SQLITE: - slow_range_in_select = False 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. @@ -533,11 +545,6 @@ def setup_connection_for_dialect( # Ensure all times are using UTC to avoid issues with daylight savings execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'") elif dialect_name == SupportedDialect.POSTGRESQL: - # Historically we have marked PostgreSQL as having slow range in select - # but this may not be true for all versions. We should investigate - # this further when we have more data and remove this if possible - # in the future so we can use the simpler purge SQL query for - # _select_unused_attributes_ids and _select_unused_events_ids if first_connection: # server_version_num was added in 2006 result = query_on_connection(dbapi_connection, "SHOW server_version") @@ -572,6 +579,17 @@ def end_incomplete_runs(session: Session, start_time: datetime) -> None: session.add(run) +def _is_retryable_error(instance: Recorder, err: OperationalError) -> bool: + """Return True if the error is retryable.""" + assert instance.engine is not None + return bool( + instance.engine.dialect.name == SupportedDialect.MYSQL + and isinstance(err.orig, BaseException) + and err.orig.args + and err.orig.args[0] in RETRYABLE_MYSQL_ERRORS + ) + + _FuncType = Callable[Concatenate[_RecorderT, _P], bool] @@ -589,12 +607,8 @@ def retryable_database_job( try: return job(instance, *args, **kwargs) except OperationalError as err: - assert instance.engine is not None - if ( - instance.engine.dialect.name == SupportedDialect.MYSQL - and err.orig - and err.orig.args[0] in RETRYABLE_MYSQL_ERRORS - ): + if _is_retryable_error(instance, err): + assert isinstance(err.orig, BaseException) _LOGGER.info( "%s; %s not completed, retrying", err.orig.args[1], description ) @@ -612,6 +626,46 @@ def retryable_database_job( return decorator +_WrappedFuncType = Callable[Concatenate[_RecorderT, _P], None] + + +def database_job_retry_wrapper( + description: str, attempts: int = 5 +) -> Callable[[_WrappedFuncType[_RecorderT, _P]], _WrappedFuncType[_RecorderT, _P]]: + """Try to execute a database job multiple times. + + This wrapper handles InnoDB deadlocks and lock timeouts. + + This is different from retryable_database_job in that it will retry the job + attempts number of times instead of returning False if the job fails. + """ + + def decorator( + job: _WrappedFuncType[_RecorderT, _P] + ) -> _WrappedFuncType[_RecorderT, _P]: + @functools.wraps(job) + def wrapper(instance: _RecorderT, *args: _P.args, **kwargs: _P.kwargs) -> None: + for attempt in range(attempts): + try: + job(instance, *args, **kwargs) + return + except OperationalError as err: + if attempt == attempts - 1 or not _is_retryable_error( + instance, err + ): + raise + assert isinstance(err.orig, BaseException) + _LOGGER.info( + "%s; %s failed, retrying", err.orig.args[1], description + ) + time.sleep(instance.db_retry_wait) + # Failed with retryable error + + return wrapper + + return decorator + + def periodic_db_cleanups(instance: Recorder) -> None: """Run any database cleanups that need to happen periodically. @@ -623,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 @@ -767,3 +822,38 @@ def resolve_period( end_time += offset return (start_time, end_time) + + +def take(take_num: int, iterable: Iterable) -> list[Any]: + """Return first n items of the iterable as a list. + + From itertools recipes + """ + return list(islice(iterable, take_num)) + + +def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: + """Break *iterable* into lists of length *n*. + + From more-itertools + """ + return iter(partial(take, chunked_num, iter(iterable)), []) + + +def get_index_by_name(session: Session, table_name: str, index_name: str) -> str | None: + """Get an index by name.""" + connection = session.connection() + inspector = inspect(connection) + indexes = inspector.get_indexes(table_name) + return next( + ( + possible_index["name"] + for possible_index in indexes + if possible_index["name"] + and ( + possible_index["name"] == index_name + or possible_index["name"].endswith(f"_{index_name}") + ) + ), + None, + ) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 29c0808e6a..733dafeba2 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -30,13 +30,13 @@ 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, async_add_external_statistics, async_change_statistics_unit, async_import_statistics, + async_list_statistic_ids, list_statistic_ids, statistic_during_period, statistics_during_period, @@ -151,10 +151,10 @@ def _ws_get_statistics_during_period( msg_id: int, start_time: dt, end_time: dt | None, - statistic_ids: list[str] | None, + statistic_ids: set[str] | None, period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str], - types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]], ) -> str: """Fetch statistics and convert them to json in the executor.""" result = statistics_during_period( @@ -200,7 +200,7 @@ async def ws_handle_get_statistics_during_period( end_time = None if (types := msg.get("types")) is None: - types = {"last_reset", "max", "mean", "min", "state", "sum"} + types = {"change", "last_reset", "max", "mean", "min", "state", "sum"} connection.send_message( await get_instance(hass).async_add_executor_job( _ws_get_statistics_during_period, @@ -208,7 +208,7 @@ async def ws_handle_get_statistics_during_period( msg["id"], start_time, end_time, - msg["statistic_ids"], + set(msg["statistic_ids"]), msg.get("period"), msg.get("units"), types, @@ -225,7 +225,7 @@ async def ws_handle_get_statistics_during_period( vol.Required("period"): vol.Any("5minute", "hour", "day", "week", "month"), vol.Optional("units"): UNIT_SCHEMA, vol.Optional("types"): vol.All( - [vol.Any("last_reset", "max", "mean", "min", "state", "sum")], + [vol.Any("change", "last_reset", "max", "mean", "min", "state", "sum")], vol.Coerce(set), ), } @@ -329,11 +329,10 @@ async def ws_get_statistics_metadata( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Get metadata for a list of statistic_ids.""" - instance = get_instance(hass) - statistic_ids = await instance.async_add_executor_job( - list_statistic_ids, hass, msg.get("statistic_ids") - ) - connection.send_result(msg["id"], statistic_ids) + statistic_ids = msg.get("statistic_ids") + statistic_ids_set_or_none = set(statistic_ids) if statistic_ids else None + metadata = await async_list_statistic_ids(hass, statistic_ids_set_or_none) + connection.send_result(msg["id"], metadata) @websocket_api.require_admin @@ -413,7 +412,7 @@ async def ws_adjust_sum_statistics( instance = get_instance(hass) metadatas = await instance.async_add_executor_job( - list_statistic_ids, hass, (msg["statistic_id"],) + list_statistic_ids, hass, {msg["statistic_id"]} ) if not metadatas: connection.send_error(msg["id"], "unknown_statistic_id", "Unknown statistic ID") @@ -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/remote/strings.json b/homeassistant/components/remote/strings.json index 4a2b20c65d..f0d2787b65 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -16,10 +16,13 @@ "turned_off": "{entity_name} turned off" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "name": "[%key:component::remote::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } 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 b34e14d365..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,13 +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", + 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 69835552ba..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") @@ -151,6 +151,11 @@ class RenaultVehicleProxy: """Start vehicle charge.""" return await self._vehicle.set_charge_start() + @with_error_wrapping + async def set_charge_stop(self) -> models.KamereonVehicleChargingStartActionData: + """Stop vehicle charge.""" + return await self._vehicle.set_charge_stop() + @with_error_wrapping async def set_ac_stop(self) -> models.KamereonVehicleHvacStartActionData: """Stop vehicle ac.""" 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/__init__.py b/homeassistant/components/reolink/__init__.py index 0ff0861f65..76c0963e2c 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -6,6 +6,7 @@ import asyncio from dataclasses import dataclass from datetime import timedelta import logging +from typing import Literal from aiohttp import ClientConnectorError import async_timeout @@ -23,8 +24,19 @@ from .host import ReolinkHost _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.NUMBER] -DEVICE_UPDATE_INTERVAL = 60 +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CAMERA, + Platform.LIGHT, + Platform.NUMBER, + Platform.SELECT, + Platform.SIREN, + Platform.SWITCH, + Platform.UPDATE, +] +DEVICE_UPDATE_INTERVAL = timedelta(seconds=60) +FIRMWARE_UPDATE_INTERVAL = timedelta(hours=12) @dataclass @@ -32,7 +44,8 @@ class ReolinkData: """Data for the Reolink integration.""" host: ReolinkHost - device_coordinator: DataUpdateCoordinator + device_coordinator: DataUpdateCoordinator[None] + firmware_coordinator: DataUpdateCoordinator[str | Literal[False]] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -54,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b raise ConfigEntryNotReady( f"Error while trying to setup {host.api.host}:{host.api.port}: {str(err)}" ) from err - except Exception: # pylint: disable=broad-except + except Exception: await host.stop() raise @@ -62,7 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) ) - async def async_device_config_update(): + async def async_device_config_update() -> None: """Update the host state cache and renew the ONVIF-subscription.""" async with async_timeout.timeout(host.api.timeout): try: @@ -75,23 +88,48 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async with async_timeout.timeout(host.api.timeout): await host.renew() - coordinator_device_config_update = DataUpdateCoordinator( + async def async_check_firmware_update() -> str | Literal[False]: + """Check for firmware updates.""" + if not host.api.supported(None, "update"): + return False + + async with async_timeout.timeout(host.api.timeout): + try: + return await host.api.check_new_firmware() + except ReolinkError as err: + raise UpdateFailed( + f"Error checking Reolink firmware update {host.api.nvr_name}" + ) from err + + device_coordinator = DataUpdateCoordinator( hass, _LOGGER, name=f"reolink.{host.api.nvr_name}", update_method=async_device_config_update, - update_interval=timedelta(seconds=DEVICE_UPDATE_INTERVAL), + update_interval=DEVICE_UPDATE_INTERVAL, + ) + firmware_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"reolink.{host.api.nvr_name}.firmware", + update_method=async_check_firmware_update, + update_interval=FIRMWARE_UPDATE_INTERVAL, ) # Fetch initial data so we have data when entities subscribe try: - await coordinator_device_config_update.async_config_entry_first_refresh() + # If camera WAN blocked, firmware check fails, do not prevent setup + await asyncio.gather( + device_coordinator.async_config_entry_first_refresh(), + firmware_coordinator.async_refresh(), + ) except ConfigEntryNotReady: await host.stop() raise hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = ReolinkData( host=host, - device_coordinator=coordinator_device_config_update, + device_coordinator=device_coordinator, + firmware_coordinator=firmware_coordinator, ) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 5e7718f418..850aa11017 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -1,4 +1,4 @@ -"""This component provides support for Reolink binary sensors.""" +"""Component providing support for Reolink binary sensors.""" from __future__ import annotations from collections.abc import Callable @@ -24,7 +24,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity @dataclass @@ -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), ), ) @@ -113,10 +113,9 @@ async def async_setup_entry( async_add_entities(entities) -class ReolinkBinarySensorEntity(ReolinkCoordinatorEntity, BinarySensorEntity): +class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEntity): """Base binary-sensor class for Reolink IP camera motion sensors.""" - _attr_has_entity_name = True entity_description: ReolinkBinarySensorEntityDescription def __init__( diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py new file mode 100644 index 0000000000..3aa5faa527 --- /dev/null +++ b/homeassistant/components/reolink/button.py @@ -0,0 +1,136 @@ +"""Component providing support for Reolink button entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from reolink_aio.api import GuardEnum, Host, PtzEnum + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkChannelCoordinatorEntity + + +@dataclass +class ReolinkButtonEntityDescriptionMixin: + """Mixin values for Reolink button entities.""" + + method: Callable[[Host, int], Any] + + +@dataclass +class ReolinkButtonEntityDescription( + ButtonEntityDescription, ReolinkButtonEntityDescriptionMixin +): + """A class that describes button entities.""" + + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +BUTTON_ENTITIES = ( + ReolinkButtonEntityDescription( + key="ptz_stop", + name="PTZ stop", + icon="mdi:pan", + supported=lambda api, ch: api.supported(ch, "pan_tilt"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.stop.value), + ), + ReolinkButtonEntityDescription( + key="ptz_left", + name="PTZ left", + icon="mdi:pan", + supported=lambda api, ch: api.supported(ch, "pan"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.left.value), + ), + ReolinkButtonEntityDescription( + key="ptz_right", + name="PTZ right", + icon="mdi:pan", + supported=lambda api, ch: api.supported(ch, "pan"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.right.value), + ), + ReolinkButtonEntityDescription( + key="ptz_up", + name="PTZ up", + icon="mdi:pan", + supported=lambda api, ch: api.supported(ch, "tilt"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.up.value), + ), + ReolinkButtonEntityDescription( + key="ptz_down", + name="PTZ down", + icon="mdi:pan", + supported=lambda api, ch: api.supported(ch, "tilt"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.down.value), + ), + ReolinkButtonEntityDescription( + key="ptz_calibrate", + name="PTZ calibrate", + icon="mdi:pan", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "ptz_callibrate"), + method=lambda api, ch: api.ptz_callibrate(ch), + ), + ReolinkButtonEntityDescription( + key="guard_go_to", + name="Guard go to", + icon="mdi:crosshairs-gps", + supported=lambda api, ch: api.supported(ch, "ptz_guard"), + method=lambda api, ch: api.set_ptz_guard(ch, command=GuardEnum.goto.value), + ), + ReolinkButtonEntityDescription( + key="guard_set", + name="Guard set current position", + icon="mdi:crosshairs-gps", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "ptz_guard"), + method=lambda api, ch: api.set_ptz_guard(ch, command=GuardEnum.set.value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink button entities.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ReolinkButtonEntity(reolink_data, channel, entity_description) + for entity_description in BUTTON_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ) + + +class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): + """Base button entity class for Reolink IP cameras.""" + + entity_description: ReolinkButtonEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkButtonEntityDescription, + ) -> None: + """Initialize Reolink button entity.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + async def async_press(self) -> None: + """Execute the button action.""" + await self.entity_description.method(self._host.api, self._channel) diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 5ccada7269..a34f8c85d3 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -1,8 +1,10 @@ -"""This component provides support for Reolink IP cameras.""" +"""Component providing support for Reolink IP cameras.""" from __future__ import annotations import logging +from reolink_aio.api import DUAL_LENS_MODELS + from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -10,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity _LOGGER = logging.getLogger(__name__) @@ -25,7 +27,7 @@ async def async_setup_entry( host = reolink_data.host cameras = [] - for channel in host.api.channels: + for channel in host.api.stream_channels: streams = ["sub", "main", "snapshots"] if host.api.protocol in ["rtmp", "flv"]: streams.append("ext") @@ -39,11 +41,10 @@ async def async_setup_entry( async_add_entities(cameras) -class ReolinkCamera(ReolinkCoordinatorEntity, Camera): +class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): """An implementation of a Reolink IP camera.""" _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - _attr_has_entity_name = True def __init__( self, @@ -52,12 +53,15 @@ class ReolinkCamera(ReolinkCoordinatorEntity, Camera): stream: str, ) -> None: """Initialize Reolink camera stream.""" - ReolinkCoordinatorEntity.__init__(self, reolink_data, channel) + ReolinkChannelCoordinatorEntity.__init__(self, reolink_data, channel) Camera.__init__(self) self._stream = stream - self._attr_name = self._stream + if self._host.api.model in DUAL_LENS_MODELS: + self._attr_name = f"{self._stream} lens {self._channel}" + else: + self._attr_name = self._stream self._attr_unique_id = f"{self._host.unique_id}_{self._channel}_{self._stream}" self._attr_entity_registry_enabled_default = stream == "sub" diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index e4bc98cc0f..df5bf968ae 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -95,10 +95,9 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(mac_address) self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) - short_mac = mac_address[-8:].upper() self.context["title_placeholders"] = { - "short_mac": short_mac, "ip_address": discovery_info.ip, + "hostname": discovery_info.hostname, } self._host = discovery_info.ip @@ -109,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: @@ -176,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/entity.py b/homeassistant/components/reolink/entity.py index bcf39814c9..48652eac21 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -1,49 +1,92 @@ """Reolink parent entity class.""" from __future__ import annotations +from typing import TypeVar + +from reolink_aio.api import DUAL_LENS_MODELS + from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from . import ReolinkData from .const import DOMAIN +_T = TypeVar("_T") -class ReolinkCoordinatorEntity(CoordinatorEntity): - """Parent class for Reolink hardware camera entities.""" - def __init__(self, reolink_data: ReolinkData, channel: int) -> None: - """Initialize ReolinkCoordinatorEntity for a hardware camera.""" - coordinator = reolink_data.device_coordinator +class ReolinkBaseCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[_T]]): + """Parent class fo Reolink entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + reolink_data: ReolinkData, + coordinator: DataUpdateCoordinator[_T], + ) -> None: + """Initialize ReolinkBaseCoordinatorEntity.""" super().__init__(coordinator) self._host = reolink_data.host - self._channel = channel http_s = "https" if self._host.api.use_https else "http" - conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" - if self._host.api.is_nvr: - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{self._host.unique_id}_ch{self._channel}")}, - via_device=(DOMAIN, self._host.unique_id), - name=self._host.api.camera_name(self._channel), - model=self._host.api.camera_model(self._channel), - manufacturer=self._host.api.manufacturer, - configuration_url=conf_url, - ) - else: - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._host.unique_id)}, - connections={(CONNECTION_NETWORK_MAC, self._host.api.mac_address)}, - name=self._host.api.nvr_name, - model=self._host.api.model, - manufacturer=self._host.api.manufacturer, - hw_version=self._host.api.hardware_version, - sw_version=self._host.api.sw_version, - configuration_url=conf_url, - ) + self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._host.unique_id)}, + connections={(CONNECTION_NETWORK_MAC, self._host.api.mac_address)}, + name=self._host.api.nvr_name, + model=self._host.api.model, + manufacturer=self._host.api.manufacturer, + hw_version=self._host.api.hardware_version, + sw_version=self._host.api.sw_version, + configuration_url=self._conf_url, + ) @property def available(self) -> bool: """Return True if entity is available.""" return self._host.api.session_active and super().available + + +class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]): + """Parent class for entities that control the Reolink NVR itself, without a channel. + + A camera connected directly to HomeAssistant without using a NVR is in the reolink API + basically a NVR with a single channel that has the camera connected to that channel. + """ + + def __init__(self, reolink_data: ReolinkData) -> None: + """Initialize ReolinkHostCoordinatorEntity.""" + super().__init__(reolink_data, reolink_data.device_coordinator) + + +class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): + """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + ) -> None: + """Initialize ReolinkChannelCoordinatorEntity for a hardware camera connected to a channel of the NVR.""" + super().__init__(reolink_data) + + self._channel = channel + + dev_ch = channel + if self._host.api.model in DUAL_LENS_MODELS: + dev_ch = 0 + + if self._host.api.is_nvr: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{self._host.unique_id}_ch{dev_ch}")}, + via_device=(DOMAIN, self._host.unique_id), + name=self._host.api.camera_name(dev_ch), + model=self._host.api.camera_model(dev_ch), + manufacturer=self._host.api.manufacturer, + configuration_url=self._conf_url, + ) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 73c0e70812..5ff79029bd 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -1,4 +1,4 @@ -"""This component encapsulates the NVR/camera API and subscription.""" +"""Module which encapsulates the NVR/camera API and subscription.""" from __future__ import annotations import asyncio @@ -8,22 +8,26 @@ 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 from homeassistant.components import webhook from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import NoURLAvailableError, get_url 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 +POLL_INTERVAL_NO_PUSH = 5 _LOGGER = logging.getLogger(__name__) @@ -54,7 +58,11 @@ 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._cancel_poll: CALLBACK_TYPE | None = None + self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True) self._lost_subscription: bool = False @property @@ -80,9 +88,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 +109,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: @@ -109,28 +118,85 @@ class ReolinkHost: enable_rtsp=enable_rtsp, ) except ReolinkError: + ports = "" + if enable_rtsp: + ports += "RTSP " + if enable_onvif: - _LOGGER.error( - "Failed to enable ONVIF on %s. " - "Set it to ON to receive notifications", - self._api.nvr_name, - ) + ports += "ONVIF " if enable_rtmp: - _LOGGER.error( - "Failed to enable RTMP on %s. Set it to ON", - self._api.nvr_name, - ) - elif enable_rtsp: - _LOGGER.error( - "Failed to enable RTSP on %s. Set it to ON", - self._api.nvr_name, - ) + ports += "RTMP " + + ir.async_create_issue( + self._hass, + DOMAIN, + "enable_port", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="enable_port", + translation_placeholders={ + "name": self._api.nvr_name, + "ports": ports, + "info_link": "https://support.reolink.com/hc/en-us/articles/900004435763-How-to-Set-up-Reolink-Ports-Settings-via-Reolink-Client-New-Client-", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, "enable_port") self._unique_id = format_mac(self._api.mac_address) 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/", + }, + ) + await self._async_poll_all_motion() + else: + ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") + + if self._api.sw_version_update_required: + ir.async_create_issue( + self._hass, + DOMAIN, + "firmware_update", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="firmware_update", + translation_placeholders={ + "required_firmware": self._api.sw_version_required.version_string, + "current_firmware": self._api.sw_version, + "model": self._api.model, + "hw_version": self._api.hardware_version, + "name": self._api.nvr_name, + "download_link": "https://reolink.com/download-center/", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, "firmware_update") + async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" await self._api.get_states() @@ -167,6 +233,9 @@ class ReolinkHost: async def stop(self, event=None): """Disconnect the API.""" + if self._cancel_poll is not None: + self._cancel_poll() + self._cancel_poll = None self.unregister_webhook() await self.disconnect() @@ -260,10 +329,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( @@ -272,9 +341,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, @@ -283,7 +352,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/", }, ) @@ -298,28 +367,116 @@ class ReolinkHost: webhook.async_unregister(self._hass, self.webhook_id) self.webhook_id = None - async def handle_webhook( - self, hass: HomeAssistant, webhook_id: str, request: Request - ): - """Handle incoming webhook from Reolink for inbound messages and calls.""" - - _LOGGER.debug("Webhook '%s' called", webhook_id) - - if not request.body_exists: - _LOGGER.debug("Webhook '%s' triggered without payload", webhook_id) + async def _async_poll_all_motion(self, *_) -> None: + """Poll motion and AI states until the first ONVIF push is received.""" + if self._webhook_reachable.is_set(): + # ONVIF push is working, stop polling + self._cancel_poll = None return - data = await request.text() - if not data: + try: + await self._api.get_motion_state_all_ch() + except ( + aiohttp.ClientConnectorError, + ReolinkError, + ) as err: + _LOGGER.error( + "Reolink error while polling motion state for host %s:%s: %s", + self._api.host, + self._api.port, + str(err), + ) + except asyncio.TimeoutError: + _LOGGER.error( + "Reolink timeout error while polling motion state for host %s:%s", + self._api.host, + self._api.port, + ) + finally: + # schedule next poll + if not self._hass.is_stopping: + self._cancel_poll = async_call_later( + self._hass, POLL_INTERVAL_NO_PUSH, self._poll_job + ) + + # After receiving the new motion states in the upstream lib, + # update the binary sensors with async_write_ha_state + # The same dispatch as for the webhook can be used + async_dispatcher_send(self._hass, f"{self.webhook_id}_all", {}) + + async def handle_webhook( + self, hass: HomeAssistant, webhook_id: str, request: Request + ) -> None: + """Read the incoming webhook from Reolink for inbound messages and schedule processing.""" + _LOGGER.debug("Webhook '%s' called", webhook_id) + 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 new file mode 100644 index 0000000000..0f80215d50 --- /dev/null +++ b/homeassistant/components/reolink/light.py @@ -0,0 +1,158 @@ +"""Component providing support for Reolink light entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from reolink_aio.api import Host + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ColorMode, + LightEntity, + LightEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkChannelCoordinatorEntity + + +@dataclass +class ReolinkLightEntityDescriptionMixin: + """Mixin values for Reolink light entities.""" + + is_on_fn: Callable[[Host, int], bool] + turn_on_off_fn: Callable[[Host, int, bool], Any] + + +@dataclass +class ReolinkLightEntityDescription( + LightEntityDescription, ReolinkLightEntityDescriptionMixin +): + """A class that describes light entities.""" + + supported_fn: Callable[[Host, int], bool] = lambda api, ch: True + get_brightness_fn: Callable[[Host, int], int] | None = None + set_brightness_fn: Callable[[Host, int, float], Any] | None = None + + +LIGHT_ENTITIES = ( + ReolinkLightEntityDescription( + key="floodlight", + name="Floodlight", + icon="mdi:spotlight-beam", + supported_fn=lambda api, ch: api.supported(ch, "floodLight"), + is_on_fn=lambda api, ch: api.whiteled_state(ch), + turn_on_off_fn=lambda api, ch, value: api.set_whiteled(ch, state=value), + get_brightness_fn=lambda api, ch: api.whiteled_brightness(ch), + set_brightness_fn=lambda api, ch, value: api.set_whiteled(ch, brightness=value), + ), + ReolinkLightEntityDescription( + 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), + ), + ReolinkLightEntityDescription( + key="status_led", + name="Status LED", + icon="mdi:lightning-bolt-circle", + entity_category=EntityCategory.CONFIG, + 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), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink light entities.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ReolinkLightEntity(reolink_data, channel, entity_description) + for entity_description in LIGHT_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported_fn(reolink_data.host.api, channel) + ) + + +class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): + """Base light entity class for Reolink IP cameras.""" + + entity_description: ReolinkLightEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkLightEntityDescription, + ) -> None: + """Initialize Reolink light entity.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + if entity_description.set_brightness_fn is None: + self._attr_supported_color_modes = {ColorMode.ONOFF} + self._attr_color_mode = ColorMode.ONOFF + else: + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + self._attr_color_mode = ColorMode.BRIGHTNESS + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self.entity_description.is_on_fn(self._host.api, self._channel) + + @property + def brightness(self) -> int | None: + """Return the brightness of this light between 0.255.""" + if self.entity_description.get_brightness_fn is None: + return None + + return round( + 255 + * ( + self.entity_description.get_brightness_fn(self._host.api, self._channel) + / 100.0 + ) + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn light off.""" + await self.entity_description.turn_on_off_fn( + self._host.api, self._channel, False + ) + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn light on.""" + if ( + brightness := kwargs.get(ATTR_BRIGHTNESS) + ) is not None and self.entity_description.set_brightness_fn is not None: + brightness_pct = int(brightness / 255.0 * 100) + await self.entity_description.set_brightness_fn( + self._host.api, self._channel, brightness_pct + ) + + await self.entity_description.turn_on_off_fn( + self._host.api, self._channel, True + ) + self.async_write_ha_state() diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 836b9a8524..6a4ae98a15 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -6,12 +6,17 @@ "dependencies": ["webhook"], "dhcp": [ { - "hostname": "reolink*", + "hostname": "reolink*" + }, + { "macaddress": "EC71DB*" + }, + { + "registered_devices": true } ], "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.4.2"] + "requirements": ["reolink-aio==0.5.15"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 3f8860876a..bb19974114 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -1,4 +1,4 @@ -"""This component provides support for Reolink number entities.""" +"""Component providing support for Reolink number entities.""" from __future__ import annotations from collections.abc import Callable @@ -13,22 +13,21 @@ from homeassistant.components.number import ( NumberMode, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity @dataclass class ReolinkNumberEntityDescriptionMixin: """Mixin values for Reolink number entities.""" - value: Callable[[Host, int | None], bool] - get_min_value: Callable[[Host, int | None], float] - get_max_value: Callable[[Host, int | None], float] - method: Callable[[Host, int | None, float], Any] + value: Callable[[Host, int], float] + method: Callable[[Host, int, float], Any] @dataclass @@ -38,7 +37,9 @@ class ReolinkNumberEntityDescription( """A class that describes number entities.""" mode: NumberMode = NumberMode.AUTO - supported: Callable[[Host, int | None], bool] = lambda api, ch: True + supported: Callable[[Host, int], bool] = lambda api, ch: True + get_min_value: Callable[[Host, int], float] | None = None + get_max_value: Callable[[Host, int], float] | None = None NUMBER_ENTITIES = ( @@ -50,7 +51,7 @@ NUMBER_ENTITIES = ( native_step=1, get_min_value=lambda api, ch: api.zoom_range(ch)["zoom"]["pos"]["min"], get_max_value=lambda api, ch: api.zoom_range(ch)["zoom"]["pos"]["max"], - supported=lambda api, ch: api.zoom_supported(ch), + supported=lambda api, ch: api.supported(ch, "zoom"), value=lambda api, ch: api.get_zoom(ch), method=lambda api, ch, value: api.set_zoom(ch, int(value)), ), @@ -62,9 +63,184 @@ NUMBER_ENTITIES = ( native_step=1, get_min_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["min"], get_max_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["max"], - supported=lambda api, ch: api.zoom_supported(ch), + supported=lambda api, ch: api.supported(ch, "focus"), value=lambda api, ch: api.get_focus(ch), - method=lambda api, ch, value: api.set_zoom(ch, int(value)), + method=lambda api, ch, value: api.set_focus(ch, int(value)), + ), + # "Floodlight turn on brightness" controls the brightness of the floodlight when + # it is turned on internally by the camera (see "select.floodlight_mode" entity) + # or when using the "light.floodlight" entity. + ReolinkNumberEntityDescription( + key="floodlight_brightness", + name="Floodlight turn on brightness", + icon="mdi:spotlight-beam", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=1, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "floodLight"), + value=lambda api, ch: api.whiteled_brightness(ch), + method=lambda api, ch, value: api.set_whiteled(ch, brightness=int(value)), + ), + ReolinkNumberEntityDescription( + key="volume", + name="Volume", + icon="mdi:volume-high", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "volume"), + value=lambda api, ch: api.volume(ch), + method=lambda api, ch, value: api.set_volume(ch, volume=int(value)), + ), + ReolinkNumberEntityDescription( + key="guard_return_time", + name="Guard return time", + icon="mdi:crosshairs-gps", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=10, + native_max_value=300, + supported=lambda api, ch: api.supported(ch, "ptz_guard"), + value=lambda api, ch: api.ptz_guard_time(ch), + method=lambda api, ch, value: api.set_ptz_guard(ch, time=int(value)), + ), + ReolinkNumberEntityDescription( + key="motion_sensitivity", + name="Motion sensitivity", + icon="mdi:motion-sensor", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=1, + native_max_value=50, + supported=lambda api, ch: api.supported(ch, "md_sensitivity"), + value=lambda api, ch: api.md_sensitivity(ch), + method=lambda api, ch, value: api.set_md_sensitivity(ch, int(value)), + ), + ReolinkNumberEntityDescription( + key="ai_face_sensititvity", + name="AI face sensitivity", + icon="mdi:face-recognition", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: ( + api.supported(ch, "ai_sensitivity") and api.ai_supported(ch, "face") + ), + value=lambda api, ch: api.ai_sensitivity(ch, "face"), + method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "face"), + ), + ReolinkNumberEntityDescription( + key="ai_person_sensititvity", + name="AI person sensitivity", + icon="mdi:account", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: ( + api.supported(ch, "ai_sensitivity") and api.ai_supported(ch, "people") + ), + value=lambda api, ch: api.ai_sensitivity(ch, "people"), + method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "people"), + ), + ReolinkNumberEntityDescription( + key="ai_vehicle_sensititvity", + name="AI vehicle sensitivity", + icon="mdi:car", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: ( + api.supported(ch, "ai_sensitivity") and api.ai_supported(ch, "vehicle") + ), + value=lambda api, ch: api.ai_sensitivity(ch, "vehicle"), + method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "vehicle"), + ), + ReolinkNumberEntityDescription( + key="ai_pet_sensititvity", + name="AI pet sensitivity", + icon="mdi:dog-side", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: ( + api.supported(ch, "ai_sensitivity") and api.ai_supported(ch, "dog_cat") + ), + value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"), + method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"), + ), + ReolinkNumberEntityDescription( + key="auto_quick_reply_time", + name="Auto quick reply time", + icon="mdi:message-reply-text-outline", + 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, "quick_reply"), + 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)), ), ) @@ -85,10 +261,9 @@ async def async_setup_entry( ) -class ReolinkNumberEntity(ReolinkCoordinatorEntity, NumberEntity): +class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): """Base number entity class for Reolink IP cameras.""" - _attr_has_entity_name = True entity_description: ReolinkNumberEntityDescription def __init__( @@ -101,15 +276,17 @@ class ReolinkNumberEntity(ReolinkCoordinatorEntity, NumberEntity): super().__init__(reolink_data, channel) self.entity_description = entity_description - self._attr_native_min_value = self.entity_description.get_min_value( - self._host.api, self._channel - ) - self._attr_native_max_value = self.entity_description.get_max_value( - self._host.api, self._channel - ) + if entity_description.get_min_value is not None: + self._attr_native_min_value = entity_description.get_min_value( + self._host.api, channel + ) + if entity_description.get_max_value is not None: + self._attr_native_max_value = entity_description.get_max_value( + self._host.api, channel + ) self._attr_mode = entity_description.mode self._attr_unique_id = ( - f"{self._host.unique_id}_{self._channel}_{entity_description.key}" + f"{self._host.unique_id}_{channel}_{entity_description.key}" ) @property @@ -120,3 +297,4 @@ class ReolinkNumberEntity(ReolinkCoordinatorEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" await self.entity_description.method(self._host.api, self._channel, value) + self.async_write_ha_state() diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py new file mode 100644 index 0000000000..6303bc5813 --- /dev/null +++ b/homeassistant/components/reolink/select.py @@ -0,0 +1,164 @@ +"""Component providing support for Reolink select entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from reolink_aio.api import ( + DayNightEnum, + Host, + SpotlightModeEnum, + StatusLedEnum, + TrackMethodEnum, +) + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkChannelCoordinatorEntity + + +@dataclass +class ReolinkSelectEntityDescriptionMixin: + """Mixin values for Reolink select entities.""" + + method: Callable[[Host, int, str], Any] + get_options: list[str] | Callable[[Host, int], list[str]] + + +@dataclass +class ReolinkSelectEntityDescription( + SelectEntityDescription, ReolinkSelectEntityDescriptionMixin +): + """A class that describes select entities.""" + + supported: Callable[[Host, int], bool] = lambda api, ch: True + value: Callable[[Host, int], str] | None = None + + +SELECT_ENTITIES = ( + ReolinkSelectEntityDescription( + key="floodlight_mode", + name="Floodlight mode", + icon="mdi:spotlight-beam", + entity_category=EntityCategory.CONFIG, + translation_key="floodlight_mode", + get_options=[mode.name for mode in SpotlightModeEnum], + supported=lambda api, ch: api.supported(ch, "floodLight"), + value=lambda api, ch: SpotlightModeEnum(api.whiteled_mode(ch)).name, + method=lambda api, ch, name: api.set_whiteled(ch, mode=name), + ), + ReolinkSelectEntityDescription( + key="day_night_mode", + name="Day night mode", + icon="mdi:theme-light-dark", + entity_category=EntityCategory.CONFIG, + translation_key="day_night_mode", + get_options=[mode.name for mode in DayNightEnum], + supported=lambda api, ch: api.supported(ch, "dayNight"), + value=lambda api, ch: DayNightEnum(api.daynight_state(ch)).name, + method=lambda api, ch, name: api.set_daynight(ch, DayNightEnum[name].value), + ), + ReolinkSelectEntityDescription( + key="ptz_preset", + name="PTZ preset", + icon="mdi:pan", + get_options=lambda api, ch: list(api.ptz_presets(ch)), + supported=lambda api, ch: api.supported(ch, "ptz_presets"), + method=lambda api, ch, name: api.set_ptz_command(ch, preset=name), + ), + ReolinkSelectEntityDescription( + key="auto_quick_reply_message", + name="Auto quick reply message", + icon="mdi:message-reply-text-outline", + translation_key="auto_quick_reply_message", + get_options=lambda api, ch: list(api.quick_reply_dict(ch).values()), + supported=lambda api, ch: api.supported(ch, "quick_reply"), + value=lambda api, ch: api.quick_reply_dict(ch)[api.quick_reply_file(ch)], + method=lambda api, ch, mess: api.set_quick_reply( + 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), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink select entities.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ReolinkSelectEntity(reolink_data, channel, entity_description) + for entity_description in SELECT_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ) + + +class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): + """Base select entity class for Reolink IP cameras.""" + + entity_description: ReolinkSelectEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkSelectEntityDescription, + ) -> None: + """Initialize Reolink select entity.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + if callable(entity_description.get_options): + self._attr_options = entity_description.get_options(self._host.api, channel) + else: + self._attr_options = entity_description.get_options + + @property + def current_option(self) -> str | None: + """Return the current option.""" + if self.entity_description.value is None: + return None + + return self.entity_description.value(self._host.api, self._channel) + + 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/siren.py b/homeassistant/components/reolink/siren.py new file mode 100644 index 0000000000..9dba3b840e --- /dev/null +++ b/homeassistant/components/reolink/siren.py @@ -0,0 +1,93 @@ +"""Component providing support for Reolink siren entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from reolink_aio.api import Host + +from homeassistant.components.siren import ( + ATTR_DURATION, + ATTR_VOLUME_LEVEL, + SirenEntity, + SirenEntityDescription, + SirenEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkChannelCoordinatorEntity + + +@dataclass +class ReolinkSirenEntityDescription(SirenEntityDescription): + """A class that describes siren entities.""" + + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +SIREN_ENTITIES = ( + ReolinkSirenEntityDescription( + key="siren", + name="Siren", + icon="mdi:alarm-light", + supported=lambda api, ch: api.supported(ch, "siren_play"), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink siren entities.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ReolinkSirenEntity(reolink_data, channel, entity_description) + for entity_description in SIREN_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ) + + +class ReolinkSirenEntity(ReolinkChannelCoordinatorEntity, SirenEntity): + """Base siren entity class for Reolink IP cameras.""" + + _attr_supported_features = ( + SirenEntityFeature.TURN_ON + | SirenEntityFeature.TURN_OFF + | SirenEntityFeature.DURATION + | SirenEntityFeature.VOLUME_SET + ) + entity_description: ReolinkSirenEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkSirenEntityDescription, + ) -> None: + """Initialize Reolink siren entity.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the siren.""" + if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: + await self._host.api.set_volume(self._channel, int(volume * 100)) + duration = kwargs.get(ATTR_DURATION) + await self._host.api.set_siren(self._channel, True, duration) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the siren.""" + await self._host.api.set_siren(self._channel, False, None) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index cc60948876..f208e3e403 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -1,9 +1,9 @@ { "config": { - "flow_title": "{short_mac} ({ip_address})", + "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,56 @@ "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. Using fast motion/AI state polling until the first ONVIF push is received." + }, + "enable_port": { + "title": "Reolink port not enabled", + "description": "Failed to automatically enable {ports}port(s) on {name}. Use the [Reolink client]({info_link}) to manually set it to ON" + }, + "firmware_update": { + "title": "Reolink firmware update required", + "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running a old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The latest firmware can be downloaded from the [Reolink download center]({download_link})." + } + }, + "entity": { + "select": { + "floodlight_mode": { + "state": { + "off": "Off", + "auto": "Auto", + "schedule": "Schedule" + } + }, + "day_night_mode": { + "state": { + "auto": "Auto", + "color": "Color", + "blackwhite": "Black&White" + } + }, + "auto_quick_reply_message": { + "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 new file mode 100644 index 0000000000..1a4deda17e --- /dev/null +++ b/homeassistant/components/reolink/switch.py @@ -0,0 +1,248 @@ +"""Component providing support for Reolink switch entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from reolink_aio.api import Host + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity + + +@dataclass +class ReolinkSwitchEntityDescriptionMixin: + """Mixin values for Reolink switch entities.""" + + value: Callable[[Host, int], bool] + method: Callable[[Host, int, bool], Any] + + +@dataclass +class ReolinkSwitchEntityDescription( + SwitchEntityDescription, ReolinkSwitchEntityDescriptionMixin +): + """A class that describes switch entities.""" + + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +@dataclass +class ReolinkNVRSwitchEntityDescriptionMixin: + """Mixin values for Reolink NVR switch entities.""" + + value: Callable[[Host], bool] + method: Callable[[Host, bool], Any] + + +@dataclass +class ReolinkNVRSwitchEntityDescription( + SwitchEntityDescription, ReolinkNVRSwitchEntityDescriptionMixin +): + """A class that describes NVR switch entities.""" + + supported: Callable[[Host], bool] = lambda api: True + + +SWITCH_ENTITIES = ( + ReolinkSwitchEntityDescription( + key="record_audio", + name="Record audio", + icon="mdi:microphone", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "audio"), + value=lambda api, ch: api.audio_record(ch), + method=lambda api, ch, value: api.set_audio(ch, value), + ), + ReolinkSwitchEntityDescription( + key="siren_on_event", + name="Siren on event", + icon="mdi:alarm-light", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "siren"), + value=lambda api, ch: api.audio_alarm_enabled(ch), + method=lambda api, ch, value: api.set_audio_alarm(ch, value), + ), + ReolinkSwitchEntityDescription( + key="auto_tracking", + name="Auto tracking", + icon="mdi:target-account", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "auto_track"), + value=lambda api, ch: api.auto_track_enabled(ch), + method=lambda api, ch, value: api.set_auto_tracking(ch, value), + ), + ReolinkSwitchEntityDescription( + key="auto_focus", + name="Auto focus", + icon="mdi:focus-field", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "auto_focus"), + value=lambda api, ch: api.autofocus_enabled(ch), + method=lambda api, ch, value: api.set_autofocus(ch, value), + ), + ReolinkSwitchEntityDescription( + key="gaurd_return", + name="Guard return", + icon="mdi:crosshairs-gps", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "ptz_guard"), + 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 = ( + ReolinkNVRSwitchEntityDescription( + key="email", + name="Email on event", + icon="mdi:email", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.supported(None, "email"), + value=lambda api: api.email_enabled(), + method=lambda api, value: api.set_email(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="ftp_upload", + name="FTP upload", + icon="mdi:swap-horizontal", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.supported(None, "ftp"), + value=lambda api: api.ftp_enabled(), + method=lambda api, value: api.set_ftp(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="push_notifications", + name="Push notifications", + icon="mdi:message-badge", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.supported(None, "push"), + value=lambda api: api.push_enabled(), + method=lambda api, value: api.set_push(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="record", + name="Record", + icon="mdi:record-rec", + supported=lambda api: api.supported(None, "recording"), + value=lambda api: api.recording_enabled(), + method=lambda api, value: api.set_recording(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="buzzer", + name="Buzzer on event", + icon="mdi:room-service", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.supported(None, "buzzer"), + value=lambda api: api.buzzer_enabled(), + method=lambda api, value: api.set_buzzer(None, value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink switch entities.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[ReolinkSwitchEntity | ReolinkNVRSwitchEntity] = [ + ReolinkSwitchEntity(reolink_data, channel, entity_description) + for entity_description in SWITCH_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ] + entities.extend( + [ + ReolinkNVRSwitchEntity(reolink_data, entity_description) + for entity_description in NVR_SWITCH_ENTITIES + if entity_description.supported(reolink_data.host.api) + ] + ) + async_add_entities(entities) + + +class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): + """Base switch entity class for Reolink IP cameras.""" + + entity_description: ReolinkSwitchEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkSwitchEntityDescription, + ) -> None: + """Initialize Reolink switch entity.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self.entity_description.value(self._host.api, self._channel) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.entity_description.method(self._host.api, self._channel, True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.entity_description.method(self._host.api, self._channel, False) + self.async_write_ha_state() + + +class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): + """Switch entity class for Reolink NVR features.""" + + entity_description: ReolinkNVRSwitchEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + entity_description: ReolinkNVRSwitchEntityDescription, + ) -> None: + """Initialize Reolink switch entity.""" + super().__init__(reolink_data) + self.entity_description = entity_description + + self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self.entity_description.value(self._host.api) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.entity_description.method(self._host.api, True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.entity_description.method(self._host.api, False) + self.async_write_ha_state() diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py new file mode 100644 index 0000000000..aeb44cb774 --- /dev/null +++ b/homeassistant/components/reolink/update.py @@ -0,0 +1,78 @@ +"""Update entities for Reolink devices.""" +from __future__ import annotations + +import logging +from typing import Any, Literal + +from reolink_aio.exceptions import ReolinkError + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkBaseCoordinatorEntity + +LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up update entities for Reolink component.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + if reolink_data.host.api.supported(None, "update"): + async_add_entities([ReolinkUpdateEntity(reolink_data)]) + + +class ReolinkUpdateEntity( + ReolinkBaseCoordinatorEntity[str | Literal[False]], UpdateEntity +): + """Update entity for a Netgear device.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = UpdateEntityFeature.INSTALL + _attr_release_url = "https://reolink.com/download-center/" + _attr_name = "Update" + + def __init__( + self, + reolink_data: ReolinkData, + ) -> None: + """Initialize a Netgear device.""" + super().__init__(reolink_data, reolink_data.firmware_coordinator) + + self._attr_unique_id = f"{self._host.unique_id}" + + @property + def installed_version(self) -> str | None: + """Version currently in use.""" + return self._host.api.sw_version + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + if not self.coordinator.data: + return self.installed_version + + return self.coordinator.data + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the latest firmware version.""" + try: + await self._host.api.update_firmware() + except ReolinkError as err: + raise HomeAssistantError( + f"Error trying to update Reolink firmware: {err}" + ) from err 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..0c1f4df609 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", + rest.url, + 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..95086f68d7 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,28 +24,37 @@ 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 self.headers: httpx.Headers | None = None + @property + def url(self) -> str: + """Get url.""" + return self._resource + def set_url(self, url: str) -> None: """Set url.""" self._resource = url @@ -51,8 +62,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 +100,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/notify.py b/homeassistant/components/rest/notify.py index f1d4152b4b..e155fe4704 100644 --- a/homeassistant/components/rest/notify.py +++ b/homeassistant/components/rest/notify.py @@ -5,8 +5,7 @@ from http import HTTPStatus import logging from typing import Any -import requests -from requests.auth import AuthBase, HTTPBasicAuth, HTTPDigestAuth +import httpx import voluptuous as vol from homeassistant.components.notify import ( @@ -32,6 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -72,7 +72,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( _LOGGER = logging.getLogger(__name__) -def get_service( +async def async_get_service( hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, @@ -91,12 +91,12 @@ def get_service( password: str | None = config.get(CONF_PASSWORD) verify_ssl: bool = config[CONF_VERIFY_SSL] - auth: AuthBase | None = None + auth: httpx.Auth | None = None if username and password: if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: - auth = HTTPDigestAuth(username, password) + auth = httpx.DigestAuth(username, password) else: - auth = HTTPBasicAuth(username, password) + auth = httpx.BasicAuth(username, password) return RestNotificationService( hass, @@ -129,7 +129,7 @@ class RestNotificationService(BaseNotificationService): target_param_name: str | None, data: dict[str, Any] | None, data_template: dict[str, Any] | None, - auth: AuthBase | None, + auth: httpx.Auth | None, verify_ssl: bool, ) -> None: """Initialize the service.""" @@ -146,7 +146,7 @@ class RestNotificationService(BaseNotificationService): self._auth = auth self._verify_ssl = verify_ssl - def send_message(self, message: str = "", **kwargs: Any) -> None: + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" data = {self._message_param_name: message} @@ -179,34 +179,32 @@ class RestNotificationService(BaseNotificationService): if self._data_template: data.update(_data_template_creator(self._data_template)) + websession = get_async_client(self._hass, self._verify_ssl) if self._method == "POST": - response = requests.post( + response = await websession.post( self._resource, headers=self._headers, params=self._params, data=data, timeout=10, - auth=self._auth, - verify=self._verify_ssl, + auth=self._auth or httpx.USE_CLIENT_DEFAULT, ) elif self._method == "POST_JSON": - response = requests.post( + response = await websession.post( self._resource, headers=self._headers, params=self._params, json=data, timeout=10, - auth=self._auth, - verify=self._verify_ssl, + auth=self._auth or httpx.USE_CLIENT_DEFAULT, ) else: # default GET - response = requests.get( + response = await websession.get( self._resource, headers=self._headers, params={**self._params, **data} if self._params else data, timeout=10, auth=self._auth, - verify=self._verify_ssl, ) if ( @@ -214,21 +212,29 @@ class RestNotificationService(BaseNotificationService): and response.status_code < 600 ): _LOGGER.exception( - "Server error. Response %d: %s:", response.status_code, response.reason + "Server error. Response %d: %s:", + response.status_code, + response.reason_phrase, ) elif ( response.status_code >= HTTPStatus.BAD_REQUEST and response.status_code < HTTPStatus.INTERNAL_SERVER_ERROR ): _LOGGER.exception( - "Client error. Response %d: %s:", response.status_code, response.reason + "Client error. Response %d: %s:", + response.status_code, + response.reason_phrase, ) elif ( response.status_code >= HTTPStatus.OK and response.status_code < HTTPStatus.MULTIPLE_CHOICES ): _LOGGER.debug( - "Success. Response %d: %s:", response.status_code, response.reason + "Success. Response %d: %s:", + response.status_code, + response.reason_phrase, ) else: - _LOGGER.debug("Response %d: %s:", response.status_code, response.reason) + _LOGGER.debug( + "Response %d: %s:", response.status_code, response.reason_phrase + ) 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..6fc0b69d1f 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", + rest.url, + 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..89b6529d48 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -6,8 +6,8 @@ from http import HTTPStatus import logging from typing import Any -import aiohttp import async_timeout +import httpx import voluptuous as vol from homeassistant.components.switch import ( @@ -28,9 +28,10 @@ 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 +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.template_entity import ( TEMPLATE_ENTITY_BASE_SCHEMA, TemplateEntity, @@ -88,8 +89,8 @@ async def async_setup_platform( switch = RestSwitch(hass, config, unique_id) req = await switch.get_device_state(hass) - if req.status >= HTTPStatus.BAD_REQUEST: - _LOGGER.error("Got non-ok response from resource: %s", req.status) + if req.status_code >= HTTPStatus.BAD_REQUEST: + _LOGGER.error("Got non-ok response from resource: %s", req.status_code) else: async_add_entities([switch]) except (TypeError, ValueError): @@ -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, httpx.RequestError) as exc: + raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc class RestSwitch(TemplateEntity, SwitchEntity): @@ -119,11 +120,11 @@ class RestSwitch(TemplateEntity, SwitchEntity): unique_id=unique_id, ) - auth: aiohttp.BasicAuth | None = None + auth: httpx.BasicAuth | None = None username: str | None = None if username := config.get(CONF_USERNAME): password: str = config[CONF_PASSWORD] - auth = aiohttp.BasicAuth(username, password=password) + auth = httpx.BasicAuth(username, password=password) self._resource: str = config[CONF_RESOURCE] self._state_resource: str = config.get(CONF_STATE_RESOURCE) or self._resource @@ -154,13 +155,13 @@ class RestSwitch(TemplateEntity, SwitchEntity): try: req = await self.set_device_state(body_on_t) - if req.status == HTTPStatus.OK: + if req.status_code == HTTPStatus.OK: self._attr_is_on = True else: _LOGGER.error( "Can't turn on %s. Is resource/endpoint offline?", self._resource ) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (asyncio.TimeoutError, httpx.RequestError): _LOGGER.error("Error while switching on %s", self._resource) async def async_turn_off(self, **kwargs: Any) -> None: @@ -169,24 +170,24 @@ class RestSwitch(TemplateEntity, SwitchEntity): try: req = await self.set_device_state(body_off_t) - if req.status == HTTPStatus.OK: + if req.status_code == HTTPStatus.OK: self._attr_is_on = False else: _LOGGER.error( "Can't turn off %s. Is resource/endpoint offline?", self._resource ) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (asyncio.TimeoutError, httpx.RequestError): _LOGGER.error("Error while switching off %s", self._resource) - async def set_device_state(self, body: Any) -> aiohttp.ClientResponse: + async def set_device_state(self, body: Any) -> httpx.Response: """Send a state update to the device.""" - websession = async_get_clientsession(self.hass, self._verify_ssl) + websession = get_async_client(self.hass, self._verify_ssl) rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) async with async_timeout.timeout(self._timeout): - req: aiohttp.ClientResponse = await getattr(websession, self._method)( + req: httpx.Response = await getattr(websession, self._method)( self._resource, auth=self._auth, data=bytes(body, "utf-8"), @@ -201,12 +202,12 @@ class RestSwitch(TemplateEntity, SwitchEntity): await self.get_device_state(self.hass) except asyncio.TimeoutError: _LOGGER.exception("Timed out while fetching data") - except aiohttp.ClientError as err: + except httpx.RequestError as err: _LOGGER.exception("Error while fetching data: %s", err) - async def get_device_state(self, hass: HomeAssistant) -> aiohttp.ClientResponse: + async def get_device_state(self, hass: HomeAssistant) -> httpx.Response: """Get the latest data from REST API and update the state.""" - websession = async_get_clientsession(hass, self._verify_ssl) + websession = get_async_client(hass, self._verify_ssl) rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) @@ -218,7 +219,7 @@ class RestSwitch(TemplateEntity, SwitchEntity): headers=rendered_headers, params=rendered_params, ) - text = await req.text() + text = req.text if self._is_on_template is not None: text = self._is_on_template.async_render_with_possible_json_value( diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index f5a0f0808d..b563275297 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -20,7 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, STATE_ON, ) -from homeassistant.core import CoreState, HomeAssistant, ServiceCall, callback +from homeassistant.core import CoreState, HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -28,6 +28,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -246,7 +247,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) @callback - def reconnect(exc=None): + def reconnect(_: Exception | None = None) -> None: """Schedule reconnect after connection has been unexpectedly lost.""" # Reset protocol binding before starting reconnect RflinkCommand.set_rflink_protocol(None) @@ -258,6 +259,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.warning("Disconnected from Rflink, reconnecting") hass.async_create_task(connect()) + _reconnect_job = HassJob(reconnect, "Rflink reconnect", cancel_on_shutdown=True) + async def connect(): """Set up connection and hook it into HA for reconnect/shutdown.""" _LOGGER.info("Initiating Rflink connection") @@ -284,7 +287,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: SerialException, OSError, asyncio.TimeoutError, - ) as exc: + ): reconnect_interval = config[DOMAIN][CONF_RECONNECT_INTERVAL] _LOGGER.exception( "Error connecting to Rflink, reconnecting in %s", reconnect_interval @@ -292,7 +295,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Connection to Rflink device is lost, make entities unavailable async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False) - hass.loop.call_later(reconnect_interval, reconnect, exc) + async_call_later(hass, reconnect_interval, _reconnect_job) return # There is a valid connection to a Rflink device now so diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index 8b9c9165b2..0d0cf218cd 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/rflink", "iot_class": "assumed_state", "loggers": ["rflink"], - "requirements": ["rflink==0.0.63"] + "requirements": ["rflink==0.0.65"] } diff --git a/homeassistant/components/rfxtrx/manifest.json b/homeassistant/components/rfxtrx/manifest.json index ebbfba1fb5..1e2a3d6da2 100644 --- a/homeassistant/components/rfxtrx/manifest.json +++ b/homeassistant/components/rfxtrx/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/rfxtrx", "iot_class": "local_push", "loggers": ["RFXtrx"], - "requirements": ["pyRFXtrx==0.30.0"] + "requirements": ["pyRFXtrx==0.30.1"] } diff --git a/homeassistant/components/rfxtrx/siren.py b/homeassistant/components/rfxtrx/siren.py index c9f10febb6..bfff08d5ea 100644 --- a/homeassistant/components/rfxtrx/siren.py +++ b/homeassistant/components/rfxtrx/siren.py @@ -119,6 +119,11 @@ class RfxtrxOffDelayMixin(Entity): self._timeout() self._timeout = None + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + self._cancel_timeout() + return await super().async_will_remove_from_hass() + class RfxtrxChime(RfxtrxCommandEntity, SirenEntity, RfxtrxOffDelayMixin): """Representation of a RFXtrx chime.""" 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/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 06872cc733..d2c01bbd4f 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -1,4 +1,4 @@ -"""This component provides HA sensor support for Ring Door Bell/Chimes.""" +"""Component providing HA sensor support for Ring Door Bell/Chimes.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index f5d70a86cb..e99fabfab2 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -1,4 +1,4 @@ -"""This component provides support to the Ring Door Bell camera.""" +"""Component providing support to the Ring Door Bell camera.""" from __future__ import annotations from datetime import timedelta diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index e6b29b94fb..143c333f60 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -1,4 +1,4 @@ -"""This component provides HA switch support for Ring Door Bell/Chimes.""" +"""Component providing HA switch support for Ring Door Bell/Chimes.""" from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 027eccb1c3..3d198ce757 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -1,4 +1,4 @@ -"""This component provides HA sensor support for Ring Door Bell/Chimes.""" +"""Component providing HA sensor support for Ring Door Bell/Chimes.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index b83d3e7b2a..626444a9dc 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -1,4 +1,4 @@ -"""This component provides HA Siren support for Ring Chimes.""" +"""Component providing HA Siren support for Ring Chimes.""" import logging from typing import Any diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 0fa6e3b111..9a3c80114e 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -1,4 +1,4 @@ -"""This component provides HA switch support for Ring Door Bell/Chimes.""" +"""Component providing HA switch support for Ring Door Bell/Chimes.""" from datetime import timedelta import logging from typing import Any 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..e0fac0abfc 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -1,18 +1,18 @@ """The Rituals Perfume Genie integration.""" -from datetime import timedelta -import logging +import asyncio import aiohttp from pyrituals import Account, Diffuser from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ACCOUNT_HASH, COORDINATORS, DEVICES, DOMAIN +from .const import ACCOUNT_HASH, DOMAIN +from .coordinator import RitualsDataUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -22,10 +22,6 @@ PLATFORMS = [ Platform.SWITCH, ] -_LOGGER = logging.getLogger(__name__) - -UPDATE_INTERVAL = timedelta(seconds=30) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Rituals Perfume Genie from a config entry.""" @@ -37,20 +33,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except aiohttp.ClientError as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - COORDINATORS: {}, - DEVICES: {}, + # Migrate old unique_ids to the new format + async_migrate_entities_unique_ids(hass, entry, account_devices) + + # Create a coordinator for each diffuser + coordinators = { + diffuser.hublot: RitualsDataUpdateCoordinator(hass, diffuser) + for diffuser in account_devices } - for device in account_devices: - hublot = device.hublot - - coordinator = RitualsDataUpdateCoordinator(hass, device) - await coordinator.async_config_entry_first_refresh() - - hass.data[DOMAIN][entry.entry_id][DEVICES][hublot] = device - hass.data[DOMAIN][entry.entry_id][COORDINATORS][hublot] = coordinator + # Refresh all coordinators + await asyncio.gather( + *[ + coordinator.async_config_entry_first_refresh() + for coordinator in coordinators.values() + ] + ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -65,19 +65,36 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): - """Class to manage fetching Rituals Perfume Genie device data from single endpoint.""" +@callback +def async_migrate_entities_unique_ids( + hass: HomeAssistant, config_entry: ConfigEntry, diffusers: list[Diffuser] +) -> None: + """Migrate unique_ids in the entity registry to the new format.""" + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) - def __init__(self, hass: HomeAssistant, device: Diffuser) -> None: - """Initialize global Rituals Perfume Genie data updater.""" - self._device = device - super().__init__( - hass, - _LOGGER, - name=f"{DOMAIN}-{device.hublot}", - update_interval=UPDATE_INTERVAL, - ) + conversion: dict[tuple[str, str], str] = { + (Platform.BINARY_SENSOR, " Battery Charging"): "charging", + (Platform.NUMBER, " Perfume Amount"): "perfume_amount", + (Platform.SELECT, " Room Size"): "room_size_square_meter", + (Platform.SENSOR, " Battery"): "battery_percentage", + (Platform.SENSOR, " Fill"): "fill", + (Platform.SENSOR, " Perfume"): "perfume", + (Platform.SENSOR, " Wifi"): "wifi_percentage", + (Platform.SWITCH, ""): "is_on", + } - async def _async_update_data(self) -> None: - """Fetch data from Rituals.""" - await self._device.update_data() + for diffuser in diffusers: + for registry_entry in registry_entries: + if new_unique_id := conversion.get( + ( + registry_entry.domain, + registry_entry.unique_id.removeprefix(diffuser.hublot), + ) + ): + entity_registry.async_update_entity( + registry_entry.entity_id, + new_unique_id=f"{diffuser.hublot}-{new_unique_id}", + ) diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 5e81ecb5ca..3c49dc14f5 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -1,22 +1,51 @@ """Support for Rituals Perfume Genie binary sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from pyrituals import Diffuser from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RitualsDataUpdateCoordinator -from .const import COORDINATORS, DEVICES, DOMAIN +from .const import DOMAIN +from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -CHARGING_SUFFIX = " Battery Charging" + +@dataclass +class RitualsentityDescriptionMixin: + """Mixin values for Rituals entities.""" + + is_on_fn: Callable[[Diffuser], bool] + has_fn: Callable[[Diffuser], bool] + + +@dataclass +class RitualsBinarySensorEntityDescription( + BinarySensorEntityDescription, RitualsentityDescriptionMixin +): + """Class describing Rituals binary sensor entities.""" + + +ENTITY_DESCRIPTIONS = ( + RitualsBinarySensorEntityDescription( + key="charging", + name="Battery Charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda diffuser: diffuser.charging, + has_fn=lambda diffuser: diffuser.has_battery, + ), +) async def async_setup_entry( @@ -25,29 +54,33 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the diffuser binary sensors.""" - diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES] - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] async_add_entities( - DiffuserBatteryChargingBinarySensor(diffuser, coordinators[hublot]) - for hublot, diffuser in diffusers.items() - if diffuser.has_battery + RitualsBinarySensorEntity(coordinator, description) + for coordinator in coordinators.values() + for description in ENTITY_DESCRIPTIONS + if description.has_fn(coordinator.diffuser) ) -class DiffuserBatteryChargingBinarySensor(DiffuserEntity, BinarySensorEntity): - """Representation of a diffuser battery charging binary sensor.""" +class RitualsBinarySensorEntity(DiffuserEntity, BinarySensorEntity): + """Defines a Rituals binary sensor entity.""" - _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING - _attr_entity_category = EntityCategory.DIAGNOSTIC + entity_description: RitualsBinarySensorEntityDescription def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator + self, + coordinator: RitualsDataUpdateCoordinator, + description: RitualsBinarySensorEntityDescription, ) -> None: - """Initialize the battery charging binary sensor.""" - super().__init__(diffuser, coordinator, CHARGING_SUFFIX) + """Initialize Rituals binary sensor entity.""" + super().__init__(coordinator, description) + self._attr_name = f"{coordinator.diffuser.name} {description.name}" @property def is_on(self) -> bool: - """Return the state of the battery charging binary sensor.""" - return self._diffuser.charging + """Return the state of the binary sensor.""" + return self.entity_description.is_on_fn(self.coordinator.diffuser) diff --git a/homeassistant/components/rituals_perfume_genie/const.py b/homeassistant/components/rituals_perfume_genie/const.py index 21c570ffb9..35d1c32d30 100644 --- a/homeassistant/components/rituals_perfume_genie/const.py +++ b/homeassistant/components/rituals_perfume_genie/const.py @@ -1,7 +1,9 @@ """Constants for the Rituals Perfume Genie integration.""" + +from datetime import timedelta + DOMAIN = "rituals_perfume_genie" ACCOUNT_HASH = "account_hash" -COORDINATORS = "coordinators" -DEVICES = "devices" +UPDATE_INTERVAL = timedelta(minutes=2) diff --git a/homeassistant/components/rituals_perfume_genie/coordinator.py b/homeassistant/components/rituals_perfume_genie/coordinator.py new file mode 100644 index 0000000000..b63b28e4de --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/coordinator.py @@ -0,0 +1,29 @@ +"""The Rituals Perfume Genie data update coordinator.""" +import logging + +from pyrituals import Diffuser + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching Rituals Perfume Genie device data from single endpoint.""" + + def __init__(self, hass: HomeAssistant, diffuser: Diffuser) -> None: + """Initialize global Rituals Perfume Genie data updater.""" + self.diffuser = diffuser + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}-{diffuser.hublot}", + update_interval=UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> None: + """Fetch data from Rituals.""" + await self.diffuser.update_data() diff --git a/homeassistant/components/rituals_perfume_genie/entity.py b/homeassistant/components/rituals_perfume_genie/entity.py index e3bf1ef4e6..e5b9f3ebd6 100644 --- a/homeassistant/components/rituals_perfume_genie/entity.py +++ b/homeassistant/components/rituals_perfume_genie/entity.py @@ -1,13 +1,11 @@ """Base class for Rituals Perfume Genie diffuser entity.""" from __future__ import annotations -from pyrituals import Diffuser - -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import RitualsDataUpdateCoordinator from .const import DOMAIN +from .coordinator import RitualsDataUpdateCoordinator MANUFACTURER = "Rituals Cosmetics" MODEL = "The Perfume Genie" @@ -19,28 +17,22 @@ class DiffuserEntity(CoordinatorEntity[RitualsDataUpdateCoordinator]): def __init__( self, - diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator, - entity_suffix: str, + description: EntityDescription, ) -> None: """Init from config, hookup diffuser and coordinator.""" super().__init__(coordinator) - self._diffuser = diffuser - - hublot = self._diffuser.hublot - hubname = self._diffuser.name - - self._attr_name = f"{hubname}{entity_suffix}" - self._attr_unique_id = f"{hublot}{entity_suffix}" + self.entity_description = description + self._attr_unique_id = f"{coordinator.diffuser.hublot}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, hublot)}, + identifiers={(DOMAIN, coordinator.diffuser.hublot)}, manufacturer=MANUFACTURER, - model=MODEL if diffuser.has_battery else MODEL2, - name=hubname, - sw_version=diffuser.version, + model=MODEL if coordinator.diffuser.has_battery else MODEL2, + name=coordinator.diffuser.name, + sw_version=coordinator.diffuser.version, ) @property def available(self) -> bool: """Return if the entity is available.""" - return super().available and self._diffuser.is_online + return super().available and self.coordinator.diffuser.is_online diff --git a/homeassistant/components/rituals_perfume_genie/manifest.json b/homeassistant/components/rituals_perfume_genie/manifest.json index 3483fd9d0a..996dd1faec 100644 --- a/homeassistant/components/rituals_perfume_genie/manifest.json +++ b/homeassistant/components/rituals_perfume_genie/manifest.json @@ -1,7 +1,7 @@ { "domain": "rituals_perfume_genie", "name": "Rituals Perfume Genie", - "codeowners": ["@milanmeu"], + "codeowners": ["@milanmeu", "@frenck"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie", "iot_class": "cloud_polling", diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 8fe0a663e3..8049e53fa0 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -1,21 +1,48 @@ """Support for Rituals Perfume Genie numbers.""" from __future__ import annotations +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + from pyrituals import Diffuser -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RitualsDataUpdateCoordinator -from .const import COORDINATORS, DEVICES, DOMAIN +from .const import DOMAIN +from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -MIN_PERFUME_AMOUNT = 1 -MAX_PERFUME_AMOUNT = 3 -PERFUME_AMOUNT_SUFFIX = " Perfume Amount" +@dataclass +class RitualsNumberEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Diffuser], int] + set_value_fn: Callable[[Diffuser, int], Awaitable[Any]] + + +@dataclass +class RitualsNumberEntityDescription( + NumberEntityDescription, RitualsNumberEntityDescriptionMixin +): + """Class describing Rituals number entities.""" + + +ENTITY_DESCRIPTIONS = ( + RitualsNumberEntityDescription( + key="perfume_amount", + name="Perfume Amount", + icon="mdi:gauge", + native_min_value=1, + native_max_value=3, + value_fn=lambda diffuser: diffuser.perfume_amount, + set_value_fn=lambda diffuser, value: diffuser.set_perfume_amount(value), + ), +) async def async_setup_entry( @@ -24,39 +51,39 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the diffuser numbers.""" - diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES] - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] - entities: list[DiffuserEntity] = [] - for hublot, diffuser in diffusers.items(): - coordinator = coordinators[hublot] - entities.append(DiffuserPerfumeAmount(diffuser, coordinator)) - - async_add_entities(entities) + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + RitualsNumberEntity(coordinator, description) + for coordinator in coordinators.values() + for description in ENTITY_DESCRIPTIONS + ) -class DiffuserPerfumeAmount(DiffuserEntity, NumberEntity): - """Representation of a diffuser perfume amount number.""" +class RitualsNumberEntity(DiffuserEntity, NumberEntity): + """Representation of a diffuser number entity.""" - _attr_icon = "mdi:gauge" - _attr_native_max_value = MAX_PERFUME_AMOUNT - _attr_native_min_value = MIN_PERFUME_AMOUNT + entity_description: RitualsNumberEntityDescription def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator + self, + coordinator: RitualsDataUpdateCoordinator, + description: RitualsNumberEntityDescription, ) -> None: """Initialize the diffuser perfume amount number.""" - super().__init__(diffuser, coordinator, PERFUME_AMOUNT_SUFFIX) + super().__init__(coordinator, description) + self._attr_name = f"{coordinator.diffuser.name} {description.name}" @property def native_value(self) -> int: - """Return the current perfume amount.""" - return self._diffuser.perfume_amount + """Return the number value.""" + return self.entity_description.value_fn(self.coordinator.diffuser) async def async_set_native_value(self, value: float) -> None: - """Set the perfume amount.""" + """Change to new number value.""" if not value.is_integer(): - raise ValueError( - f"Can't set the perfume amount to {value}. Perfume amount must be an" - " integer." - ) - await self._diffuser.set_perfume_amount(int(value)) + raise ValueError(f"Can't set value to {value}. Value must be an integer.") + await self.entity_description.set_value_fn( + self.coordinator.diffuser, int(value) + ) diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index 918ec7022c..10ad5dbf3b 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -1,19 +1,51 @@ """Support for Rituals Perfume Genie numbers.""" from __future__ import annotations +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + from pyrituals import Diffuser -from homeassistant.components.select import SelectEntity +from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import AREA_SQUARE_METERS, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RitualsDataUpdateCoordinator -from .const import COORDINATORS, DEVICES, DOMAIN +from .const import DOMAIN +from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -ROOM_SIZE_SUFFIX = " Room Size" + +@dataclass +class RitualsEntityDescriptionMixin: + """Mixin for required keys.""" + + current_fn: Callable[[Diffuser], str] + select_fn: Callable[[Diffuser, str], Awaitable[None]] + + +@dataclass +class RitualsSelectEntityDescription( + SelectEntityDescription, RitualsEntityDescriptionMixin +): + """Class describing Rituals select entities.""" + + +ENTITY_DESCRIPTIONS = ( + RitualsSelectEntityDescription( + key="room_size_square_meter", + name="Room Size", + icon="mdi:ruler-square", + unit_of_measurement=AREA_SQUARE_METERS, + entity_category=EntityCategory.CONFIG, + options=["15", "30", "60", "100"], + current_fn=lambda diffuser: str(diffuser.room_size_square_meter), + select_fn=lambda diffuser, value: ( + diffuser.set_room_size_square_meter(int(value)) + ), + ), +) async def async_setup_entry( @@ -22,34 +54,39 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the diffuser select entities.""" - diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES] - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( - DiffuserRoomSize(diffuser, coordinators[hublot]) - for hublot, diffuser in diffusers.items() + RitualsSelectEntity(coordinator, description) + for coordinator in coordinators.values() + for description in ENTITY_DESCRIPTIONS ) -class DiffuserRoomSize(DiffuserEntity, SelectEntity): - """Representation of a diffuser room size select entity.""" +class RitualsSelectEntity(DiffuserEntity, SelectEntity): + """Representation of a diffuser select entity.""" - _attr_icon = "mdi:ruler-square" - _attr_unit_of_measurement = AREA_SQUARE_METERS - _attr_options = ["15", "30", "60", "100"] - _attr_entity_category = EntityCategory.CONFIG + entity_description: RitualsSelectEntityDescription def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator + self, + coordinator: RitualsDataUpdateCoordinator, + description: RitualsSelectEntityDescription, ) -> None: """Initialize the diffuser room size select entity.""" - super().__init__(diffuser, coordinator, ROOM_SIZE_SUFFIX) - self._attr_entity_registry_enabled_default = diffuser.has_battery + super().__init__(coordinator, description) + self._attr_entity_registry_enabled_default = ( + self.coordinator.diffuser.has_battery + ) + self._attr_name = f"{coordinator.diffuser.name} {description.name}" @property def current_option(self) -> str: - """Return the diffuser room size.""" - return str(self._diffuser.room_size_square_meter) + """Return the selected entity option to represent the entity state.""" + return self.entity_description.current_fn(self.coordinator.diffuser) async def async_select_option(self, option: str) -> None: - """Change the diffuser room size.""" - await self._diffuser.set_room_size_square_meter(int(option)) + """Change the selected option.""" + await self.entity_description.select_fn(self.coordinator.diffuser, option) diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 04102e8abc..e48bf4de21 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -1,22 +1,71 @@ """Support for Rituals Perfume Genie sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from pyrituals import Diffuser -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RitualsDataUpdateCoordinator -from .const import COORDINATORS, DEVICES, DOMAIN +from .const import DOMAIN +from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -BATTERY_SUFFIX = " Battery" -PERFUME_SUFFIX = " Perfume" -FILL_SUFFIX = " Fill" -WIFI_SUFFIX = " Wifi" + +@dataclass +class RitualsEntityDescriptionMixin: + """Mixin values for Rituals entities.""" + + value_fn: Callable[[Diffuser], int | str] + + +@dataclass +class RitualsSensorEntityDescription( + SensorEntityDescription, RitualsEntityDescriptionMixin +): + """Class describing Rituals sensor entities.""" + + has_fn: Callable[[Diffuser], bool] = lambda _: True + + +ENTITY_DESCRIPTIONS = ( + RitualsSensorEntityDescription( + key="battery_percentage", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + value_fn=lambda diffuser: diffuser.battery_percentage, + has_fn=lambda diffuser: diffuser.has_battery, + ), + RitualsSensorEntityDescription( + key="fill", + name="Fill", + icon="mdi:beaker", + value_fn=lambda diffuser: diffuser.fill, + ), + RitualsSensorEntityDescription( + key="perfume", + name="Perfume", + icon="mdi:tag", + value_fn=lambda diffuser: diffuser.perfume, + ), + RitualsSensorEntityDescription( + key="wifi_percentage", + name="Wifi", + icon="mdi:wifi", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda diffuser: diffuser.wifi_percentage, + ), +) async def async_setup_entry( @@ -25,96 +74,34 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the diffuser sensors.""" - diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES] - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] - entities: list[DiffuserEntity] = [] - for hublot, diffuser in diffusers.items(): - coordinator = coordinators[hublot] - entities.append(DiffuserPerfumeSensor(diffuser, coordinator)) - entities.append(DiffuserFillSensor(diffuser, coordinator)) - entities.append(DiffuserWifiSensor(diffuser, coordinator)) - if diffuser.has_battery: - entities.append(DiffuserBatterySensor(diffuser, coordinator)) + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] - async_add_entities(entities) + async_add_entities( + RitualsSensorEntity(coordinator, description) + for coordinator in coordinators.values() + for description in ENTITY_DESCRIPTIONS + if description.has_fn(coordinator.diffuser) + ) -class DiffuserPerfumeSensor(DiffuserEntity, SensorEntity): - """Representation of a diffuser perfume sensor.""" +class RitualsSensorEntity(DiffuserEntity, SensorEntity): + """Representation of a diffuser sensor.""" - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: - """Initialize the perfume sensor.""" - super().__init__(diffuser, coordinator, PERFUME_SUFFIX) - - @property - def icon(self) -> str: - """Return the perfume sensor icon.""" - if self._diffuser.has_cartridge: - return "mdi:tag-text" - return "mdi:tag-remove" - - @property - def native_value(self) -> str: - """Return the state of the perfume sensor.""" - return self._diffuser.perfume - - -class DiffuserFillSensor(DiffuserEntity, SensorEntity): - """Representation of a diffuser fill sensor.""" - - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: - """Initialize the fill sensor.""" - super().__init__(diffuser, coordinator, FILL_SUFFIX) - - @property - def icon(self) -> str: - """Return the fill sensor icon.""" - if self._diffuser.has_cartridge: - return "mdi:beaker" - return "mdi:beaker-question" - - @property - def native_value(self) -> str: - """Return the state of the fill sensor.""" - return self._diffuser.fill - - -class DiffuserBatterySensor(DiffuserEntity, SensorEntity): - """Representation of a diffuser battery sensor.""" - - _attr_device_class = SensorDeviceClass.BATTERY - _attr_native_unit_of_measurement = PERCENTAGE + entity_description: RitualsSensorEntityDescription _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator + self, + coordinator: RitualsDataUpdateCoordinator, + description: RitualsSensorEntityDescription, ) -> None: - """Initialize the battery sensor.""" - super().__init__(diffuser, coordinator, BATTERY_SUFFIX) + """Initialize the diffuser sensor.""" + super().__init__(coordinator, description) + self._attr_name = f"{coordinator.diffuser.name} {description.name}" @property - def native_value(self) -> int: - """Return the state of the battery sensor.""" - return self._diffuser.battery_percentage - - -class DiffuserWifiSensor(DiffuserEntity, SensorEntity): - """Representation of a diffuser wifi sensor.""" - - _attr_native_unit_of_measurement = PERCENTAGE - _attr_entity_category = EntityCategory.DIAGNOSTIC - - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: - """Initialize the wifi sensor.""" - super().__init__(diffuser, coordinator, WIFI_SUFFIX) - - @property - def native_value(self) -> int: - """Return the state of the wifi sensor.""" - return self._diffuser.wifi_percentage + def native_value(self) -> str | int: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.diffuser) diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index a213db4e5d..44ff951f53 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -1,62 +1,95 @@ """Support for Rituals Perfume Genie switches.""" from __future__ import annotations +from collections.abc import Awaitable, Callable +from dataclasses import dataclass from typing import Any from pyrituals import Diffuser -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RitualsDataUpdateCoordinator -from .const import COORDINATORS, DEVICES, DOMAIN +from .const import DOMAIN +from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity +@dataclass +class RitualsEntityDescriptionMixin: + """Mixin values for Rituals entities.""" + + is_on_fn: Callable[[Diffuser], bool] + turn_on_fn: Callable[[Diffuser], Awaitable[None]] + turn_off_fn: Callable[[Diffuser], Awaitable[None]] + + +@dataclass +class RitualsSwitchEntityDescription( + SwitchEntityDescription, RitualsEntityDescriptionMixin +): + """Class describing Rituals switch entities.""" + + +ENTITY_DESCRIPTIONS = ( + RitualsSwitchEntityDescription( + key="is_on", + icon="mdi:fan", + is_on_fn=lambda diffuser: diffuser.is_on, + turn_on_fn=lambda diffuser: diffuser.turn_on(), + turn_off_fn=lambda diffuser: diffuser.turn_off(), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the diffuser switch.""" - diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES] - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] - entities = [] - for hublot, diffuser in diffusers.items(): - coordinator = coordinators[hublot] - entities.append(DiffuserSwitch(diffuser, coordinator)) + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] - async_add_entities(entities) + async_add_entities( + RitualsSwitchEntity(coordinator, description) + for coordinator in coordinators.values() + for description in ENTITY_DESCRIPTIONS + ) -class DiffuserSwitch(DiffuserEntity, SwitchEntity): +class RitualsSwitchEntity(DiffuserEntity, SwitchEntity): """Representation of a diffuser switch.""" - _attr_icon = "mdi:fan" + entity_description: RitualsSwitchEntityDescription def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator + self, + coordinator: RitualsDataUpdateCoordinator, + description: RitualsSwitchEntityDescription, ) -> None: """Initialize the diffuser switch.""" - super().__init__(diffuser, coordinator, "") - self._attr_is_on = self._diffuser.is_on + super().__init__(coordinator, description) + self._attr_name = coordinator.diffuser.name + self._attr_is_on = description.is_on_fn(coordinator.diffuser) async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the device on.""" - await self._diffuser.turn_on() + """Turn the switch on.""" + await self.entity_description.turn_on_fn(self.coordinator.diffuser) self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" - await self._diffuser.turn_off() + """Turn the switch off.""" + await self.entity_description.turn_off_fn(self.coordinator.diffuser) self._attr_is_on = False self.async_write_ha_state() @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - self._attr_is_on = self._diffuser.is_on - self.async_write_ha_state() + self._attr_is_on = self.entity_description.is_on_fn(self.coordinator.diffuser) + super()._handle_coordinator_update() diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py new file mode 100644 index 0000000000..1a308f9dff --- /dev/null +++ b/homeassistant/components/roborock/__init__.py @@ -0,0 +1,104 @@ +"""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 DeviceData, HomeDataDevice, 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) + device_map: dict[str, HomeDataDevice] = { + device.duid: device for device in home_data.devices + home_data.received_devices + } + product_info = {product.id: product for product in home_data.products} + # 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_clients = [ + RoborockMqttClient( + user_data, DeviceData(device, product_info[device.product_id].model) + ) + for device in device_map.values() + ] + network_results = await asyncio.gather( + *(mqtt_client.get_networking() for mqtt_client in mqtt_clients) + ) + network_info = { + device.duid: result + for device, result in zip(device_map.values(), network_results) + if result is not None + } + await asyncio.gather( + *(mqtt_client.async_disconnect() for mqtt_client in mqtt_clients), + return_exceptions=True, + ) + if not network_info: + raise ConfigEntryNotReady( + "Could not get network information about your devices" + ) + coordinator_map: dict[str, RoborockDataUpdateCoordinator] = {} + for device_id, device in device_map.items(): + coordinator_map[device_id] = RoborockDataUpdateCoordinator( + hass, + device, + network_info[device_id], + product_info[device.product_id], + ) + # If one device update fails - we still want to set up other devices + await asyncio.gather( + *( + coordinator.async_config_entry_first_refresh() + for coordinator in coordinator_map.values() + ), + return_exceptions=True, + ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + device_id: coordinator + for device_id, coordinator in coordinator_map.items() + if coordinator.last_update_success + } # Only add coordinators that succeeded + + if not hass.data[DOMAIN][entry.entry_id]: + # Don't start if no coordinators succeeded. + raise ConfigEntryNotReady("There are no devices that can currently be reached.") + + 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 asyncio.gather( + *( + coordinator.release() + for coordinator in hass.data[DOMAIN][entry.entry_id].values() + ) + ) + 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..fcfad6e8cd --- /dev/null +++ b/homeassistant/components/roborock/config_flow.py @@ -0,0 +1,113 @@ +"""Config flow for Roborock.""" +from __future__ import annotations + +import logging +from typing import Any + +from roborock.api import RoborockApiClient +from roborock.containers import UserData +from roborock.exceptions import ( + RoborockAccountDoesNotExist, + RoborockException, + RoborockInvalidCode, + RoborockInvalidEmail, + RoborockUrlException, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_BASE_URL, CONF_ENTRY_CODE, CONF_USER_DATA, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Roborock.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._username: str | None = None + self._client: RoborockApiClient | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + username = user_input[CONF_USERNAME] + await self.async_set_unique_id(username.lower()) + self._abort_if_unique_id_configured() + self._username = username + _LOGGER.debug("Requesting code for Roborock account") + self._client = RoborockApiClient(username) + try: + await self._client.request_code() + except RoborockAccountDoesNotExist: + errors["base"] = "invalid_email" + except RoborockUrlException: + errors["base"] = "unknown_url" + except RoborockInvalidEmail: + errors["base"] = "invalid_email_format" + except RoborockException as ex: + _LOGGER.exception(ex) + errors["base"] = "unknown_roborock" + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception(ex) + errors["base"] = "unknown" + else: + return await self.async_step_code() + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_USERNAME): str}), + errors=errors, + ) + + async def async_step_code( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + assert self._client + assert self._username + if user_input is not None: + code = user_input[CONF_ENTRY_CODE] + _LOGGER.debug("Logging into Roborock account using email provided code") + try: + login_data = await self._client.code_login(code) + except RoborockInvalidCode: + errors["base"] = "invalid_code" + except RoborockException as ex: + _LOGGER.exception(ex) + errors["base"] = "unknown_roborock" + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception(ex) + errors["base"] = "unknown" + else: + return self._create_entry(self._client, self._username, login_data) + + return self.async_show_form( + step_id="code", + data_schema=vol.Schema({vol.Required(CONF_ENTRY_CODE): str}), + errors=errors, + ) + + def _create_entry( + self, client: RoborockApiClient, username: str, user_data: UserData + ) -> FlowResult: + """Finished config flow and create entry.""" + return self.async_create_entry( + title=username, + data={ + CONF_USERNAME: username, + CONF_USER_DATA: user_data.as_dict(), + CONF_BASE_URL: client.base_url, + }, + ) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py new file mode 100644 index 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..acaa2bfa3f --- /dev/null +++ b/homeassistant/components/roborock/coordinator.py @@ -0,0 +1,63 @@ +"""Roborock Coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo +from roborock.exceptions import RoborockException +from roborock.local_api import RoborockLocalClient +from roborock.roborock_typing import DeviceProp + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN +from .models import RoborockHassDeviceInfo + +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): + """Class to manage fetching data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + device: HomeDataDevice, + device_networking: NetworkInfo, + product_info: HomeDataProduct, + ) -> None: + """Initialize.""" + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + self.device_info = RoborockHassDeviceInfo( + device, + device_networking, + product_info, + DeviceProp(), + ) + device_info = DeviceData(device, product_info.model, device_networking.ip) + self.api = RoborockLocalClient(device_info) + + async def release(self) -> None: + """Disconnect from API.""" + await self.api.async_disconnect() + + async def _update_device_prop(self) -> None: + """Update device properties.""" + device_prop = await self.api.get_prop() + if device_prop: + if self.device_info.props: + self.device_info.props.update(device_prop) + else: + self.device_info.props = device_prop + + async def _async_update_data(self) -> DeviceProp: + """Update data via library.""" + try: + await self._update_device_prop() + except RoborockException as ex: + raise UpdateFailed(ex) from ex + return self.device_info.props diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py new file mode 100644 index 0000000000..39a9524226 --- /dev/null +++ b/homeassistant/components/roborock/device.py @@ -0,0 +1,64 @@ +"""Support for Roborock device base class.""" + +from typing import Any + +from roborock.containers import Status +from roborock.exceptions import RoborockException +from roborock.roborock_typing import RoborockCommand + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import RoborockDataUpdateCoordinator +from .const import DOMAIN + + +class RoborockCoordinatedEntity(CoordinatorEntity[RoborockDataUpdateCoordinator]): + """Representation of a base a coordinated Roborock Entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + ) -> None: + """Initialize the coordinated Roborock Device.""" + super().__init__(coordinator) + self._attr_unique_id = unique_id + + @property + def _device_status(self) -> Status: + """Return the status of the device.""" + data = self.coordinator.data + if data: + status = data.status + if status: + return status + return Status({}) + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + name=self.coordinator.device_info.device.name, + identifiers={(DOMAIN, self.coordinator.device_info.device.duid)}, + manufacturer="Roborock", + model=self.coordinator.device_info.product.model, + sw_version=self.coordinator.device_info.device.fv, + ) + + async def send( + self, command: RoborockCommand, params: dict[str, Any] | list[Any] | None = None + ) -> dict: + """Send a command to a vacuum cleaner.""" + try: + response = await self.coordinator.api.send_command(command, params) + except RoborockException as err: + raise HomeAssistantError( + f"Error while calling {command.name} with {params}" + ) from err + + await self.coordinator.async_request_refresh() + return response diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 00f90271cf..44a4cba89c 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.17.0"] } diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py new file mode 100644 index 0000000000..a30c84ce1d --- /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.roborock_typing import DeviceProp + + +@dataclass +class RoborockHassDeviceInfo: + """A model to describe roborock devices.""" + + device: HomeDataDevice + network_info: NetworkInfo + product: HomeDataProduct + props: DeviceProp diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py new file mode 100644 index 0000000000..d27888a177 --- /dev/null +++ b/homeassistant/components/roborock/select.py @@ -0,0 +1,110 @@ +"""Support for Roborock select.""" +from collections.abc import Callable +from dataclasses import dataclass + +from roborock.containers import Status +from roborock.roborock_typing import RoborockCommand + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +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 + + +@dataclass +class RoborockSelectDescriptionMixin: + """Define an entity description mixin for select entities.""" + + # The command that the select entity will send to the api. + api_command: RoborockCommand + # Gets the current value of the select entity. + value_fn: Callable[[Status], str] + # Gets all options of the select entity. + options_lambda: Callable[[Status], list[str]] + # Takes the value from the select entiy and converts it for the api. + parameter_lambda: Callable[[str, Status], 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", + api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, + value_fn=lambda data: data.water_box_mode.name, + options_lambda=lambda data: data.water_box_mode.keys() + if data.water_box_mode + else None, + parameter_lambda=lambda key, status: [status.water_box_mode.as_dict().get(key)], + ), + RoborockSelectDescription( + key="mop_mode", + translation_key="mop_mode", + api_command=RoborockCommand.SET_MOP_MODE, + value_fn=lambda data: data.mop_mode.name, + options_lambda=lambda data: data.mop_mode.keys() if data.mop_mode else None, + parameter_lambda=lambda key, status: [status.mop_mode.as_dict().get(key)], + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roborock select platform.""" + + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + RoborockSelectEntity( + f"{description.key}_{slugify(device_id)}", + coordinator, + description, + ) + for device_id, coordinator in coordinators.items() + for description in SELECT_DESCRIPTIONS + if description.options_lambda(coordinator.device_info.props.status) is not None + ) + + +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, + coordinator: RoborockDataUpdateCoordinator, + entity_description: RoborockSelectDescription, + ) -> None: + """Create a select entity.""" + self.entity_description = entity_description + super().__init__(unique_id, coordinator) + self._attr_options = self.entity_description.options_lambda(self._device_status) + + async def async_select_option(self, option: str) -> None: + """Set the option.""" + await self.send( + self.entity_description.api_command, + self.entity_description.parameter_lambda(option, self._device_status), + ) + + @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..64c9d268e5 --- /dev/null +++ b/homeassistant/components/roborock/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter your Roborock email address.", + "data": { + "username": "Email" + } + }, + "code": { + "description": "Type the verification code sent to your email", + "data": { + "code": "Verification code" + } + } + }, + "error": { + "invalid_code": "The code you entered was incorrect, please check it and try again.", + "invalid_email": "There is no account associated with the email you entered, please try again.", + "invalid_email_format": "There is an issue with the formatting of your email - please try again.", + "unknown_roborock": "There was an unknown roborock exception - please check your logs.", + "unknown_url": "There was an issue determining the correct url for your roborock account - please check your logs.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "select": { + "mop_mode": { + "name": "Mop mode", + "state": { + "standard": "Standard", + "deep": "Deep", + "deep_plus": "Deep+", + "custom": "Custom", + "fast": "Fast" + } + }, + "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..666b8488d8 --- /dev/null +++ b/homeassistant/components/roborock/vacuum.py @@ -0,0 +1,162 @@ +"""Support for Roborock vacuum class.""" +from typing import Any + +from roborock.code_mappings import RoborockStateCode +from roborock.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 + +STATE_CODE_TO_STATE = { + RoborockStateCode.starting: STATE_IDLE, # "Starting" + RoborockStateCode.charger_disconnected: STATE_IDLE, # "Charger disconnected" + RoborockStateCode.idle: STATE_IDLE, # "Idle" + RoborockStateCode.remote_control_active: STATE_CLEANING, # "Remote control active" + RoborockStateCode.cleaning: STATE_CLEANING, # "Cleaning" + RoborockStateCode.returning_home: STATE_RETURNING, # "Returning home" + RoborockStateCode.manual_mode: STATE_CLEANING, # "Manual mode" + RoborockStateCode.charging: STATE_DOCKED, # "Charging" + RoborockStateCode.charging_problem: STATE_ERROR, # "Charging problem" + RoborockStateCode.paused: STATE_PAUSED, # "Paused" + RoborockStateCode.spot_cleaning: STATE_CLEANING, # "Spot cleaning" + RoborockStateCode.error: STATE_ERROR, # "Error" + RoborockStateCode.shutting_down: STATE_IDLE, # "Shutting down" + RoborockStateCode.updating: STATE_DOCKED, # "Updating" + RoborockStateCode.docking: STATE_RETURNING, # "Docking" + RoborockStateCode.going_to_target: STATE_CLEANING, # "Going to target" + RoborockStateCode.zoned_cleaning: STATE_CLEANING, # "Zoned cleaning" + RoborockStateCode.segment_cleaning: STATE_CLEANING, # "Segment cleaning" + RoborockStateCode.emptying_the_bin: STATE_DOCKED, # "Emptying the bin" on s7+ + RoborockStateCode.washing_the_mop: STATE_DOCKED, # "Washing the mop" on s7maxV + RoborockStateCode.going_to_wash_the_mop: STATE_RETURNING, # "Going to wash the mop" on s7maxV + RoborockStateCode.charging_complete: STATE_DOCKED, # "Charging complete" + RoborockStateCode.device_offline: STATE_ERROR, # "Device offline" +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Roborock sensor.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + RoborockVacuum(slugify(device_id), coordinator) + for device_id, coordinator in coordinators.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 + ) + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + ) -> None: + """Initialize a vacuum.""" + StateVacuumEntity.__init__(self) + RoborockCoordinatedEntity.__init__(self, unique_id, coordinator) + self._attr_fan_speed_list = self._device_status.fan_power.keys() + + @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 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.name + + @property + def status(self) -> str | None: + """Return the status of the vacuum cleaner.""" + return self._device_status.state.name + + async def async_start(self) -> None: + """Start the vacuum.""" + await self.send(RoborockCommand.APP_START) + + 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, + [self._device_status.fan_power.as_dict().get(fan_speed)], + ) + await self.coordinator.async_request_refresh() + + async def async_start_pause(self) -> None: + """Start, pause or resume the cleaning task.""" + 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/manifest.json b/homeassistant/components/roku/manifest.json index f5308eddf9..944304aa64 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -11,7 +11,7 @@ "iot_class": "local_polling", "loggers": ["rokuecp"], "quality_scale": "silver", - "requirements": ["rokuecp==0.17.0"], + "requirements": ["rokuecp==0.17.1"], "ssdp": [ { "st": "roku:ecp", diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index b09ddb7ef7..877e58233d 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -252,7 +252,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return None @property - def source_list(self) -> list: + def source_list(self) -> list[str]: """List of available input sources.""" return ["Home"] + sorted( app.name for app in self.coordinator.data.apps if app.name is not None @@ -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.""" @@ -353,7 +353,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): @roku_exception_handler() async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play media from a URL or file, launch an application, or tune to a channel.""" extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {} diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 5aa630df5d..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.5"] + "requirements": ["roombapy==1.6.8"] } diff --git a/homeassistant/components/roon/manifest.json b/homeassistant/components/roon/manifest.json index f1d26af190..4fa527d076 100644 --- a/homeassistant/components/roon/manifest.json +++ b/homeassistant/components/roon/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roon", "iot_class": "local_push", "loggers": ["roonapi"], - "requirements": ["roonapi==0.1.3"] + "requirements": ["roonapi==0.1.4"] } diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 09ecc3cec9..3bcafe4ba9 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -12,6 +12,8 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, + RepeatMode, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_DEFAULT_NAME @@ -35,6 +37,16 @@ SERVICE_TRANSFER = "transfer" ATTR_TRANSFER = "transfer_id" +REPEAT_MODE_MAPPING_TO_HA = { + "loop": RepeatMode.ALL, + "disabled": RepeatMode.OFF, + "loop_one": RepeatMode.ONE, +} + +REPEAT_MODE_MAPPING_TO_ROON = { + value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items() +} + async def async_setup_entry( hass: HomeAssistant, @@ -84,6 +96,7 @@ class RoonDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.SHUFFLE_SET | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.TURN_ON @@ -262,6 +275,9 @@ class RoonDevice(MediaPlayerEntity): self._attr_unique_id = self.player_data["dev_id"] self._zone_id = self.player_data["zone_id"] self._output_id = self.player_data["output_id"] + self._attr_repeat = REPEAT_MODE_MAPPING_TO_HA.get( + self.player_data["settings"]["loop"] + ) self._attr_shuffle = self.player_data["settings"]["shuffle"] self._attr_name = self.player_data["display_name"] @@ -331,7 +347,7 @@ class RoonDevice(MediaPlayerEntity): def set_volume_level(self, volume: float) -> None: """Send new volume_level to device.""" - volume = int(volume * 100) + volume = volume * 100 self._server.roonapi.set_volume_percent(self.output_id, volume) def mute_volume(self, mute=True): @@ -373,7 +389,15 @@ class RoonDevice(MediaPlayerEntity): """Set shuffle state.""" self._server.roonapi.shuffle(self.output_id, shuffle) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def set_repeat(self, repeat: RepeatMode) -> None: + """Set repeat mode.""" + if repeat not in REPEAT_MODE_MAPPING_TO_ROON: + raise ValueError(f"Unsupported repeat mode: {repeat}") + self._server.roonapi.repeat(self.output_id, REPEAT_MODE_MAPPING_TO_ROON[repeat]) + + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Send the play_media command to the media player.""" _LOGGER.debug("Playback request for %s / %s", media_type, media_id) @@ -474,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/ruckus_unleashed/__init__.py b/homeassistant/components/ruckus_unleashed/__init__.py index 5861486457..f276c0f8fc 100644 --- a/homeassistant/components/ruckus_unleashed/__init__.py +++ b/homeassistant/components/ruckus_unleashed/__init__.py @@ -6,8 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers import device_registry as dr from .const import ( API_AP, @@ -43,13 +42,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: system_info = await ruckus.system_info() - registry = device_registry.async_get(hass) + registry = dr.async_get(hass) ap_info = await ruckus.ap_info() for device in ap_info[API_AP][API_ID].values(): registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, device[API_MAC])}, - identifiers={(CONNECTION_NETWORK_MAC, device[API_MAC])}, + connections={(dr.CONNECTION_NETWORK_MAC, device[API_MAC])}, + identifiers={(dr.CONNECTION_NETWORK_MAC, device[API_MAC])}, manufacturer=MANUFACTURER, name=device[API_DEVICE_NAME], model=device[API_MODEL], diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index 5e8998c47d..dd6d7fd676 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.device_tracker import ScannerEntity, SourceType from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -37,7 +37,7 @@ async def async_setup_entry( coordinator.async_add_listener(router_update) ) - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) restore_entities(registry, coordinator, entry, async_add_entities, tracked) 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/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index 0b8b6e92eb..2e345905d5 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -237,7 +237,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SabnzbdApiException as err: _LOGGER.error(err) - async_track_time_interval(hass, async_update_sabnzbd, UPDATE_INTERVAL) + entry.async_on_unload( + async_track_time_interval(hass, async_update_sabnzbd, UPDATE_INTERVAL) + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 993100262e..55d0fbdfbd 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -26,8 +26,12 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.typing import ConfigType @@ -92,6 +96,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in config: return True + 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", + translation_placeholders={ + "on_action_url": "https://www.home-assistant.io/integrations/samsungtv/#turn-on-action" + }, + learn_more_url="https://www.home-assistant.io/integrations/samsungtv/#turn-on-action", + ) for entry_config in config[DOMAIN]: ip_address = await hass.async_add_executor_job( socket.gethostbyname, entry_config[CONF_HOST] @@ -148,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.""" @@ -211,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 @@ -323,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/samsungtv/device_trigger.py b/homeassistant/components/samsungtv/device_trigger.py new file mode 100644 index 0000000000..f3a69e637e --- /dev/null +++ b/homeassistant/components/samsungtv/device_trigger.py @@ -0,0 +1,80 @@ +"""Provides device automations for control of Samsung TV.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.const import CONF_DEVICE_ID, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import trigger +from .const import DOMAIN +from .helpers import ( + async_get_client_by_device_entry, + async_get_device_entry_by_device_id, +) +from .triggers.turn_on import ( + PLATFORM_TYPE as TURN_ON_PLATFORM_TYPE, + async_get_turn_on_trigger, +) + +TRIGGER_TYPES = {TURN_ON_PLATFORM_TYPE} +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + } +) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + if config[CONF_TYPE] == TURN_ON_PLATFORM_TYPE: + device_id = config[CONF_DEVICE_ID] + try: + device = async_get_device_entry_by_device_id(hass, device_id) + if DOMAIN in hass.data: + async_get_client_by_device_entry(hass, device) + except ValueError as err: + raise InvalidDeviceAutomationConfig(err) from err + + return config + + +async def async_get_triggers( + _hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: + """List device triggers for device.""" + triggers = [async_get_turn_on_trigger(device_id)] + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + if (trigger_type := config[CONF_TYPE]) == TURN_ON_PLATFORM_TYPE: + trigger_config = { + CONF_PLATFORM: trigger_type, + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + } + trigger_config = await trigger.async_validate_trigger_config( + hass, trigger_config + ) + return await trigger.async_attach_trigger( + hass, trigger_config, action, trigger_info + ) + + raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") diff --git a/homeassistant/components/samsungtv/helpers.py b/homeassistant/components/samsungtv/helpers.py new file mode 100644 index 0000000000..06a3c3e70e --- /dev/null +++ b/homeassistant/components/samsungtv/helpers.py @@ -0,0 +1,61 @@ +"""Helper functions for Samsung TV.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry + +from .bridge import SamsungTVBridge +from .const import DOMAIN + + +@callback +def async_get_device_entry_by_device_id( + hass: HomeAssistant, device_id: str +) -> DeviceEntry: + """Get Device Entry from Device Registry by device ID. + + Raises ValueError if device ID is invalid. + """ + device_reg = dr.async_get(hass) + if (device := device_reg.async_get(device_id)) is None: + raise ValueError(f"Device {device_id} is not a valid {DOMAIN} device.") + + return device + + +@callback +def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> str: + """Get device ID from an entity ID. + + Raises ValueError if entity or device ID is invalid. + """ + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(entity_id) + + if ( + entity_entry is None + or entity_entry.device_id is None + or entity_entry.platform != DOMAIN + ): + raise ValueError(f"Entity {entity_id} is not a valid {DOMAIN} entity.") + + return entity_entry.device_id + + +@callback +def async_get_client_by_device_entry( + hass: HomeAssistant, device: DeviceEntry +) -> SamsungTVBridge: + """Get SamsungTVBridge from Device Registry by device entry. + + Raises ValueError if client is not found. + """ + domain_data: dict[str, SamsungTVBridge] = hass.data[DOMAIN] + for config_entry_id in device.config_entries: + if bridge := domain_data.get(config_entry_id): + return bridge + + raise ValueError( + f"Device {device.id} is not from an existing {DOMAIN} config entry" + ) diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index d3e49c3bd4..027badc189 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -37,7 +37,7 @@ "requirements": [ "getmac==0.8.2", "samsungctl[websocket]==0.7.1", - "samsungtvws[async,encrypted]==2.5.0", + "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", "async-upnp-client==0.33.1" ], diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 3e544b181f..302d9c4915 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -6,6 +6,7 @@ from collections.abc import Coroutine, Sequence from datetime import datetime, timedelta from typing import Any +import async_timeout from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester from async_upnp_client.client import UpnpDevice, UpnpService, UpnpStateVariable from async_upnp_client.client_factory import UpnpFactory @@ -32,13 +33,16 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_component +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_component, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script +from homeassistant.helpers.trigger import PluggableAction from homeassistant.util import dt as dt_util from .bridge import SamsungTVBridge, SamsungTVWSBridge @@ -50,6 +54,7 @@ from .const import ( DOMAIN, LOGGER, ) +from .triggers.turn_on import async_get_turn_on_trigger SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"} @@ -111,6 +116,7 @@ class SamsungTVDevice(MediaPlayerEntity): self._ssdp_rendering_control_location: str | None = config_entry.data.get( CONF_SSDP_RENDERING_CONTROL_LOCATION ) + self._turn_on = PluggableAction(self.async_write_ha_state) self._on_script = on_script # Assume that the TV is in Play mode self._playing: bool = True @@ -125,7 +131,8 @@ class SamsungTVDevice(MediaPlayerEntity): self._attr_supported_features = SUPPORT_SAMSUNGTV if self._on_script or self._mac: - # Add turn-on if on_script or mac is available + # (deprecated) add turn-on if on_script YAML or mac is available + # Triggers have not yet been registered so this is adjusted in the property self._attr_supported_features |= MediaPlayerEntityFeature.TURN_ON if self._ssdp_rendering_control_location: self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET @@ -139,7 +146,7 @@ class SamsungTVDevice(MediaPlayerEntity): self._attr_device_info["identifiers"] = {(DOMAIN, self.unique_id)} if self._mac: self._attr_device_info["connections"] = { - (CONNECTION_NETWORK_MAC, self._mac) + (dr.CONNECTION_NETWORK_MAC, self._mac) } # Mark the end of a shutdown command (need to wait 15 seconds before @@ -153,6 +160,15 @@ class SamsungTVDevice(MediaPlayerEntity): self._dmr_device: DmrDevice | None = None self._upnp_server: AiohttpNotifyServer | None = None + @property + def supported_features(self) -> MediaPlayerEntityFeature: + """Flag media player features that are supported.""" + # `turn_on` triggers are not yet registered during initialisation, + # so this property needs to be dynamic + if self._turn_on: + return self._attr_supported_features | MediaPlayerEntityFeature.TURN_ON + return self._attr_supported_features + def _update_sources(self) -> None: self._attr_source_list = list(SOURCES) if app_list := self._app_list: @@ -250,7 +266,8 @@ class SamsungTVDevice(MediaPlayerEntity): # enter it unless we have to (Python 3.11 will have zero cost try) return try: - await asyncio.wait_for(self._app_list_event.wait(), APP_LIST_DELAY) + async with async_timeout.timeout(APP_LIST_DELAY): + await self._app_list_event.wait() except asyncio.TimeoutError as err: # No need to try again self._app_list_event.set() @@ -357,11 +374,23 @@ class SamsungTVDevice(MediaPlayerEntity): return False return ( self.state == MediaPlayerState.ON + or bool(self._turn_on) or self._on_script is not None or self._mac is not None or self._power_off_in_progress() ) + async def async_added_to_hass(self) -> None: + """Connect and subscribe to dispatcher signals and state updates.""" + await super().async_added_to_hass() + + if (entry := self.registry_entry) and entry.device_id: + self.async_on_remove( + self._turn_on.async_register( + self.hass, async_get_turn_on_trigger(entry.device_id) + ) + ) + async def async_turn_off(self) -> None: """Turn off media player.""" self._end_of_power_off = dt_util.utcnow() + SCAN_INTERVAL_PLUS_OFF_TIME @@ -415,7 +444,7 @@ class SamsungTVDevice(MediaPlayerEntity): await self._async_send_keys(["KEY_CHDOWN"]) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Support changing a channel.""" if media_type == MediaType.APP: @@ -446,7 +475,10 @@ class SamsungTVDevice(MediaPlayerEntity): async def async_turn_on(self) -> None: """Turn the media player on.""" - if self._on_script: + if self._turn_on: + await self._turn_on.async_run(self.hass, self._context) + elif self._on_script: + # YAML on_script is deprecated - replaced by turn_on trigger await self._on_script.async_run(context=self._context) elif self._mac: await self.hass.async_add_executor_job(self._wake_on_lan) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index e67b50fae7..cfa04244e8 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -39,5 +39,16 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "device_automation": { + "trigger_type": { + "samsungtv.turn_on": "Device is requested to turn on" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The SamsungTV YAML configuration is being removed", + "description": "Configuring SamsungTV using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the SamsungTV YAML configuration from your `configuration.yaml` file and restart Home Assistant to fix this issue.\n\nPlease note that previously configured `turn_on_action` needs to be manually converted to use the `turn_on` trigger ([documentation]({on_action_url}))." + } } } diff --git a/homeassistant/components/samsungtv/trigger.py b/homeassistant/components/samsungtv/trigger.py new file mode 100644 index 0000000000..cd78ff18be --- /dev/null +++ b/homeassistant/components/samsungtv/trigger.py @@ -0,0 +1,46 @@ +"""Samsung TV trigger dispatcher.""" +from __future__ import annotations + +from typing import cast + +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + TriggerProtocol, +) +from homeassistant.helpers.typing import ConfigType + +from .triggers import turn_on + +TRIGGERS = { + "turn_on": turn_on, +} + + +def _get_trigger_platform(config: ConfigType) -> TriggerProtocol: + """Return trigger platform.""" + platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) + if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: + raise ValueError(f"Unknown Samsung TV trigger platform {config[CONF_PLATFORM]}") + return cast(TriggerProtocol, TRIGGERS[platform_split[1]]) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + platform = _get_trigger_platform(config) + return cast(ConfigType, platform.TRIGGER_SCHEMA(config)) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach trigger of specified platform.""" + platform = _get_trigger_platform(config) + return await platform.async_attach_trigger(hass, config, action, trigger_info) diff --git a/homeassistant/components/samsungtv/triggers/__init__.py b/homeassistant/components/samsungtv/triggers/__init__.py new file mode 100644 index 0000000000..9e2e2af6d4 --- /dev/null +++ b/homeassistant/components/samsungtv/triggers/__init__.py @@ -0,0 +1 @@ +"""Samsung TV triggers.""" diff --git a/homeassistant/components/samsungtv/triggers/turn_on.py b/homeassistant/components/samsungtv/triggers/turn_on.py new file mode 100644 index 0000000000..de0036234a --- /dev/null +++ b/homeassistant/components/samsungtv/triggers/turn_on.py @@ -0,0 +1,108 @@ +"""Samsung TV device turn on trigger.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.trigger import ( + PluggableAction, + TriggerActionType, + TriggerInfo, +) +from homeassistant.helpers.typing import ConfigType + +from ..const import DOMAIN +from ..helpers import ( + async_get_device_entry_by_device_id, + async_get_device_id_from_entity_id, +) + +# Platform type should be . +PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" + +TRIGGER_TYPE_TURN_ON = "turn_on" + +TRIGGER_SCHEMA = vol.All( + cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): PLATFORM_TYPE, + vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + }, + ), + cv.has_at_least_one_key(ATTR_ENTITY_ID, ATTR_DEVICE_ID), +) + + +def async_get_turn_on_trigger(device_id: str) -> dict[str, str]: + """Return data for a turn on trigger.""" + + return { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: PLATFORM_TYPE, + } + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, + *, + platform_type: str = PLATFORM_TYPE, +) -> CALLBACK_TYPE | None: + """Attach a trigger.""" + device_ids = set() + if ATTR_DEVICE_ID in config: + device_ids.update(config.get(ATTR_DEVICE_ID, [])) + + if ATTR_ENTITY_ID in config: + device_ids.update( + { + async_get_device_id_from_entity_id(hass, entity_id) + for entity_id in config.get(ATTR_ENTITY_ID, []) + } + ) + + trigger_data = trigger_info["trigger_data"] + + unsubs = [] + + for device_id in device_ids: + device = async_get_device_entry_by_device_id(hass, device_id) + device_name = device.name_by_user or device.name + + variables = { + **trigger_data, + CONF_PLATFORM: platform_type, + ATTR_DEVICE_ID: device_id, + "description": f"Samsung turn on trigger for {device_name}", + } + + turn_on_trigger = async_get_turn_on_trigger(device_id) + + unsubs.append( + PluggableAction.async_attach_trigger( + hass, turn_on_trigger, action, {"trigger": variables} + ) + ) + + @callback + def async_remove() -> None: + """Remove state listeners async.""" + for unsub in unsubs: + unsub() + unsubs.clear() + + return async_remove diff --git a/homeassistant/components/scene/services.yaml b/homeassistant/components/scene/services.yaml index cbe5e70f68..202b4a98aa 100644 --- a/homeassistant/components/scene/services.yaml +++ b/homeassistant/components/scene/services.yaml @@ -29,7 +29,7 @@ apply: name: Entities state description: The entities and the state that they need to be. required: true - example: + example: | light.kitchen: "on" light.ceiling: state: "on" @@ -60,7 +60,7 @@ create: entities: name: Entities state description: The entities to control with the scene. - example: + example: | light.tv_back_light: "on" light.ceiling: state: "on" @@ -70,7 +70,7 @@ create: snapshot_entities: name: Snapshot entities description: The entities of which a snapshot is to be taken - example: + example: | - light.ceiling - light.kitchen selector: 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 fdcb8c4ffd..4c22e5ecea 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -1,9 +1,24 @@ { "title": "Schedule", - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "name": "[%key:component::schedule::title%]", + "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/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index fad4dc6509..6662c20ad4 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -10,7 +10,6 @@ from screenlogicpy.const import ( SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT, - ScreenLogicWarning, ) from homeassistant.config_entries import ConfigEntry @@ -52,8 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await gateway.async_connect(**connect_info) except ScreenLogicError as ex: - _LOGGER.error("Error while connecting to the gateway %s: %s", connect_info, ex) - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady(ex.msg) from ex coordinator = ScreenlogicDataUpdateCoordinator( hass, config_entry=entry, gateway=gateway @@ -90,26 +88,26 @@ async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None await hass.config_entries.async_reload(entry.entry_id) -async def async_get_connect_info(hass: HomeAssistant, entry: ConfigEntry): +async def async_get_connect_info( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, str | int]: """Construct connect_info from configuration entry and returns it to caller.""" mac = entry.unique_id # Attempt to rediscover gateway to follow IP changes discovered_gateways = await async_discover_gateways_by_unique_id(hass) if mac in discovered_gateways: - connect_info = discovered_gateways[mac] - else: - _LOGGER.warning("Gateway rediscovery failed") - # Static connection defined or fallback from discovery - connect_info = { - SL_GATEWAY_NAME: name_for_mac(mac), - SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], - SL_GATEWAY_PORT: entry.data[CONF_PORT], - } + return discovered_gateways[mac] - return connect_info + _LOGGER.warning("Gateway rediscovery failed") + # Static connection defined or fallback from discovery + return { + SL_GATEWAY_NAME: name_for_mac(mac), + SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], + SL_GATEWAY_PORT: entry.data[CONF_PORT], + } -class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): +class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage the data update for the Screenlogic component.""" def __init__( @@ -143,7 +141,7 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): """Return the gateway data.""" return self.gateway.get_data() - async def _async_update_configured_data(self): + async def _async_update_configured_data(self) -> None: """Update data sets based on equipment config.""" equipment_flags = self.gateway.get_data()[SL_DATA.KEY_CONFIG]["equipment_flags"] if not self.gateway.is_client: @@ -155,28 +153,19 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: await self.gateway.async_get_scg() - async def _async_update_data(self): + async def _async_update_data(self) -> None: """Fetch data from the Screenlogic gateway.""" + assert self.config_entry is not None try: - await self._async_update_configured_data() - except ScreenLogicError as error: - _LOGGER.warning("Update error - attempting reconnect: %s", error) - await self._async_reconnect_update_data() - except ScreenLogicWarning as warn: - raise UpdateFailed(f"Incomplete update: {warn}") from warn + if not self.gateway.is_connected: + connect_info = await async_get_connect_info( + self.hass, self.config_entry + ) + await self.gateway.async_connect(**connect_info) + await self._async_update_configured_data() + except ScreenLogicError as ex: + if self.gateway.is_connected: + await self.gateway.async_disconnect() + raise UpdateFailed(ex.msg) from ex return None - - async def _async_reconnect_update_data(self): - """Attempt to reconnect to the gateway and fetch data.""" - try: - # Clean up the previous connection as we're about to create a new one - await self.gateway.async_disconnect() - - connect_info = await async_get_connect_info(self.hass, self.config_entry) - await self.gateway.async_connect(**connect_info) - - await self._async_update_configured_data() - - except (ScreenLogicError, ScreenLogicWarning) as ex: - raise UpdateFailed(ex) from ex diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 80b4df4d2d..eb006b5536 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -1,9 +1,10 @@ """Base ScreenLogicEntity definitions.""" +from datetime import datetime import logging from typing import Any -# from screenlogicpy import ScreenLogicError, ScreenLogicGateway -from screenlogicpy.const import DATA as SL_DATA, EQUIPMENT, ON_OFF +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const import CODE, DATA as SL_DATA, EQUIPMENT, ON_OFF from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -19,7 +20,12 @@ _LOGGER = logging.getLogger(__name__) class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): """Base class for all ScreenLogic entities.""" - def __init__(self, coordinator, data_key, enabled=True): + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + data_key: str, + enabled: bool = True, + ) -> None: """Initialize of the entity.""" super().__init__(coordinator) self._data_key = data_key @@ -34,8 +40,10 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): ] except KeyError: equipment_model = f"Unknown Model C:{controller_type} H:{hardware_type}" + mac = self.mac + assert mac is not None self._attr_device_info = DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self.mac)}, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, manufacturer="Pentair", model=equipment_model, name=self.gateway_name, @@ -43,17 +51,18 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): ) @property - def mac(self): + def mac(self) -> str | None: """Mac address.""" + assert self.coordinator.config_entry is not None return self.coordinator.config_entry.unique_id @property - def config_data(self): + def config_data(self) -> dict[str | int, Any]: """Shortcut for config data.""" return self.gateway_data[SL_DATA.KEY_CONFIG] @property - def gateway(self): + def gateway(self) -> ScreenLogicGateway: """Return the gateway.""" return self.coordinator.gateway @@ -63,18 +72,18 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): return self.gateway.get_data() @property - def gateway_name(self): + def gateway_name(self) -> str: """Return the configured name of the gateway.""" return self.gateway.name - async def _async_refresh(self): + async def _async_refresh(self) -> None: """Refresh the data from the gateway.""" await self.coordinator.async_refresh() # Second debounced refresh to catch any secondary # changes in the device await self.coordinator.async_request_refresh() - async def _async_refresh_timed(self, now): + async def _async_refresh_timed(self, now: datetime) -> None: """Refresh from a timed called.""" await self.coordinator.async_request_refresh() @@ -82,25 +91,40 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): class ScreenLogicPushEntity(ScreenlogicEntity): """Base class for all ScreenLogic push entities.""" - def __init__(self, coordinator, data_key, message_code, enabled=True): + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + data_key: str, + message_code: CODE, + enabled: bool = True, + ) -> None: """Initialize the entity.""" super().__init__(coordinator, data_key, enabled) self._update_message_code = message_code + self._last_update_success = True @callback def _async_data_updated(self) -> None: """Handle data updates.""" + self._last_update_success = self.coordinator.last_update_success self.async_write_ha_state() async def async_added_to_hass(self) -> None: """When entity is added to hass.""" - + await super().async_added_to_hass() self.async_on_remove( await self.gateway.async_subscribe_client( self._async_data_updated, self._update_message_code ) ) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + # For push entities, only take updates from the coordinator if availability changes. + if self.coordinator.last_update_success != self._last_update_success: + self._async_data_updated() + class ScreenLogicCircuitEntity(ScreenLogicPushEntity): """Base class for all ScreenLogic switch and light entities.""" @@ -108,7 +132,7 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity): _attr_has_entity_name = True @property - def name(self): + def name(self) -> str: """Get the name of the switch.""" return self.circuit["name"] @@ -117,15 +141,15 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity): """Get whether the switch is in on state.""" return self.circuit["value"] == ON_OFF.ON - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Send the ON command.""" await self._async_set_circuit(ON_OFF.ON) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Send the OFF command.""" await self._async_set_circuit(ON_OFF.OFF) - async def _async_set_circuit(self, circuit_value) -> None: + async def _async_set_circuit(self, circuit_value: int) -> None: if not await self.gateway.async_set_circuit(self._data_key, circuit_value): raise HomeAssistantError( f"Failed to set_circuit {self._data_key} {circuit_value}" diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 0c2a427d7f..5b8b836942 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.7.1"] + "requirements": ["screenlogicpy==0.8.2"] } diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py index 35bd1edb84..96bced7086 100644 --- a/homeassistant/components/screenlogic/switch.py +++ b/homeassistant/components/screenlogic/switch.py @@ -1,7 +1,12 @@ """Support for a ScreenLogic 'circuit' switch.""" import logging -from screenlogicpy.const import CODE, DATA as SL_DATA, GENERIC_CIRCUIT_NAMES +from screenlogicpy.const import ( + CODE, + DATA as SL_DATA, + GENERIC_CIRCUIT_NAMES, + INTERFACE_GROUP, +) from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -31,7 +36,8 @@ async def async_setup_entry( coordinator, circuit_num, CODE.STATUS_CHANGED, - circuit["name"] not in GENERIC_CIRCUIT_NAMES, + circuit["name"] not in GENERIC_CIRCUIT_NAMES + and circuit["interface"] != INTERFACE_GROUP.DONT_SHOW, ) for circuit_num, circuit in circuits.items() if circuit["function"] not in LIGHT_CIRCUIT_FUNCTIONS 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 2d39b6ac63..b9624f16a3 100644 --- a/homeassistant/components/script/strings.json +++ b/homeassistant/components/script/strings.json @@ -1,9 +1,35 @@ { "title": "Script", - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "name": "[%key:component::script::title%]", + "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/search/__init__.py b/homeassistant/components/search/__init__.py index 70702f351f..b574081d5d 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components import automation, group, person, script, websocket_api from homeassistant.components.homeassistant import scene from homeassistant.core import HomeAssistant, callback, split_entity_id -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import entity_sources as get_entity_sources from homeassistant.helpers.typing import ConfigType @@ -53,8 +53,8 @@ def websocket_search_related( """Handle search.""" searcher = Searcher( hass, - device_registry.async_get(hass), - entity_registry.async_get(hass), + dr.async_get(hass), + er.async_get(hass), get_entity_sources(hass), ) connection.send_result( @@ -86,8 +86,8 @@ class Searcher: def __init__( self, hass: HomeAssistant, - device_reg: device_registry.DeviceRegistry, - entity_reg: entity_registry.EntityRegistry, + device_reg: dr.DeviceRegistry, + entity_reg: er.EntityRegistry, entity_sources: dict[str, dict[str, str]], ) -> None: """Search results.""" @@ -141,12 +141,10 @@ class Searcher: @callback def _resolve_area(self, area_id) -> None: """Resolve an area.""" - for device in device_registry.async_entries_for_area(self._device_reg, area_id): + for device in dr.async_entries_for_area(self._device_reg, area_id): self._add_or_resolve("device", device.id) - for entity_entry in entity_registry.async_entries_for_area( - self._entity_reg, area_id - ): + for entity_entry in er.async_entries_for_area(self._entity_reg, area_id): self._add_or_resolve("entity", entity_entry.entity_id) for entity_id in script.scripts_with_area(self.hass, area_id): @@ -178,12 +176,12 @@ class Searcher: Will only be called if config entry is an entry point. """ - for device_entry in device_registry.async_entries_for_config_entry( + for device_entry in dr.async_entries_for_config_entry( self._device_reg, config_entry_id ): self._add_or_resolve("device", device_entry.id) - for entity_entry in entity_registry.async_entries_for_config_entry( + for entity_entry in er.async_entries_for_config_entry( self._entity_reg, config_entry_id ): self._add_or_resolve("entity", entity_entry.entity_id) @@ -203,9 +201,7 @@ class Searcher: # We do not resolve device_entry.via_device_id because that # device is not related data-wise inside HA. - for entity_entry in entity_registry.async_entries_for_device( - self._entity_reg, device_id - ): + for entity_entry in er.async_entries_for_device(self._entity_reg, device_id): self._add_or_resolve("entity", entity_entry.entity_id) for entity_id in script.scripts_with_device(self.hass, device_id): diff --git a/homeassistant/components/season/config_flow.py b/homeassistant/components/season/config_flow.py index 854c015843..39a52e57b1 100644 --- a/homeassistant/components/season/config_flow.py +++ b/homeassistant/components/season/config_flow.py @@ -6,7 +6,7 @@ from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_TYPE from homeassistant.data_entry_flow import FlowResult from .const import DEFAULT_NAME, DOMAIN, TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL @@ -25,7 +25,7 @@ class SeasonConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(user_input[CONF_TYPE]) self._abort_if_unique_id_configured() return self.async_create_entry( - title=user_input.get(CONF_NAME, DEFAULT_NAME), + title=DEFAULT_NAME, data={CONF_TYPE: user_input[CONF_TYPE]}, ) @@ -42,7 +42,3 @@ class SeasonConfigFlow(ConfigFlow, domain=DOMAIN): }, ), ) - - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle import from configuration.yaml.""" - return await self.async_step_user(user_input) diff --git a/homeassistant/components/season/sensor.py b/homeassistant/components/season/sensor.py index a568e51ed9..27a46943bb 100644 --- a/homeassistant/components/season/sensor.py +++ b/homeassistant/components/season/sensor.py @@ -4,25 +4,17 @@ from __future__ import annotations from datetime import date, datetime import ephem -import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TYPE 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.dt import utcnow -from .const import DEFAULT_NAME, DOMAIN, TYPE_ASTRONOMICAL, VALID_TYPES +from .const import DOMAIN, TYPE_ASTRONOMICAL EQUATOR = "equator" @@ -49,39 +41,6 @@ SEASON_ICONS = { } -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In(VALID_TYPES), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the season sensor platform.""" - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - 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, @@ -144,7 +103,7 @@ class SeasonSensorEntity(SensorEntity): self.hemisphere = hemisphere self.type = entry.data[CONF_TYPE] self._attr_device_info = DeviceInfo( - name=entry.title, + name="Season", identifiers={(DOMAIN, entry.entry_id)}, entry_type=DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/select/device_action.py b/homeassistant/components/select/device_action.py index ce1cea89c9..d553cdf304 100644 --- a/homeassistant/components/select/device_action.py +++ b/homeassistant/components/select/device_action.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_capability from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -74,7 +74,7 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Select devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ { CONF_DEVICE_ID: device_id, @@ -89,7 +89,7 @@ async def async_get_actions( SERVICE_SELECT_OPTION, SERVICE_SELECT_PREVIOUS, ) - for entry in entity_registry.async_entries_for_device(registry, device_id) + for entry in er.async_entries_for_device(registry, device_id) if entry.domain == DOMAIN ] diff --git a/homeassistant/components/select/device_condition.py b/homeassistant/components/select/device_condition.py index 6e6a3c704b..13280ba4f0 100644 --- a/homeassistant/components/select/device_condition.py +++ b/homeassistant/components/select/device_condition.py @@ -13,7 +13,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_capability from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -38,7 +42,7 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Select devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ { CONF_CONDITION: "device", @@ -47,7 +51,7 @@ async def async_get_conditions( CONF_ENTITY_ID: entry.entity_id, CONF_TYPE: "selected_option", } - for entry in entity_registry.async_entries_for_device(registry, device_id) + for entry in er.async_entries_for_device(registry, device_id) if entry.domain == DOMAIN ] diff --git a/homeassistant/components/select/device_trigger.py b/homeassistant/components/select/device_trigger.py index 897ed855a5..8e8267cb5e 100644 --- a/homeassistant/components/select/device_trigger.py +++ b/homeassistant/components/select/device_trigger.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_capability from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -44,7 +44,7 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Select devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ { CONF_PLATFORM: "device", @@ -53,7 +53,7 @@ async def async_get_triggers( CONF_ENTITY_ID: entry.entity_id, CONF_TYPE: "current_option_changed", } - for entry in entity_registry.async_entries_for_device(registry, device_id) + for entry in er.async_entries_for_device(registry, device_id) if entry.domain == DOMAIN ] diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index 11a4ba9517..9080b940b2 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -14,5 +14,15 @@ "condition_type": { "selected_option": "Current {entity_name} selected option" } + }, + "entity_component": { + "_": { + "name": "[%key:component::select::title%]", + "state_attributes": { + "options": { + "name": "Options" + } + } + } } } diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py index 049b86e106..cfe1a12a24 100644 --- a/homeassistant/components/sense/const.py +++ b/homeassistant/components/sense/const.py @@ -10,7 +10,7 @@ from sense_energy import ( ) DOMAIN = "sense" -DEFAULT_TIMEOUT = 10 +DEFAULT_TIMEOUT = 30 ACTIVE_UPDATE_RATE = 60 DEFAULT_NAME = "Sense" SENSE_DATA = "sense_data" diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 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/sense/sensor.py b/homeassistant/components/sense/sensor.py index 9a3bb8bc3f..d6679d80f6 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -210,6 +210,7 @@ class SenseVoltageSensor(SensorEntity): """Implementation of a Sense energy voltage sensor.""" _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT _attr_attribution = ATTRIBUTION _attr_should_poll = False diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index 729299bb8f..f99792f7dc 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -15,5 +15,5 @@ "iot_class": "cloud_polling", "loggers": ["pysensibo"], "quality_scale": "platinum", - "requirements": ["pysensibo==1.0.25"] + "requirements": ["pysensibo==1.0.28"] } diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 75c37ab7b7..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 @@ -196,19 +195,30 @@ class SensorEntity(Entity): if self.unique_id is None or self.device_class is None: return registry = er.async_get(self.hass) + + # Bail out if the entity is not yet registered if not ( entity_id := registry.async_get_entity_id( platform.domain, platform.platform_name, self.unique_id ) ): + # Prime _sensor_option_unit_of_measurement to ensure the correct unit + # is stored in the entity registry. + self._sensor_option_unit_of_measurement = self._get_initial_suggested_unit() return + registry_entry = registry.async_get(entity_id) assert registry_entry + # Prime _sensor_option_unit_of_measurement to ensure the correct unit + # is stored in the entity registry. + self.registry_entry = registry_entry + self._async_read_entity_options() + # If the sensor has 'unit_of_measurement' in its sensor options, the user has # overridden the unit. - # If the sensor has 'sensor.private' in its entity options, it was added after - # automatic unit conversion was implemented. + # If the sensor has 'sensor.private' in its entity options, it already has a + # suggested_unit. registry_unit = registry_entry.unit_of_measurement if ( ( @@ -230,11 +240,14 @@ class SensorEntity(Entity): # Set suggested_unit_of_measurement to the old unit to enable automatic # conversion - registry.async_update_entity_options( + self.registry_entry = registry.async_update_entity_options( entity_id, f"{DOMAIN}.private", {"suggested_unit_of_measurement": registry_unit}, ) + # Update _sensor_option_unit_of_measurement to ensure the correct unit + # is stored in the entity registry. + self._async_read_entity_options() async def async_internal_added_to_hass(self) -> None: """Call when the sensor entity is added to hass.""" @@ -257,15 +270,20 @@ class SensorEntity(Entity): @property def _numeric_state_expected(self) -> bool: """Return true if the sensor must be numeric.""" + # Note: the order of the checks needs to be kept aligned + # with the checks in `state` property. + device_class = try_parse_enum(SensorDeviceClass, self.device_class) + if device_class in NON_NUMERIC_DEVICE_CLASSES: + return False if ( self.state_class is not None or self.native_unit_of_measurement is not None or self.suggested_display_precision is not None ): return True - # Sensors with custom device classes are not considered numeric - device_class = try_parse_enum(SensorDeviceClass, self.device_class) - return device_class not in {None, *NON_NUMERIC_DEVICE_CLASSES} + # Sensors with custom device classes will have the device class + # converted to None and are not considered numeric + return device_class is not None @property def options(self) -> list[str] | None: @@ -305,12 +323,8 @@ class SensorEntity(Entity): return None - def get_initial_entity_options(self) -> er.EntityOptionsType | None: - """Return initial entity options. - - These will be stored in the entity registry the first time the entity is seen, - and then never updated. - """ + def _get_initial_suggested_unit(self) -> str | UndefinedType: + """Return the initial unit.""" # Unit suggested by the integration suggested_unit_of_measurement = self.suggested_unit_of_measurement @@ -321,6 +335,19 @@ class SensorEntity(Entity): ) if suggested_unit_of_measurement is None: + return UNDEFINED + + return suggested_unit_of_measurement + + def get_initial_entity_options(self) -> er.EntityOptionsType | None: + """Return initial entity options. + + These will be stored in the entity registry the first time the entity is seen, + and then never updated. + """ + suggested_unit_of_measurement = self._get_initial_suggested_unit() + + if suggested_unit_of_measurement is UNDEFINED: return None return { @@ -416,7 +443,7 @@ class SensorEntity(Entity): return self._sensor_option_unit_of_measurement # Second priority, for non registered entities: unit suggested by integration - if not self.registry_entry and self.suggested_unit_of_measurement: + if not self.unique_id and self.suggested_unit_of_measurement: return self.suggested_unit_of_measurement # Third priority: Legacy temperature conversion, which applies @@ -435,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 @@ -553,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 @@ -709,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 58cf985b09..17155912e4 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.backports.enum import StrEnum from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, @@ -75,7 +76,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" @@ -160,6 +161,17 @@ class SensorDeviceClass(StrEnum): ENERGY = "energy" """Energy. + Use this device class for sensors measuring energy consumption, for example + electric energy consumption. + Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + """ + + ENERGY_STORAGE = "energy_storage" + """Stored energy. + + Use this device class for sensors measuring stored energy, for example the amount + of electric energy currently stored in a battery or the capacity of a battery. + Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` """ @@ -336,6 +348,12 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `µg/m³` """ + VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" + """Ratio of VOC. + + Unit of measurement: `ppm`, `ppb` + """ + VOLTAGE = "voltage" """Voltage. @@ -351,6 +369,18 @@ class SensorDeviceClass(StrEnum): USCS/imperial units are currently assumed to be US volumes) """ + VOLUME_STORAGE = "volume_storage" + """Generic stored volume. + + Use this device class for sensors measuring stored volume, for example the amount + of fuel in a fuel tank. + + Unit of measurement: `VOLUME_*` units + - SI / metric: `mL`, `L`, `m³` + - USCS / imperial: `ft³`, `CCF`, `fl. oz.`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be US volumes) + """ + WATER = "water" """Water. @@ -420,8 +450,6 @@ STATE_CLASS_TOTAL: Final = "total" STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] -# Note: this needs to be aligned with frontend: OVERRIDE_SENSOR_UNITS in -# `entity-registry-settings.ts` UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.CURRENT: ElectricCurrentConverter, @@ -429,6 +457,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.DATA_SIZE: InformationConverter, SensorDeviceClass.DISTANCE: DistanceConverter, SensorDeviceClass.ENERGY: EnergyConverter, + SensorDeviceClass.ENERGY_STORAGE: EnergyConverter, SensorDeviceClass.GAS: VolumeConverter, SensorDeviceClass.POWER: PowerConverter, SensorDeviceClass.POWER_FACTOR: UnitlessRatioConverter, @@ -437,8 +466,10 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.PRESSURE: PressureConverter, SensorDeviceClass.SPEED: SpeedConverter, SensorDeviceClass.TEMPERATURE: TemperatureConverter, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: UnitlessRatioConverter, SensorDeviceClass.VOLTAGE: ElectricPotentialConverter, SensorDeviceClass.VOLUME: VolumeConverter, + SensorDeviceClass.VOLUME_STORAGE: VolumeConverter, SensorDeviceClass.WATER: VolumeConverter, SensorDeviceClass.WEIGHT: MassConverter, SensorDeviceClass.WIND_SPEED: SpeedConverter, @@ -460,8 +491,10 @@ 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), SensorDeviceClass.FREQUENCY: set(UnitOfFrequency), SensorDeviceClass.GAS: { UnitOfVolume.CENTUM_CUBIC_FEET, @@ -496,6 +529,10 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: { CONCENTRATION_MICROGRAMS_PER_CUBIC_METER }, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: { + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + }, SensorDeviceClass.VOLTAGE: set(UnitOfElectricPotential), SensorDeviceClass.VOLUME: set(UnitOfVolume), SensorDeviceClass.WATER: { @@ -526,6 +563,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, + SensorDeviceClass.ENERGY_STORAGE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.ENUM: set(), SensorDeviceClass.FREQUENCY: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.GAS: {SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING}, @@ -554,11 +592,13 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorDeviceClass.TEMPERATURE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.TIMESTAMP: set(), SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.VOLTAGE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.VOLUME: { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, + SensorDeviceClass.VOLUME_STORAGE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.WATER: { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index ded2eed6dd..c52e076e51 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -32,6 +32,7 @@ from . import ATTR_STATE_CLASS, DOMAIN, SensorDeviceClass DEVICE_CLASS_NONE = "none" CONF_IS_APPARENT_POWER = "is_apparent_power" +CONF_IS_AQI = "is_aqi" CONF_IS_ATMOSPHERIC_PRESSURE = "is_atmospheric_pressure" CONF_IS_BATTERY_LEVEL = "is_battery_level" CONF_IS_CO = "is_carbon_monoxide" @@ -40,6 +41,7 @@ CONF_IS_CURRENT = "is_current" CONF_IS_DATA_RATE = "is_data_rate" CONF_IS_DATA_SIZE = "is_data_size" CONF_IS_DISTANCE = "is_distance" +CONF_IS_DURATION = "is_duration" CONF_IS_ENERGY = "is_energy" CONF_IS_FREQUENCY = "is_frequency" CONF_IS_HUMIDITY = "is_humidity" @@ -47,6 +49,7 @@ CONF_IS_GAS = "is_gas" CONF_IS_ILLUMINANCE = "is_illuminance" CONF_IS_IRRADIANCE = "is_irradiance" CONF_IS_MOISTURE = "is_moisture" +CONF_IS_MONETARY = "is_monetary" CONF_IS_NITROGEN_DIOXIDE = "is_nitrogen_dioxide" CONF_IS_NITROGEN_MONOXIDE = "is_nitrogen_monoxide" CONF_IS_NITROUS_OXIDE = "is_nitrous_oxide" @@ -67,6 +70,7 @@ CONF_IS_SULPHUR_DIOXIDE = "is_sulphur_dioxide" CONF_IS_TEMPERATURE = "is_temperature" CONF_IS_VALUE = "is_value" CONF_IS_VOLATILE_ORGANIC_COMPOUNDS = "is_volatile_organic_compounds" +CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "is_volatile_organic_compounds_parts" CONF_IS_VOLTAGE = "is_voltage" CONF_IS_VOLUME = "is_volume" CONF_IS_WATER = "is_water" @@ -75,6 +79,7 @@ CONF_IS_WIND_SPEED = "is_wind_speed" ENTITY_CONDITIONS = { SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_IS_APPARENT_POWER}], + SensorDeviceClass.AQI: [{CONF_TYPE: CONF_IS_AQI}], SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_IS_ATMOSPHERIC_PRESSURE}], SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_IS_BATTERY_LEVEL}], SensorDeviceClass.CO: [{CONF_TYPE: CONF_IS_CO}], @@ -83,13 +88,16 @@ ENTITY_CONDITIONS = { SensorDeviceClass.DATA_RATE: [{CONF_TYPE: CONF_IS_DATA_RATE}], SensorDeviceClass.DATA_SIZE: [{CONF_TYPE: CONF_IS_DATA_SIZE}], SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_IS_DISTANCE}], + SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_IS_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_IS_ENERGY}], + SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_IS_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_IS_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_IS_GAS}], SensorDeviceClass.HUMIDITY: [{CONF_TYPE: CONF_IS_HUMIDITY}], SensorDeviceClass.ILLUMINANCE: [{CONF_TYPE: CONF_IS_ILLUMINANCE}], SensorDeviceClass.IRRADIANCE: [{CONF_TYPE: CONF_IS_IRRADIANCE}], SensorDeviceClass.MOISTURE: [{CONF_TYPE: CONF_IS_MOISTURE}], + SensorDeviceClass.MONETARY: [{CONF_TYPE: CONF_IS_MONETARY}], SensorDeviceClass.NITROGEN_DIOXIDE: [{CONF_TYPE: CONF_IS_NITROGEN_DIOXIDE}], SensorDeviceClass.NITROGEN_MONOXIDE: [{CONF_TYPE: CONF_IS_NITROGEN_MONOXIDE}], SensorDeviceClass.NITROUS_OXIDE: [{CONF_TYPE: CONF_IS_NITROUS_OXIDE}], @@ -113,8 +121,12 @@ ENTITY_CONDITIONS = { SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: [ {CONF_TYPE: CONF_IS_VOLATILE_ORGANIC_COMPOUNDS} ], + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: [ + {CONF_TYPE: CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS} + ], SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_IS_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_IS_VOLUME}], + SensorDeviceClass.VOLUME_STORAGE: [{CONF_TYPE: CONF_IS_VOLUME}], SensorDeviceClass.WATER: [{CONF_TYPE: CONF_IS_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_IS_WEIGHT}], SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_IS_WIND_SPEED}], @@ -128,6 +140,7 @@ CONDITION_SCHEMA = vol.All( vol.Required(CONF_TYPE): vol.In( [ CONF_IS_APPARENT_POWER, + CONF_IS_AQI, CONF_IS_ATMOSPHERIC_PRESSURE, CONF_IS_BATTERY_LEVEL, CONF_IS_CO, @@ -136,6 +149,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_DATA_RATE, CONF_IS_DATA_SIZE, CONF_IS_DISTANCE, + CONF_IS_DURATION, CONF_IS_ENERGY, CONF_IS_FREQUENCY, CONF_IS_GAS, @@ -143,6 +157,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_ILLUMINANCE, CONF_IS_IRRADIANCE, CONF_IS_MOISTURE, + CONF_IS_MONETARY, CONF_IS_NITROGEN_DIOXIDE, CONF_IS_NITROGEN_MONOXIDE, CONF_IS_NITROUS_OXIDE, @@ -162,6 +177,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_SULPHUR_DIOXIDE, CONF_IS_TEMPERATURE, CONF_IS_VOLATILE_ORGANIC_COMPOUNDS, + CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, CONF_IS_VOLTAGE, CONF_IS_VOLUME, CONF_IS_WATER, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 721df69cc5..0d594e1b7c 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -31,6 +31,7 @@ from . import ATTR_STATE_CLASS, DOMAIN, SensorDeviceClass DEVICE_CLASS_NONE = "none" CONF_APPARENT_POWER = "apparent_power" +CONF_AQI = "aqi" CONF_ATMOSPHERIC_PRESSURE = "atmospheric_pressure" CONF_BATTERY_LEVEL = "battery_level" CONF_CO = "carbon_monoxide" @@ -39,6 +40,7 @@ CONF_CURRENT = "current" CONF_DATA_RATE = "data_rate" CONF_DATA_SIZE = "data_size" CONF_DISTANCE = "distance" +CONF_DURATION = "duration" CONF_ENERGY = "energy" CONF_FREQUENCY = "frequency" CONF_GAS = "gas" @@ -46,6 +48,7 @@ CONF_HUMIDITY = "humidity" CONF_ILLUMINANCE = "illuminance" CONF_IRRADIANCE = "irradiance" CONF_MOISTURE = "moisture" +CONF_MONETARY = "monetary" CONF_NITROGEN_DIOXIDE = "nitrogen_dioxide" CONF_NITROGEN_MONOXIDE = "nitrogen_monoxide" CONF_NITROUS_OXIDE = "nitrous_oxide" @@ -66,6 +69,7 @@ CONF_SULPHUR_DIOXIDE = "sulphur_dioxide" CONF_TEMPERATURE = "temperature" CONF_VALUE = "value" CONF_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" +CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" CONF_VOLTAGE = "voltage" CONF_VOLUME = "volume" CONF_WATER = "water" @@ -74,6 +78,7 @@ CONF_WIND_SPEED = "wind_speed" ENTITY_TRIGGERS = { SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_APPARENT_POWER}], + SensorDeviceClass.AQI: [{CONF_TYPE: CONF_AQI}], SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_ATMOSPHERIC_PRESSURE}], SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_BATTERY_LEVEL}], SensorDeviceClass.CO: [{CONF_TYPE: CONF_CO}], @@ -82,13 +87,16 @@ ENTITY_TRIGGERS = { SensorDeviceClass.DATA_RATE: [{CONF_TYPE: CONF_DATA_RATE}], SensorDeviceClass.DATA_SIZE: [{CONF_TYPE: CONF_DATA_SIZE}], SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_DISTANCE}], + SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_ENERGY}], + SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_GAS}], SensorDeviceClass.HUMIDITY: [{CONF_TYPE: CONF_HUMIDITY}], SensorDeviceClass.ILLUMINANCE: [{CONF_TYPE: CONF_ILLUMINANCE}], SensorDeviceClass.IRRADIANCE: [{CONF_TYPE: CONF_IRRADIANCE}], SensorDeviceClass.MOISTURE: [{CONF_TYPE: CONF_MOISTURE}], + SensorDeviceClass.MONETARY: [{CONF_TYPE: CONF_MONETARY}], SensorDeviceClass.NITROGEN_DIOXIDE: [{CONF_TYPE: CONF_NITROGEN_DIOXIDE}], SensorDeviceClass.NITROGEN_MONOXIDE: [{CONF_TYPE: CONF_NITROGEN_MONOXIDE}], SensorDeviceClass.NITROUS_OXIDE: [{CONF_TYPE: CONF_NITROUS_OXIDE}], @@ -112,8 +120,12 @@ ENTITY_TRIGGERS = { SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: [ {CONF_TYPE: CONF_VOLATILE_ORGANIC_COMPOUNDS} ], + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: [ + {CONF_TYPE: CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS} + ], SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_VOLUME}], + SensorDeviceClass.VOLUME_STORAGE: [{CONF_TYPE: CONF_VOLUME}], SensorDeviceClass.WATER: [{CONF_TYPE: CONF_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_WEIGHT}], SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_WIND_SPEED}], @@ -128,6 +140,7 @@ TRIGGER_SCHEMA = vol.All( vol.Required(CONF_TYPE): vol.In( [ CONF_APPARENT_POWER, + CONF_AQI, CONF_ATMOSPHERIC_PRESSURE, CONF_BATTERY_LEVEL, CONF_CO, @@ -136,6 +149,7 @@ TRIGGER_SCHEMA = vol.All( CONF_DATA_RATE, CONF_DATA_SIZE, CONF_DISTANCE, + CONF_DURATION, CONF_ENERGY, CONF_FREQUENCY, CONF_GAS, @@ -143,6 +157,7 @@ TRIGGER_SCHEMA = vol.All( CONF_ILLUMINANCE, CONF_IRRADIANCE, CONF_MOISTURE, + CONF_MONETARY, CONF_NITROGEN_DIOXIDE, CONF_NITROGEN_MONOXIDE, CONF_NITROUS_OXIDE, @@ -162,6 +177,7 @@ TRIGGER_SCHEMA = vol.All( CONF_SULPHUR_DIOXIDE, CONF_TEMPERATURE, CONF_VOLATILE_ORGANIC_COMPOUNDS, + CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS, CONF_VOLTAGE, CONF_VOLUME, CONF_WATER, diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 7f88945997..f9fdc25253 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -119,7 +119,16 @@ def _time_weighted_average( duration = end - old_start_time accumulated += old_fstate * duration.total_seconds() - return accumulated / (end - start).total_seconds() + period_seconds = (end - start).total_seconds() + if period_seconds == 0: + # If the only state changed that happened was at the exact moment + # at the end of the period, we can't calculate a meaningful average + # so we return 0.0 since it represents a time duration smaller than + # we can measure. This probably means the precision of statistics + # column schema in the database is incorrect but it is actually possible + # to happen if the state change event fired at the exact microsecond + return 0.0 + return accumulated / period_seconds def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: @@ -145,31 +154,36 @@ def _parse_float(state: str) -> float: return fstate +def _float_or_none(state: str) -> float | None: + """Return a float or None.""" + try: + return _parse_float(state) + except (ValueError, TypeError): + return None + + +def _entity_history_to_float_and_state( + entity_history: Iterable[State], +) -> list[tuple[float, State]]: + """Return a list of (float, state) tuples for the given entity.""" + return [ + (fstate, state) + for state in entity_history + if (fstate := _float_or_none(state.state)) is not None + ] + + def _normalize_states( hass: HomeAssistant, - session: Session, old_metadatas: dict[str, tuple[int, StatisticMetaData]], - entity_history: Iterable[State], + fstates: list[tuple[float, State]], entity_id: str, ) -> tuple[str | None, list[tuple[float, State]]]: """Normalize units.""" - old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None state_unit: str | None = None - - fstates: list[tuple[float, State]] = [] - for state in entity_history: - try: - fstate = _parse_float(state.state) - except (ValueError, TypeError): # TypeError to guard for NULL state in DB - continue - fstates.append((fstate, state)) - - if not fstates: - return None, fstates - - state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) - statistics_unit: str | None + state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) + old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None if not old_metadata: # We've not seen this sensor before, the first valid state determines the unit # used for statistics @@ -379,7 +393,15 @@ def compile_statistics( Note: This will query the database and must not be run in the event loop """ - with recorder_util.session_scope(hass=hass) as session: + # There is already an active session when this code is called since + # it is called from the recorder statistics. We need to make sure + # this session never gets committed since it would be out of sync + # with the recorder statistics session so we mark it as read only. + # + # If we ever need to write to the database from this function we + # will need to refactor the recorder statistics to use a single + # session. + with recorder_util.session_scope(hass=hass, read_only=True) as session: compiled = _compile_statistics(hass, session, start, end) return compiled @@ -395,10 +417,6 @@ def _compile_statistics( # noqa: C901 sensor_states = _get_sensor_states(hass) wanted_statistics = _wanted_statistics(sensor_states) - old_metadatas = statistics.get_metadata_with_session( - session, statistic_ids=[i.entity_id for i in sensor_states] - ) - # Get history between start and end entities_full_history = [ i.entity_id for i in sensor_states if "sum" in wanted_statistics[i.entity_id] @@ -427,36 +445,43 @@ def _compile_statistics( # noqa: C901 entity_ids=entities_significant_history, ) history_list = {**history_list, **_history_list} - # If there are no recent state changes, the sensor's state may already be pruned - # from the recorder. Get the state from the state machine instead. - for _state in sensor_states: - if _state.entity_id not in history_list: - history_list[_state.entity_id] = [_state] - to_process = [] - to_query = [] + entities_with_float_states: dict[str, list[tuple[float, State]]] = {} for _state in sensor_states: entity_id = _state.entity_id - if entity_id not in history_list: + # If there are no recent state changes, the sensor's state may already be pruned + # from the recorder. Get the state from the state machine instead. + if not (entity_history := history_list.get(entity_id, [_state])): continue + if not (float_states := _entity_history_to_float_and_state(entity_history)): + continue + entities_with_float_states[entity_id] = float_states - entity_history = history_list[entity_id] - statistics_unit, fstates = _normalize_states( + # Only lookup metadata for entities that have valid float states + # since it will result in cache misses for statistic_ids + # that are not in the metadata table and we are not working + # with them anyway. + old_metadatas = statistics.get_metadata_with_session( + get_instance(hass), session, statistic_ids=set(entities_with_float_states) + ) + to_process: list[tuple[str, str | None, str, list[tuple[float, State]]]] = [] + to_query: set[str] = set() + for _state in sensor_states: + entity_id = _state.entity_id + if not (maybe_float_states := entities_with_float_states.get(entity_id)): + continue + statistics_unit, valid_float_states = _normalize_states( hass, - session, old_metadatas, - entity_history, + maybe_float_states, entity_id, ) - - if not fstates: + if not valid_float_states: continue - - state_class = _state.attributes[ATTR_STATE_CLASS] - - to_process.append((entity_id, statistics_unit, state_class, fstates)) + state_class: str = _state.attributes[ATTR_STATE_CLASS] + to_process.append((entity_id, statistics_unit, state_class, valid_float_states)) if "sum" in wanted_statistics[entity_id]: - to_query.append(entity_id) + to_query.add(entity_id) last_stats = statistics.get_latest_short_term_statistics( hass, to_query, {"last_reset", "state", "sum"}, metadata=old_metadatas @@ -465,7 +490,7 @@ def _compile_statistics( # noqa: C901 entity_id, statistics_unit, state_class, - fstates, + valid_float_states, ) in to_process: # Check metadata if old_metadata := old_metadatas.get(entity_id): @@ -507,20 +532,20 @@ def _compile_statistics( # noqa: C901 if "max" in wanted_statistics[entity_id]: stat["max"] = max( *itertools.islice( - zip(*fstates), # type: ignore[typeddict-item] + zip(*valid_float_states), # type: ignore[typeddict-item] 1, ) ) if "min" in wanted_statistics[entity_id]: stat["min"] = min( *itertools.islice( - zip(*fstates), # type: ignore[typeddict-item] + zip(*valid_float_states), # type: ignore[typeddict-item] 1, ) ) if "mean" in wanted_statistics[entity_id]: - stat["mean"] = _time_weighted_average(fstates, start, end) + stat["mean"] = _time_weighted_average(valid_float_states, start, end) if "sum" in wanted_statistics[entity_id]: last_reset = old_last_reset = None @@ -529,13 +554,16 @@ def _compile_statistics( # noqa: C901 if entity_id in last_stats: # We have compiled history for this sensor before, # use that as a starting point. - last_reset = old_last_reset = _timestamp_to_isoformat_or_none( - last_stats[entity_id][0]["last_reset"] - ) - new_state = old_state = last_stats[entity_id][0]["state"] - _sum = last_stats[entity_id][0]["sum"] or 0.0 + last_stat = last_stats[entity_id][0] + last_reset = _timestamp_to_isoformat_or_none(last_stat["last_reset"]) + old_last_reset = last_reset + # If there are no previous values and has_sum + # was previously false there will be no last_stat + # for state or sum + new_state = old_state = last_stat.get("state") + _sum = last_stat.get("sum") or 0.0 - for fstate, state in fstates: + for fstate, state in valid_float_states: reset = False if ( state_class != SensorStateClass.TOTAL_INCREASING @@ -588,15 +616,15 @@ def _compile_statistics( # noqa: C901 ), entity_id, new_state, - state.last_updated.isoformat(), fstate, + state.last_updated.isoformat(), ) except HomeAssistantError: continue if reset: # The sensor has been reset, update the sum - if old_state is not None: + if old_state is not None and new_state is not None: _sum += new_state - old_state # ..and update the starting point new_state = fstate diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 2d53612698..c4c1f81109 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -3,6 +3,7 @@ "device_automation": { "condition_type": { "is_apparent_power": "Current {entity_name} apparent power", + "is_aqi": "Current {entity_name} air quality index", "is_atmospheric_pressure": "Current {entity_name} atmospheric pressure", "is_battery_level": "Current {entity_name} battery level", "is_carbon_monoxide": "Current {entity_name} carbon monoxide concentration level", @@ -11,6 +12,7 @@ "is_data_rate": "Current {entity_name} data rate", "is_data_size": "Current {entity_name} data size", "is_distance": "Current {entity_name} distance", + "is_duration": "Current {entity_name} duration", "is_energy": "Current {entity_name} energy", "is_frequency": "Current {entity_name} frequency", "is_gas": "Current {entity_name} gas", @@ -18,6 +20,7 @@ "is_illuminance": "Current {entity_name} illuminance", "is_irradiance": "Current {entity_name} irradiance", "is_moisture": "Current {entity_name} moisture", + "is_monetary": "Current {entity_name} money", "is_nitrogen_dioxide": "Current {entity_name} nitrogen dioxide concentration level", "is_nitrogen_monoxide": "Current {entity_name} nitrogen monoxide concentration level", "is_nitrous_oxide": "Current {entity_name} nitrous oxide concentration level", @@ -27,6 +30,8 @@ "is_pm25": "Current {entity_name} PM2.5 concentration level", "is_power": "Current {entity_name} power", "is_power_factor": "Current {entity_name} power factor", + "is_precipitation": "Current {entity_name} precipitation", + "is_precipitation_intensity": "Current {entity_name} precipitation intensity", "is_pressure": "Current {entity_name} pressure", "is_reactive_power": "Current {entity_name} reactive power", "is_signal_strength": "Current {entity_name} signal strength", @@ -36,20 +41,25 @@ "is_temperature": "Current {entity_name} temperature", "is_value": "Current {entity_name} value", "is_volatile_organic_compounds": "Current {entity_name} volatile organic compounds concentration level", + "is_volatile_organic_compounds_parts": "[%key:component::sensor::device_automation::condition_type::is_volatile_organic_compounds%]", "is_voltage": "Current {entity_name} voltage", "is_volume": "Current {entity_name} volume", "is_water": "Current {entity_name} water", - "is_weight": "Current {entity_name} weight" + "is_weight": "Current {entity_name} weight", + "is_wind_speed": "Current {entity_name} wind speed" }, "trigger_type": { "apparent_power": "{entity_name} apparent power changes", + "aqi": "{entity_name} air quality index changes", "atmospheric_pressure": "{entity_name} atmospheric pressure changes", "battery_level": "{entity_name} battery level changes", "carbon_monoxide": "{entity_name} carbon monoxide concentration changes", "carbon_dioxide": "{entity_name} carbon dioxide concentration changes", "current": "{entity_name} current changes", "data_rate": "{entity_name} data rate changes", + "data_size": "{entity_name} data size changes", "distance": "{entity_name} distance changes", + "duration": "{entity_name} duration changes", "energy": "{entity_name} energy changes", "frequency": "{entity_name} frequency changes", "gas": "{entity_name} gas changes", @@ -57,6 +67,7 @@ "illuminance": "{entity_name} illuminance changes", "irradiance": "{entity_name} irradiance changes", "moisture": "{entity_name} moisture changes", + "monetary": "{entity_name} money changes", "nitrogen_dioxide": "{entity_name} nitrogen dioxide concentration changes", "nitrogen_monoxide": "{entity_name} nitrogen monoxide concentration changes", "nitrous_oxide": "{entity_name} nitrous oxide concentration changes", @@ -66,6 +77,8 @@ "pm25": "{entity_name} PM2.5 concentration changes", "power": "{entity_name} power changes", "power_factor": "{entity_name} power factor changes", + "precipitation": "{entity_name} precipitation changes", + "precipitation_intensity": "{entity_name} precipitation intensity changes", "pressure": "{entity_name} pressure changes", "reactive_power": "{entity_name} reactive power changes", "signal_strength": "{entity_name} signal strength changes", @@ -75,16 +88,184 @@ "temperature": "{entity_name} temperature changes", "value": "{entity_name} value changes", "volatile_organic_compounds": "{entity_name} volatile organic compounds concentration changes", + "volatile_organic_compounds_parts": "[%key:component::sensor::device_automation::trigger_type::volatile_organic_compounds%]", "voltage": "{entity_name} voltage changes", "volume": "{entity_name} volume changes", "water": "{entity_name} water changes", - "weight": "{entity_name} weight changes" + "weight": "{entity_name} weight changes", + "wind_speed": "{entity_name} wind speed changes" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "name": "[%key:component::sensor::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "last_reset": { + "name": "Last reset" + }, + "options": { + "name": "Possible states" + }, + "state_class": { + "name": "State class", + "state": { + "measurement": "Measurement", + "total": "Total", + "total_increasing": "Total increasing" + } + } + } + }, + "date": { + "name": "Date" + }, + "duration": { + "name": "Duration" + }, + "apparent_power": { + "name": "Apparent power" + }, + "aqi": { + "name": "Air quality index" + }, + "atmospheric_pressure": { + "name": "Atmospheric pressure" + }, + "battery": { + "name": "Battery" + }, + "carbon_monoxide": { + "name": "Carbon monoxide" + }, + "carbon_dioxide": { + "name": "Carbon dioxide" + }, + "current": { + "name": "Current" + }, + "data_rate": { + "name": "Data rate" + }, + "data_size": { + "name": "Data size" + }, + "distance": { + "name": "Distance" + }, + "energy": { + "name": "Energy" + }, + "energy_storage": { + "name": "Stored energy" + }, + "enum": { + "name": "[%key:component::sensor::title%]" + }, + "frequency": { + "name": "Frequency" + }, + "gas": { + "name": "Gas" + }, + "humidity": { + "name": "Humidity" + }, + "illuminance": { + "name": "Illuminance" + }, + "irradiance": { + "name": "Irradiance" + }, + "moisture": { + "name": "Moisture" + }, + "monetary": { + "name": "Balance" + }, + "nitrogen_dioxide": { + "name": "Nitrogen dioxide" + }, + "nitrogen_monoxide": { + "name": "Nitrogen monoxide" + }, + "nitrous_oxide": { + "name": "Nitrous oxide" + }, + "ozone": { + "name": "Ozone" + }, + "pm1": { + "name": "PM1" + }, + "pm10": { + "name": "PM10" + }, + "pm25": { + "name": "PM2.5" + }, + "power_factor": { + "name": "Power factor" + }, + "power": { + "name": "Power" + }, + "precipitation": { + "name": "Precipitation" + }, + "precipitation_intensity": { + "name": "Precipitation intensity" + }, + "pressure": { + "name": "Pressure" + }, + "reactive_power": { + "name": "Reactive power" + }, + "signal_strength": { + "name": "Signal strength" + }, + "sound_pressure": { + "name": "Sound pressure" + }, + "speed": { + "name": "Speed" + }, + "sulphur_dioxide": { + "name": "Sulphur dioxide" + }, + "temperature": { + "name": "Temperature" + }, + "timestamp": { + "name": "Timestamp" + }, + "volatile_organic_compounds": { + "name": "VOCs" + }, + "volatile_organic_compounds_parts": { + "name": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]" + }, + "voltage": { + "name": "Voltage" + }, + "volume": { + "name": "Volume" + }, + "volume_storage": { + "name": "Stored volume" + }, + "water": { + "name": "Water" + }, + "weight": { + "name": "Weight" + }, + "wind_speed": { + "name": "Wind speed" } } } diff --git a/homeassistant/components/sensor/websocket_api.py b/homeassistant/components/sensor/websocket_api.py index 10699b8c1c..2457bfcabe 100644 --- a/homeassistant/components/sensor/websocket_api.py +++ b/homeassistant/components/sensor/websocket_api.py @@ -29,7 +29,10 @@ def ws_device_class_units( ) -> None: """Return supported units for a device class.""" device_class = msg["device_class"] - convertible_units = set() + convertible_units = [] if device_class in UNIT_CONVERTERS and device_class in DEVICE_CLASS_UNITS: - convertible_units = DEVICE_CLASS_UNITS[device_class] + convertible_units = sorted( + DEVICE_CLASS_UNITS[device_class], + key=lambda s: str.casefold(str(s)), + ) connection.send_result(msg["id"], {"units": convertible_units}) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 91da032099..8db94696e7 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.13.0"] + "requirements": ["sentry-sdk==1.21.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/__init__.py b/homeassistant/components/sfr_box/__init__.py index 07f122fa4b..564f1970b6 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -36,19 +36,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = DomainData( box=box, dsl=SFRDataUpdateCoordinator(hass, box, "dsl", lambda b: b.dsl_get_info()), + ftth=SFRDataUpdateCoordinator(hass, box, "ftth", lambda b: b.ftth_get_info()), system=SFRDataUpdateCoordinator( hass, box, "system", lambda b: b.system_get_info() ), + wan=SFRDataUpdateCoordinator(hass, box, "wan", lambda b: b.wan_get_info()), ) - tasks = [ - data.dsl.async_config_entry_first_refresh(), - data.system.async_config_entry_first_refresh(), - ] + # Preload system information + await data.system.async_config_entry_first_refresh() + system_info = data.system.data + + # Preload other coordinators (based on net infrastructure) + tasks = [data.wan.async_config_entry_first_refresh()] + if (net_infra := system_info.net_infra) == "adsl": + tasks.append(data.dsl.async_config_entry_first_refresh()) + elif net_infra == "ftth": + tasks.append(data.ftth.async_config_entry_first_refresh()) await asyncio.gather(*tasks) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data - system_info = data.system.data device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index d90c1944aa..e4d41fb0cb 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Generic, TypeVar -from sfrbox_api.models import DslInfo, SystemInfo +from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, WanInfo from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -42,10 +42,28 @@ class SFRBoxBinarySensorEntityDescription( DSL_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[DslInfo], ...] = ( SFRBoxBinarySensorEntityDescription[DslInfo]( key="status", - name="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", + 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", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda x: x.status == "up", + translation_key="wan_status", ), ) @@ -56,10 +74,20 @@ async def async_setup_entry( """Set up the sensors.""" data: DomainData = hass.data[DOMAIN][entry.entry_id] - entities = [ - SFRBoxBinarySensor(data.dsl, description, data.system.data) - for description in DSL_SENSOR_TYPES + entities: list[SFRBoxBinarySensor] = [ + SFRBoxBinarySensor(data.wan, description, data.system.data) + for description in WAN_SENSOR_TYPES ] + if (net_infra := data.system.data.net_infra) == "adsl": + entities.extend( + SFRBoxBinarySensor(data.dsl, description, data.system.data) + for description in DSL_SENSOR_TYPES + ) + elif net_infra == "ftth": + entities.extend( + SFRBoxBinarySensor(data.ftth, description, data.system.data) + for description in FTTH_SENSOR_TYPES + ) async_add_entities(entities) 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/diagnostics.py b/homeassistant/components/sfr_box/diagnostics.py index 6a7ceb0e86..1fb9805326 100644 --- a/homeassistant/components/sfr_box/diagnostics.py +++ b/homeassistant/components/sfr_box/diagnostics.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .models import DomainData -TO_REDACT = {"mac_addr", "serial_number"} +TO_REDACT = {"mac_addr", "serial_number", "ip_addr", "ipv6_addr"} async def async_get_config_entry_diagnostics( @@ -26,9 +26,17 @@ async def async_get_config_entry_diagnostics( "data": dict(entry.data), }, "data": { - "dsl": async_redact_data(dataclasses.asdict(data.dsl.data), TO_REDACT), + "dsl": async_redact_data( + dataclasses.asdict(await data.system.box.dsl_get_info()), TO_REDACT + ), + "ftth": async_redact_data( + dataclasses.asdict(await data.system.box.ftth_get_info()), TO_REDACT + ), "system": async_redact_data( - dataclasses.asdict(data.system.data), TO_REDACT + dataclasses.asdict(await data.system.box.system_get_info()), TO_REDACT + ), + "wan": async_redact_data( + dataclasses.asdict(await data.system.box.wan_get_info()), TO_REDACT ), }, } diff --git a/homeassistant/components/sfr_box/models.py b/homeassistant/components/sfr_box/models.py index e2f86aeb92..ff723c2c6e 100644 --- a/homeassistant/components/sfr_box/models.py +++ b/homeassistant/components/sfr_box/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from sfrbox_api.bridge import SFRBox -from sfrbox_api.models import DslInfo, SystemInfo +from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, WanInfo from .coordinator import SFRDataUpdateCoordinator @@ -13,4 +13,6 @@ class DomainData: box: SFRBox dsl: SFRDataUpdateCoordinator[DslInfo] + ftth: SFRDataUpdateCoordinator[FtthInfo] system: SFRDataUpdateCoordinator[SystemInfo] + wan: SFRDataUpdateCoordinator[WanInfo] diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index f84441d249..19512f4382 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -1,10 +1,9 @@ """SFR Box sensor platform.""" -from collections.abc import Callable, Iterable +from collections.abc import Callable from dataclasses import dataclass -from itertools import chain from typing import Generic, TypeVar -from sfrbox_api.models import DslInfo, SystemInfo +from sfrbox_api.models import DslInfo, SystemInfo, WanInfo from homeassistant.components.sensor import ( SensorDeviceClass, @@ -47,84 +46,83 @@ class SFRBoxSensorEntityDescription(SensorEntityDescription, SFRBoxSensorMixin[_ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( SFRBoxSensorEntityDescription[DslInfo]( key="linemode", - name="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="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="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="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="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="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="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="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="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="Line status", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -136,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="Training", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -157,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, @@ -179,23 +175,40 @@ 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", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=[ + "adsl_ppp", + "adsl_routed", + "ftth_routed", + "grps_ppp", + "unknown", + ], + translation_key="wan_mode", + value_fn=lambda x: x.mode.replace("/", "_"), + ), +) async def async_setup_entry( @@ -204,16 +217,19 @@ async def async_setup_entry( """Set up the sensors.""" data: DomainData = hass.data[DOMAIN][entry.entry_id] - entities: Iterable[SFRBoxSensor] = chain( - ( + entities: list[SFRBoxSensor] = [ + SFRBoxSensor(data.system, description, data.system.data) + for description in SYSTEM_SENSOR_TYPES + ] + entities.extend( + SFRBoxSensor(data.wan, description, data.system.data) + for description in WAN_SENSOR_TYPES + ) + if data.system.data.net_infra == "adsl": + entities.extend( SFRBoxSensor(data.dsl, description, data.system.data) for description in DSL_SENSOR_TYPES - ), - ( - SFRBoxSensor(data.system, description, data.system.data) - for description in SYSTEM_SENSOR_TYPES - ), - ) + ) async_add_entities(entities) diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index ddff342a10..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", @@ -63,6 +100,31 @@ "showtime": "Showtime", "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)", + "ftth_routed": "FTTH (Routed)", + "grps_ppp": "GPRS (PPP)", + "unknown": "Unknown" + } } } } 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/manifest.json b/homeassistant/components/sharkiq/manifest.json index 1457f8f8a6..0e07dd9690 100644 --- a/homeassistant/components/sharkiq/manifest.json +++ b/homeassistant/components/sharkiq/manifest.json @@ -1,10 +1,10 @@ { "domain": "sharkiq", "name": "Shark IQ", - "codeowners": ["@JeffResc", "@funkybunch", "@AritroSaha10"], + "codeowners": ["@JeffResc", "@funkybunch"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sharkiq", "iot_class": "cloud_polling", "loggers": ["sharkiq"], - "requirements": ["sharkiq==0.0.1"] + "requirements": ["sharkiq==1.0.2"] } 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/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index cade4eaff6..0cc979a321 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -6,6 +6,7 @@ from contextlib import suppress import logging import shlex +import async_timeout import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall @@ -82,9 +83,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: process = await create_process try: - stdout_data, stderr_data = await asyncio.wait_for( - process.communicate(), COMMAND_TIMEOUT - ) + async with async_timeout.timeout(COMMAND_TIMEOUT): + stdout_data, stderr_data = await process.communicate() except asyncio.TimeoutError: _LOGGER.exception( "Timed out running command: `%s`, after: %ss", cmd, COMMAND_TIMEOUT diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 820afcf0f0..449fc14221 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -91,6 +91,7 @@ SENSORS: Final = { key="sensor|gas", name="Gas", device_class=BinarySensorDeviceClass.GAS, + translation_key="gas", value=lambda value: value in ["mild", "heavy"], extra_state_attributes=lambda block: {"detected": block.gas}, ), diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index f2a43accb0..85207ee447 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -97,7 +97,7 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): immediate=False, function=self._async_reload_entry, ) - entry.async_on_unload(self._debounced_reload.async_cancel) + entry.async_on_unload(self._debounced_reload.async_shutdown) @property def model(self) -> str: @@ -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/shelly/number.py b/homeassistant/components/shelly/number.py index 5a00a1df0d..fb95a180e7 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -30,7 +30,6 @@ from .entity import ( class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription): """Class to describe a BLOCK sensor.""" - mode: NumberMode = NumberMode("slider") rest_path: str = "" rest_arg: str = "" @@ -46,7 +45,7 @@ NUMBERS: Final = { native_min_value=0, native_max_value=100, native_step=1, - mode=NumberMode("slider"), + mode=NumberMode.SLIDER, rest_path="thermostat/0", rest_arg="pos", ), diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index e0196fbea5..b83131a100 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -82,7 +82,7 @@ SENSORS: Final = { key="device|deviceTemp", name="Device temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -92,7 +92,6 @@ SENSORS: Final = { key="emeter|current", name="Current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - value=lambda value: value, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), @@ -100,7 +99,7 @@ SENSORS: Final = { key="light|power", name="Power", native_unit_of_measurement=UnitOfPower.WATT, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -109,7 +108,7 @@ SENSORS: Final = { key="device|power", name="Power", native_unit_of_measurement=UnitOfPower.WATT, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), @@ -117,7 +116,7 @@ SENSORS: Final = { key="emeter|power", name="Power", native_unit_of_measurement=UnitOfPower.WATT, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), @@ -125,7 +124,7 @@ SENSORS: Final = { key="device|voltage", name="Voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -134,15 +133,14 @@ SENSORS: Final = { key="emeter|voltage", name="Voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), ("emeter", "powerFactor"): BlockSensorDescription( key="emeter|powerFactor", name="Power factor", - native_unit_of_measurement=PERCENTAGE, - value=lambda value: round(value * 100, 1), + suggested_display_precision=2, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), @@ -150,7 +148,7 @@ SENSORS: Final = { key="relay|power", name="Power", native_unit_of_measurement=UnitOfPower.WATT, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), @@ -158,23 +156,26 @@ SENSORS: Final = { key="roller|rollerPower", name="Power", native_unit_of_measurement=UnitOfPower.WATT, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), ("device", "energy"): BlockSensorDescription( key="device|energy", name="Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda value: round(value / 60 / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda value: value / 60, + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), ("emeter", "energy"): BlockSensorDescription( key="emeter|energy", name="Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda value: round(value / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, available=lambda block: cast(int, block.energy) != -1, @@ -182,8 +183,9 @@ SENSORS: Final = { ("emeter", "energyReturned"): BlockSensorDescription( key="emeter|energyReturned", name="Energy returned", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda value: round(value / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, available=lambda block: cast(int, block.energyReturned) != -1, @@ -191,8 +193,10 @@ SENSORS: Final = { ("light", "energy"): BlockSensorDescription( key="light|energy", name="Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda value: round(value / 60 / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda value: value / 60, + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, @@ -200,16 +204,20 @@ SENSORS: Final = { ("relay", "energy"): BlockSensorDescription( key="relay|energy", name="Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda value: round(value / 60 / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda value: value / 60, + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), ("roller", "rollerEnergy"): BlockSensorDescription( key="roller|rollerEnergy", name="Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda value: round(value / 60 / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda value: value / 60, + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -224,7 +232,7 @@ SENSORS: Final = { key="sensor|temp", name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -233,7 +241,7 @@ SENSORS: Final = { key="sensor|extTemp", name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, available=lambda block: cast(int, block.extTemp) != 999 @@ -243,7 +251,7 @@ SENSORS: Final = { key="sensor|humidity", name="Humidity", native_unit_of_measurement=PERCENTAGE, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, available=lambda block: cast(int, block.humidity) != 999 @@ -269,7 +277,8 @@ SENSORS: Final = { name="Lamp life", native_unit_of_measurement=PERCENTAGE, icon="mdi:progress-wrench", - value=lambda value: round(100 - (value / 3600 / SHAIR_MAX_WORK_HOURS), 1), + value=lambda value: 100 - (value / 3600 / SHAIR_MAX_WORK_HOURS), + suggested_display_precision=1, extra_state_attributes=lambda block: { "Operational hours": round(cast(int, block.totalWorkTime) / 3600, 1) }, @@ -279,13 +288,16 @@ SENSORS: Final = { key="adc|adc", name="ADC", native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value=lambda value: round(value, 2), + suggested_display_precision=2, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), ("sensor", "sensorOp"): BlockSensorDescription( key="sensor|sensorOp", name="Operation", + device_class=SensorDeviceClass.ENUM, + options=["unknown", "warmup", "normal", "fault"], + translation_key="operation", icon="mdi:cog-transfer", value=lambda value: value, extra_state_attributes=lambda block: {"self_test": block.selfTest}, @@ -397,7 +409,8 @@ RPC_SENSORS: Final = { sub_key="voltage", name="Voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value=lambda status, _: None if status is None else round(float(status), 1), + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=1, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -429,6 +442,16 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "current": RpcSensorDescription( + key="switch", + sub_key="current", + name="Current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value=lambda status, _: None if status is None else float(status), + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "a_current": RpcSensorDescription( key="em", sub_key="a_current", @@ -460,8 +483,10 @@ RPC_SENSORS: Final = { key="switch", sub_key="aenergy", name="Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(status["total"] / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: status["total"], + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -469,8 +494,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="total_act", name="Total active energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -478,8 +505,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="a_total_act_energy", name="Phase A total active energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, @@ -488,8 +517,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="b_total_act_energy", name="Phase B total active energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, @@ -498,8 +529,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="c_total_act_energy", name="Phase C total active energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, @@ -508,8 +541,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="total_act_ret", name="Total active returned energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -517,8 +552,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="a_total_act_ret_energy", name="Phase A total active returned energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, @@ -527,8 +564,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="b_total_act_ret_energy", name="Phase B total active returned energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, @@ -537,8 +576,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="c_total_act_ret_energy", name="Phase C total active returned energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, @@ -548,7 +589,8 @@ RPC_SENSORS: Final = { sub_key="temperature", name="Device temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value=lambda status, _: round(status["tC"], 1), + value=lambda status, _: status["tC"], + suggested_display_precision=1, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -560,7 +602,7 @@ RPC_SENSORS: Final = { sub_key="tC", name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value=lambda status, _: round(status, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), @@ -590,7 +632,7 @@ RPC_SENSORS: Final = { sub_key="rh", name="Humidity", native_unit_of_measurement=PERCENTAGE, - value=lambda status, _: round(status, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), @@ -609,7 +651,8 @@ RPC_SENSORS: Final = { sub_key="voltage", name="Voltmeter", native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value=lambda status, _: round(float(status), 2), + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, available=lambda status: status is not None, diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 62a43c5bbc..f0d4e1c9bd 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -81,5 +81,42 @@ "passive": "Passive" } } + }, + "entity": { + "binary_sensor": { + "gas": { + "state_attributes": { + "detected": { + "state": { + "unknown": "Unknown", + "none": "None", + "mild": "Mild", + "heavy": "Heavy", + "test": "Test" + } + } + } + } + }, + "sensor": { + "operation": { + "state": { + "unknown": "Unknown", + "warmup": "Warm-up", + "normal": "normal", + "fault": "Fault" + }, + "state_attributes": { + "self_test": { + "state": { + "not_completed": "Not completed", + "completed": "Completed", + "running": "Running", + "pending": "Pending" + } + } + } + } + } } } diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 55c08620e8..6b781918b4 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -19,6 +19,7 @@ from homeassistant.helpers.device_registry import ( async_get as dr_async_get, format_mac, ) +from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceClassName from homeassistant.helpers.entity_registry import async_get as er_async_get from homeassistant.helpers.typing import EventType from homeassistant.util.dt import utcnow @@ -72,12 +73,16 @@ def get_number_of_channels(device: BlockDevice, block: Block) -> int: def get_block_entity_name( device: BlockDevice, block: Block | None, - description: str | None = None, + description: str | DeviceClassName | None = None, ) -> str: """Naming for block based switch and sensors.""" channel_name = get_block_channel_name(device, block) if description: + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME as description. + assert description is not DEVICE_CLASS_NAME return f"{channel_name} {description.lower()}" return channel_name @@ -301,12 +306,16 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str: def get_rpc_entity_name( - device: RpcDevice, key: str, description: str | None = None + device: RpcDevice, key: str, description: str | DeviceClassName | None = None ) -> str: """Naming for RPC based switch and sensors.""" channel_name = get_rpc_channel_name(device, key) if description: + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME as description. + assert description is not DEVICE_CLASS_NAME return f"{channel_name} {description.lower()}" return channel_name diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 5bbb211891..3dc26fe007 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -15,9 +15,11 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType -from homeassistant.util.json import load_json +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) @@ -174,10 +190,10 @@ class NoMatchingShoppingListItem(Exception): class ShoppingData: """Class to hold shopping list data.""" - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize the shopping list.""" self.hass = hass - self.items = [] + self.items: JsonArrayType = [] async def async_add(self, name, context=None): """Add a shopping list item.""" @@ -277,16 +293,26 @@ class ShoppingData: context=context, ) - async def async_load(self): + 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.""" - def load(): + def load() -> JsonArrayType: """Load the items synchronously.""" - return load_json(self.hass.config.path(PERSISTENCE), default=[]) + return load_json_array(self.hass.config.path(PERSISTENCE)) self.items = await self.hass.async_add_executor_job(load) - def save(self): + def save(self) -> None: """Save the items.""" save_json(self.hass.config.path(PERSISTENCE), self.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..a59d1f1cda 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.async_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..ef2ecc7aa2 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.get(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..db0845473f 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.get(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..64ca3832ce 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.async_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..d31dc5da28 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 @@ -27,6 +27,7 @@ SUPPORTED_BATTERY_SENSOR_TYPES = [ DeviceTypes.MOTION, DeviceTypes.SIREN, DeviceTypes.SMOKE, + DeviceTypes.SMOKE_AND_CARBON_MONOXIDE, DeviceTypes.TEMPERATURE, ] @@ -38,6 +39,9 @@ TRIGGERED_SENSOR_TYPES = { DeviceTypes.MOTION: BinarySensorDeviceClass.MOTION, DeviceTypes.SIREN: BinarySensorDeviceClass.SAFETY, DeviceTypes.SMOKE: BinarySensorDeviceClass.SMOKE, + # Although this sensor can technically apply to both smoke and carbon, we use the + # SMOKE device class for simplicity: + DeviceTypes.SMOKE_AND_CARBON_MONOXIDE: BinarySensorDeviceClass.SMOKE, } @@ -67,6 +71,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 +106,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..d137824b3d 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.05.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/siren/strings.json b/homeassistant/components/siren/strings.json index c8e60e91ce..60d8843c15 100644 --- a/homeassistant/components/siren/strings.json +++ b/homeassistant/components/siren/strings.json @@ -1,3 +1,17 @@ { - "title": "Siren" + "title": "Siren", + "entity_component": { + "_": { + "name": "[%key:component::siren::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "available_tones": { + "name": "Available tones" + } + } + } + } } diff --git a/homeassistant/components/sky_hub/device_tracker.py b/homeassistant/components/sky_hub/device_tracker.py index 65d806a9bc..8741b2ed56 100644 --- a/homeassistant/components/sky_hub/device_tracker.py +++ b/homeassistant/components/sky_hub/device_tracker.py @@ -39,7 +39,7 @@ async def async_get_scanner( class SkyHubDeviceScanner(DeviceScanner): - """This class queries a Sky Hub router.""" + """Class which queries a Sky Hub router.""" def __init__(self, hub): """Initialise the scanner.""" diff --git a/homeassistant/components/skybell/__init__.py b/homeassistant/components/skybell/__init__.py index 35de1a5b57..ac948408a3 100644 --- a/homeassistant/components/skybell/__init__.py +++ b/homeassistant/components/skybell/__init__.py @@ -11,8 +11,6 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import SkybellDataUpdateCoordinator @@ -26,24 +24,6 @@ PLATFORMS = [ ] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the SkyBell component.""" - hass.data.setdefault(DOMAIN, {}) - - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.9.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Skybell from a config entry.""" email = entry.data[CONF_EMAIL] @@ -72,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for coordinator in device_coordinators ] ) - hass.data[DOMAIN][entry.entry_id] = device_coordinators + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device_coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/skybell/strings.json b/homeassistant/components/skybell/strings.json index f9122a1e10..4289c3ed3c 100644 --- a/homeassistant/components/skybell/strings.json +++ b/homeassistant/components/skybell/strings.json @@ -24,11 +24,5 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } - }, - "issues": { - "removed_yaml": { - "title": "The Skybell YAML configuration has been removed", - "description": "Configuring Skybell using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/sleepiq/entity.py b/homeassistant/components/sleepiq/entity.py index d4ca2c894d..e6eeaa98c2 100644 --- a/homeassistant/components/sleepiq/entity.py +++ b/homeassistant/components/sleepiq/entity.py @@ -5,7 +5,7 @@ from typing import TypeVar from asyncsleepiq import SleepIQBed, SleepIQSleeper from homeassistant.core import callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -21,7 +21,7 @@ _SleepIQCoordinatorT = TypeVar( def device_from_bed(bed: SleepIQBed) -> DeviceInfo: """Create a device given a bed.""" return DeviceInfo( - connections={(device_registry.CONNECTION_NETWORK_MAC, bed.mac_addr)}, + connections={(dr.CONNECTION_NETWORK_MAC, bed.mac_addr)}, manufacturer="SleepNumber", name=bed.name, model=bed.model, diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index b77c249dd2..3d757e2328 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.5"] } 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 993ed9571a..641d3b8ae4 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -15,6 +15,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, async_process_play_media_url, ) from homeassistant.config_entries import ConfigEntry @@ -175,7 +176,7 @@ class SlimProtoPlayer(MediaPlayerEntity): await self.player.power(False) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the play_media command to the media player.""" to_send_media_type: str | None = media_type @@ -194,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/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 4f5d93969e..40e236f918 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -45,7 +45,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.air_conditioner_mode: [ @@ -55,7 +55,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.air_quality_sensor: [ @@ -160,7 +160,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.dryer_operating_state: [ @@ -303,7 +303,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.oven_operating_state: [ @@ -362,7 +362,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.robot_cleaner_movement: [ @@ -382,7 +382,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.signal_strength: [ @@ -433,7 +433,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.thermostat_heating_setpoint: [ @@ -443,7 +443,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, SensorDeviceClass.TEMPERATURE, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.thermostat_mode: [ @@ -453,7 +453,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.thermostat_operating_state: [ @@ -473,7 +473,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, SensorDeviceClass.TEMPERATURE, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.three_axis: [], @@ -518,7 +518,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.washer_operating_state: [ diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index e3cf1dcf28..036fb6e1e9 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -3,7 +3,7 @@ from datetime import timedelta import ipaddress import logging -from pysmarty import Smarty +from pysmarty import Smarty # pylint: disable=import-error import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_NAME, Platform diff --git a/homeassistant/components/smarty/binary_sensor.py b/homeassistant/components/smarty/binary_sensor.py index d9d757a71b..baa2511518 100644 --- a/homeassistant/components/smarty/binary_sensor.py +++ b/homeassistant/components/smarty/binary_sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from pysmarty import Smarty +from pysmarty import Smarty # pylint: disable=import-error from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index cf4b49e610..cf7db560c1 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -5,7 +5,7 @@ import logging import math from typing import Any -from pysmarty import Smarty +from pysmarty import Smarty # pylint: disable=import-error from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json index cebb64863a..8769aa666a 100644 --- a/homeassistant/components/smarty/manifest.json +++ b/homeassistant/components/smarty/manifest.json @@ -2,6 +2,7 @@ "domain": "smarty", "name": "Salda Smarty", "codeowners": ["@z0mbieprocess"], + "disabled": "Dependencies not compatible with the new pip resolver", "documentation": "https://www.home-assistant.io/integrations/smarty", "iot_class": "local_polling", "loggers": ["pymodbus", "pysmarty"], diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index 57d681594c..df99529b1f 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations import datetime as dt import logging -from pysmarty import Smarty +from pysmarty import Smarty # pylint: disable=import-error from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import UnitOfTemperature 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 d69f06f698..13091689fe 100644 --- a/homeassistant/components/snapcast/manifest.json +++ b/homeassistant/components/snapcast/manifest.json @@ -1,9 +1,10 @@ { "domain": "snapcast", "name": "Snapcast", - "codeowners": [], + "codeowners": ["@luar123"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/snapcast", "iot_class": "local_polling", "loggers": ["construct", "snapcast"], - "requirements": ["snapcast==2.3.0"] + "requirements": ["snapcast==2.3.3"] } diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 9e0e10ac0e..bb54bfabf9 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): @@ -146,7 +175,7 @@ class SnapcastGroupDevice(MediaPlayerEntity): @property def name(self): """Return the name of the device.""" - return f"{GROUP_PREFIX}{self._group.identifier}" + return f"{self._group.friendly_name} {GROUP_SUFFIX}" @property def source(self): @@ -168,12 +197,6 @@ class SnapcastGroupDevice(MediaPlayerEntity): """List of available input sources.""" return list(self._group.streams_by_name().keys()) - @property - def extra_state_attributes(self): - """Return the state attributes.""" - name = f"{self._group.friendly_name} {GROUP_SUFFIX}" - return {"friendly_name": name} - async def async_select_source(self, source: str) -> None: """Set input source.""" streams = self._group.streams_by_name() @@ -211,10 +234,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.""" @@ -240,7 +264,7 @@ class SnapcastClientDevice(MediaPlayerEntity): @property def name(self): """Return the name of the device.""" - return f"{CLIENT_PREFIX}{self._client.identifier}" + return f"{self._client.friendly_name} {CLIENT_SUFFIX}" @property def source(self): @@ -263,11 +287,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): @@ -275,8 +301,6 @@ class SnapcastClientDevice(MediaPlayerEntity): state_attrs = {} if self.latency is not None: state_attrs["latency"] = self.latency - name = f"{self._client.friendly_name} {CLIENT_SUFFIX}" - state_attrs["friendly_name"] = name return state_attrs @property @@ -303,9 +327,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..3d19de74f9 100644 --- a/homeassistant/components/snips/__init__.py +++ b/homeassistant/components/snips/__init__.py @@ -90,12 +90,9 @@ SERVICE_SCHEMA_FEEDBACK = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Activate Snips component.""" - # Make sure MQTT is available and the entry is loaded - if not hass.config_entries.async_entries( - mqtt.DOMAIN - ) or not await hass.config_entries.async_wait_component( - hass.config_entries.async_entries(mqtt.DOMAIN)[0] - ): + + # Make sure MQTT integration is enabled and the client is available + if not await mqtt.async_wait_for_mqtt_client(hass): _LOGGER.error("MQTT integration is not available") return False diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index 8194b3f96c..324a1e4936 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/snmp", "iot_class": "local_polling", "loggers": ["pyasn1", "pysmi", "pysnmp"], - "requirements": ["pysnmplib==5.0.20"] + "requirements": ["pysnmplib==5.0.21"] } diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index c20e5fe6e3..fc8068fb53 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -145,7 +145,7 @@ async def async_setup_platform( ContextData(), ] get_result = await getCmd(*request_args, ObjectType(ObjectIdentity(baseoid))) - errindication, _, _, _ = await get_result + errindication, _, _, _ = get_result if errindication and not accept_errors: _LOGGER.error("Please check the details in the configuration file") @@ -207,7 +207,7 @@ class SnmpData: get_result = await getCmd( *self._request_args, ObjectType(ObjectIdentity(self._baseoid)) ) - errindication, errstatus, errindex, restable = await get_result + errindication, errstatus, errindex, restable = get_result if errindication and not self._accept_errors: _LOGGER.error("SNMP error: %s", errindication) diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index 4699aaefd7..d0fe393d55 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -261,7 +261,7 @@ class SnmpSwitch(SwitchEntity): get_result = await getCmd( *self._request_args, ObjectType(ObjectIdentity(self._baseoid)) ) - errindication, errstatus, errindex, restable = await get_result + errindication, errstatus, errindex, restable = get_result if errindication: _LOGGER.error("SNMP error: %s", errindication) diff --git a/homeassistant/components/solaredge_local/manifest.json b/homeassistant/components/solaredge_local/manifest.json index 960ff07b75..d65aa06ea0 100644 --- a/homeassistant/components/solaredge_local/manifest.json +++ b/homeassistant/components/solaredge_local/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/solaredge_local", "iot_class": "local_polling", "loggers": ["solaredge_local"], - "requirements": ["solaredge-local==0.2.0"] + "requirements": ["solaredge-local==0.2.3"] } diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index 35e5d75843..d0efcd0ec9 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -290,7 +290,7 @@ class SolarEdgeSensor(SensorEntity): """Return the state attributes.""" if extra_attr := self.entity_description.extra_attribute: try: - return {extra_attr: self._data.info[self.entity_description.key]} + return {extra_attr: self._data.info.get(self.entity_description.key)} except KeyError: pass return None @@ -298,7 +298,7 @@ class SolarEdgeSensor(SensorEntity): def update(self) -> None: """Get the latest data from the sensor and update the state.""" self._data.update() - self._attr_native_value = self._data.data[self.entity_description.key] + self._attr_native_value = self._data.data.get(self.entity_description.key) class SolarEdgeData: diff --git a/homeassistant/components/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 e7e0b3952e..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) @@ -392,7 +425,7 @@ class SonosDiscoveryManager: ) elif not known_speaker.available: try: - known_speaker.ping() + await self.hass.async_add_executor_job(known_speaker.ping) except SonosUpdateError: _LOGGER.debug( "Manual poll to %s failed, keeping unavailable", 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 54a36b093e..087c636f1e 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.0"], + "requirements": ["soco==0.29.1", "sonos-websocket==0.1.1"], "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 22517b9399..7e6c210a16 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, @@ -303,7 +305,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): return PLAY_MODES[self.media.play_mode][0] @property - def repeat(self) -> str | None: + def repeat(self) -> RepeatMode | None: """Return current repeat mode.""" sonos_repeat = PLAY_MODES[self.media.play_mode][1] return SONOS_TO_REPEAT[sonos_repeat] @@ -491,9 +493,8 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Clear players playlist.""" self.coordinator.soco.clear_queue() - @soco_error() - def play_media( # noqa: C901 - self, media_type: str, media_id: str, **kwargs: Any + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the play_media command to the media player. @@ -505,28 +506,46 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): If media_type is "playlist", media_id should be a Sonos Playlist name. Otherwise, media_id should be a URI. """ - # Use 'replace' as the default enqueue option - enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE) - - if spotify.is_spotify_media_type(media_type): - media_type = spotify.resolve_spotify_media_type(media_type) - media_id = spotify.spotify_uri_from_media_browser_url(media_id) - is_radio = False if media_source.is_media_source_id(media_id): is_radio = media_id.startswith("media-source://radio_browser/") media_type = MediaType.MUSIC - media_id = ( - run_coroutine_threadsafe( - media_source.async_resolve_media( - self.hass, media_id, self.entity_id - ), - self.hass.loop, - ) - .result() - .url + media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id ) + media_id = async_process_play_media_url(self.hass, media.url) + + if kwargs.get(ATTR_MEDIA_ANNOUNCE): + volume = kwargs.get("extra", {}).get("volume") + _LOGGER.debug("Playing %s using websocket audioclip", media_id) + try: + assert self.speaker.websocket + response, _ = await self.speaker.websocket.play_clip( + async_process_play_media_url(self.hass, media_id), + volume=volume, + ) + except SonosWebsocketError as exc: + raise HomeAssistantError( + f"Error when calling Sonos websocket: {exc}" + ) from exc + if response["success"]: + return + + if spotify.is_spotify_media_type(media_type): + media_type = spotify.resolve_spotify_media_type(media_type) + media_id = spotify.spotify_uri_from_media_browser_url(media_id) + + await self.hass.async_add_executor_job( + partial(self._play_media, media_type, media_id, is_radio, **kwargs) + ) + + @soco_error() + def _play_media( + self, media_type: MediaType | str, media_id: str, is_radio: bool, **kwargs: Any + ) -> None: + """Wrap sync calls to async_play_media.""" + enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE) if media_type == "favorite_item_id": favorite = self.speaker.favorites.lookup_by_item_id(media_id) @@ -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 3e66f5690b..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 ent_reg +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: @@ -837,7 +856,7 @@ class SonosSpeaker: # Skip updating existing single speakers in polling mode return - entity_registry = ent_reg.async_get(self.hass) + entity_registry = er.async_get(self.hass) sonos_group = [] sonos_group_entities = [] 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 17c197d692..7211843133 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -18,6 +18,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, async_process_play_media_url, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -282,7 +283,7 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): ) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" if media_source.is_media_source_id(media_id): @@ -295,7 +296,9 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): partial(self.play_media, media_type, media_id, **kwargs) ) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play a piece of media.""" _LOGGER.debug("Starting media with media_id: %s", media_id) if re.match(r"http?://", str(media_id)): @@ -395,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 1145686efe..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 @@ -104,7 +105,6 @@ class SpotifyMediaPlayer(MediaPlayerEntity): _attr_has_entity_name = True _attr_icon = "mdi:spotify" - _attr_media_content_type = MediaType.MUSIC _attr_media_image_remotely_accessible = False def __init__( @@ -161,6 +161,15 @@ class SpotifyMediaPlayer(MediaPlayerEntity): item = self._currently_playing.get("item") or {} return item.get("uri") + @property + def media_content_type(self) -> str | None: + """Return the media type.""" + if not self._currently_playing: + return None + item = self._currently_playing.get("item") or {} + is_episode = item.get("type") == MediaType.EPISODE + return MediaType.PODCAST if is_episode else MediaType.MUSIC + @property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" @@ -191,13 +200,20 @@ class SpotifyMediaPlayer(MediaPlayerEntity): @property def media_image_url(self) -> str | None: """Return the media image URL.""" - if ( - not self._currently_playing - or self._currently_playing.get("item") is None - or not self._currently_playing["item"]["album"]["images"] - ): + if not self._currently_playing or self._currently_playing.get("item") is None: return None - return fetch_image_url(self._currently_playing["item"]["album"]) + + item = self._currently_playing["item"] + if item["type"] == MediaType.EPISODE: + if item["images"]: + return fetch_image_url(item) + if item["show"]["images"]: + return fetch_image_url(item["show"]) + return None + + if not item["album"]["images"]: + return None + return fetch_image_url(item["album"]) @property def media_title(self) -> str | None: @@ -212,16 +228,24 @@ class SpotifyMediaPlayer(MediaPlayerEntity): """Return the media artist.""" if not self._currently_playing or self._currently_playing.get("item") is None: return None - return ", ".join( - artist["name"] for artist in self._currently_playing["item"]["artists"] - ) + + item = self._currently_playing["item"] + if item["type"] == MediaType.EPISODE: + return item["show"]["publisher"] + + return ", ".join(artist["name"] for artist in item["artists"]) @property def media_album_name(self) -> str | None: """Return the media album.""" if not self._currently_playing or self._currently_playing.get("item") is None: return None - return self._currently_playing["item"]["album"]["name"] + + item = self._currently_playing["item"] + if item["type"] == MediaType.EPISODE: + return item["show"]["name"] + + return item["album"]["name"] @property def media_track(self) -> int | None: @@ -258,7 +282,7 @@ class SpotifyMediaPlayer(MediaPlayerEntity): return self._currently_playing.get("shuffle_state") @property - def repeat(self) -> str | None: + def repeat(self) -> RepeatMode | None: """Return current repeat mode.""" if ( not self._currently_playing @@ -298,7 +322,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity): self.data.client.seek_track(int(position * 1000)) @spotify_exception_handler - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play media.""" media_type = media_type.removeprefix(MEDIA_PLAYER_PREFIX) @@ -359,7 +385,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity): ).result() self.data.client.set_auth(auth=self.data.session.token["access_token"]) - current = self.data.client.current_playback() + current = self.data.client.current_playback( + additional_types=[MediaType.EPISODE] + ) self._currently_playing = current or {} context = self._currently_playing.get("context") @@ -371,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/__init__.py b/homeassistant/components/sql/__init__.py index bba49c415f..dd5480450e 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -1,11 +1,19 @@ """The sql component.""" from __future__ import annotations +import logging + import voluptuous as vol -from homeassistant.components.recorder import CONF_DB_URL +from homeassistant.components.recorder import CONF_DB_URL, get_instance +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA, + STATE_CLASSES_SCHEMA, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -18,6 +26,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN, PLATFORMS +from .util import redact_credentials + +_LOGGER = logging.getLogger(__name__) def validate_sql_select(value: str) -> str: @@ -36,6 +47,8 @@ QUERY_SCHEMA = vol.Schema( vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DB_URL): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, } ) @@ -45,6 +58,18 @@ CONFIG_SCHEMA = vol.Schema( ) +def remove_configured_db_url_if_not_needed( + hass: HomeAssistant, entry: ConfigEntry +) -> None: + """Remove db url from config if it matches recorder database.""" + hass.config_entries.async_update_entry( + entry, + options={ + key: value for key, value in entry.options.items() if key != CONF_DB_URL + }, + ) + + async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener for options.""" await hass.config_entries.async_reload(entry.entry_id) @@ -65,6 +90,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SQL from a config entry.""" + _LOGGER.debug( + "Comparing %s and %s", + redact_credentials(entry.options.get(CONF_DB_URL)), + redact_credentials(get_instance(hass).db_url), + ) + if entry.options.get(CONF_DB_URL) == get_instance(hass).db_url: + remove_configured_db_url_if_not_needed(hass, entry) + entry.async_on_unload(entry.add_update_listener(async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index a6b1afe404..a6c526a6a7 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -6,34 +6,48 @@ from typing import Any import sqlalchemy from sqlalchemy.engine import Result -from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.exc import NoSuchColumnError, SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.recorder import CONF_DB_URL, DEFAULT_DB_FILE, DEFAULT_URL +from homeassistant.components.recorder import CONF_DB_URL, get_instance from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN +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.""" @@ -55,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 @@ -85,129 +108,114 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the user step.""" errors = {} - db_url_default = DEFAULT_URL.format( - hass_config_path=self.hass.config.path(DEFAULT_DB_FILE) - ) + description_placeholders = {} if user_input is not None: - db_url = user_input.get(CONF_DB_URL, db_url_default) + db_url = user_input.get(CONF_DB_URL) query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] - uom = user_input.get(CONF_UNIT_OF_MEASUREMENT) - value_template = user_input.get(CONF_VALUE_TEMPLATE) - name = user_input[CONF_NAME] + db_url_for_validation = None try: validate_sql_select(query) + db_url_for_validation = resolve_db_url(self.hass, db_url) await self.hass.async_add_executor_job( - validate_query, db_url, query, column + validate_query, db_url_for_validation, query, column ) + except NoSuchColumnError: + errors["column"] = "column_invalid" + description_placeholders = {"column": column} except SQLAlchemyError: errors["db_url"] = "db_url_invalid" except ValueError: errors["query"] = "query_invalid" + options = { + CONF_QUERY: query, + CONF_COLUMN_NAME: column, + CONF_NAME: user_input[CONF_NAME], + } + if uom := user_input.get(CONF_UNIT_OF_MEASUREMENT): + options[CONF_UNIT_OF_MEASUREMENT] = uom + if value_template := user_input.get(CONF_VALUE_TEMPLATE): + options[CONF_VALUE_TEMPLATE] = value_template + if db_url_for_validation != get_instance(self.hass).db_url: + options[CONF_DB_URL] = db_url_for_validation + if not errors: return self.async_create_entry( - title=name, + title=user_input[CONF_NAME], data={}, - options={ - CONF_DB_URL: db_url, - CONF_QUERY: query, - CONF_COLUMN_NAME: column, - CONF_UNIT_OF_MEASUREMENT: uom, - CONF_VALUE_TEMPLATE: value_template, - CONF_NAME: name, - }, + options=options, ) return self.async_show_form( step_id="user", - data_schema=DATA_SCHEMA, + data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input), errors=errors, + description_placeholders=description_placeholders, ) -class SQLOptionsFlowHandler(config_entries.OptionsFlow): +class SQLOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): """Handle SQL options.""" - def __init__(self, entry: config_entries.ConfigEntry) -> None: - """Initialize SQL options flow.""" - self.entry = entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage SQL options.""" errors = {} - db_url_default = DEFAULT_URL.format( - hass_config_path=self.hass.config.path(DEFAULT_DB_FILE) - ) + description_placeholders = {} if user_input is not None: - db_url = user_input.get(CONF_DB_URL, db_url_default) + db_url = user_input.get(CONF_DB_URL) query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] - name = self.entry.options.get(CONF_NAME, self.entry.title) + name = self.options.get(CONF_NAME, self.config_entry.title) try: validate_sql_select(query) + db_url_for_validation = resolve_db_url(self.hass, db_url) await self.hass.async_add_executor_job( - validate_query, db_url, query, column + validate_query, db_url_for_validation, query, column ) + except NoSuchColumnError: + errors["column"] = "column_invalid" + description_placeholders = {"column": column} except SQLAlchemyError: errors["db_url"] = "db_url_invalid" except ValueError: errors["query"] = "query_invalid" else: + recorder_db = get_instance(self.hass).db_url + _LOGGER.debug( + "db_url: %s, resolved db_url: %s, recorder: %s", + db_url, + db_url_for_validation, + recorder_db, + ) + + options = { + CONF_QUERY: query, + CONF_COLUMN_NAME: column, + CONF_NAME: name, + } + if uom := user_input.get(CONF_UNIT_OF_MEASUREMENT): + options[CONF_UNIT_OF_MEASUREMENT] = uom + if value_template := user_input.get(CONF_VALUE_TEMPLATE): + options[CONF_VALUE_TEMPLATE] = value_template + if db_url_for_validation != get_instance(self.hass).db_url: + options[CONF_DB_URL] = db_url_for_validation + return self.async_create_entry( - title="", - data={ - CONF_NAME: name, - CONF_DB_URL: db_url, - **user_input, - }, + data=options, ) return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_DB_URL, - description={ - "suggested_value": self.entry.options[CONF_DB_URL] - }, - ): selector.TextSelector(), - vol.Required( - CONF_QUERY, - description={"suggested_value": self.entry.options[CONF_QUERY]}, - ): selector.TextSelector( - selector.TextSelectorConfig(multiline=True) - ), - vol.Required( - CONF_COLUMN_NAME, - description={ - "suggested_value": self.entry.options[CONF_COLUMN_NAME] - }, - ): selector.TextSelector(), - vol.Optional( - CONF_UNIT_OF_MEASUREMENT, - description={ - "suggested_value": self.entry.options.get( - CONF_UNIT_OF_MEASUREMENT - ) - }, - ): selector.TextSelector(), - vol.Optional( - CONF_VALUE_TEMPLATE, - description={ - "suggested_value": self.entry.options.get( - CONF_VALUE_TEMPLATE - ) - }, - ): selector.TemplateSelector(), - } + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, user_input or self.options ), errors=errors, + description_placeholders=description_placeholders, ) diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 5b8ae58e31..fd48bd1009 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": ["@gjohansson-ST", "@dougiteixeira"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["sqlalchemy==2.0.3"] + "requirements": ["sqlalchemy==2.0.13"] } 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 5d51087a9d..2a8ea80580 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -6,35 +6,49 @@ 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, DEFAULT_DB_FILE, DEFAULT_URL -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.recorder import ( + CONF_DB_URL, + SupportedDialect, + get_instance, +) +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_NAME, 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 from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_COLUMN_NAME, CONF_QUERY, DB_URL_RE, DOMAIN +from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN +from .models import SQLData +from .util import redact_credentials, resolve_db_url _LOGGER = logging.getLogger(__name__) - -def redact_credentials(data: str) -> str: - """Redact credentials from string data.""" - return DB_URL_RE.sub("//****:****@", data) +_SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000) async def async_setup_platform( @@ -53,7 +67,9 @@ async def async_setup_platform( value_template: Template | None = conf.get(CONF_VALUE_TEMPLATE) column_name: str = conf[CONF_COLUMN_NAME] unique_id: str | None = conf.get(CONF_UNIQUE_ID) - db_url: str | None = conf.get(CONF_DB_URL) + db_url: str = resolve_db_url(hass, conf.get(CONF_DB_URL)) + device_class: SensorDeviceClass | None = conf.get(CONF_DEVICE_CLASS) + state_class: SensorStateClass | None = conf.get(CONF_STATE_CLASS) if value_template is not None: value_template.hass = hass @@ -68,6 +84,8 @@ async def async_setup_platform( unique_id, db_url, True, + device_class, + state_class, async_add_entities, ) @@ -77,7 +95,7 @@ async def async_setup_entry( ) -> None: """Set up the SQL sensor from config entry.""" - db_url: str = entry.options[CONF_DB_URL] + db_url: str = resolve_db_url(hass, entry.options.get(CONF_DB_URL)) name: str = entry.options[CONF_NAME] query_str: str = entry.options[CONF_QUERY] unit: str | None = entry.options.get(CONF_UNIT_OF_MEASUREMENT) @@ -104,10 +122,42 @@ async def async_setup_entry( entry.entry_id, db_url, False, + None, + None, async_add_entities, ) +@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, @@ -116,39 +166,77 @@ async def async_setup_sensor( unit: str | None, value_template: Template | None, unique_id: str | None, - db_url: str | None, + db_url: str, yaml: bool, + device_class: SensorDeviceClass | None, + state_class: SensorStateClass | None, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SQL sensor.""" - - if not db_url: - db_url = DEFAULT_URL.format(hass_config_path=hass.config.path(DEFAULT_DB_FILE)) - - 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)), - ) + 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;" @@ -163,12 +251,48 @@ async def async_setup_sensor( value_template, unique_id, yaml, + device_class, + state_class, + use_database_executor, ) ], True, ) +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.""" @@ -185,16 +309,23 @@ class SQLSensor(SensorEntity): value_template: Template | None, unique_id: str | None, yaml: bool, + device_class: SensorDeviceClass | None, + state_class: SensorStateClass | None, + use_database_executor: bool, ) -> None: """Initialize the SQL sensor.""" self._query = query self._attr_name = name if yaml else None self._attr_native_unit_of_measurement = unit + self._attr_device_class = device_class + self._attr_state_class = state_class self._template = value_template self._column_name = column self.sessionmaker = sessmaker 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, @@ -203,14 +334,20 @@ class SQLSensor(SensorEntity): name=name, ) - def update(self) -> None: - """Retrieve sensor data from the query.""" + async def async_update(self) -> None: + """Retrieve sensor data from the query using the right executor.""" + if self._use_database_executor: + await get_instance(self.hass).async_add_executor_job(self._update) + else: + await self.hass.async_add_executor_job(self._update) + def _update(self) -> None: + """Retrieve sensor data from the query.""" data = None 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", @@ -225,10 +362,15 @@ class SQLSensor(SensorEntity): for key, value in res.items(): if isinstance(value, decimal.Decimal): value = float(value) - if isinstance(value, date): + elif isinstance(value, date): value = value.isoformat() + elif isinstance(value, (bytes, bytearray)): + value = f"0x{value.hex()}" self._attr_extra_state_attributes[key] = value + if data is not None and isinstance(data, (bytes, bytearray)): + data = f"0x{data.hex()}" + if data is not None and self._template is not None: self._attr_native_value = ( self._template.async_render_with_possible_json_value(data, None) 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/sql/util.py b/homeassistant/components/sql/util.py new file mode 100644 index 0000000000..3dd0990b24 --- /dev/null +++ b/homeassistant/components/sql/util.py @@ -0,0 +1,26 @@ +"""Utils for sql.""" +from __future__ import annotations + +import logging + +from homeassistant.components.recorder import get_instance +from homeassistant.core import HomeAssistant + +from .const import DB_URL_RE + +_LOGGER = logging.getLogger(__name__) + + +def redact_credentials(data: str | None) -> str: + """Redact credentials from string data.""" + if not data: + return "none" + return DB_URL_RE.sub("//****:****@", data) + + +def resolve_db_url(hass: HomeAssistant, db_url: str | None) -> str: + """Return the db_url provided if not empty, otherwise return the recorder db_url.""" + _LOGGER.debug("db_url: %s", redact_credentials(db_url)) + if db_url and not db_url.isspace(): + return db_url + return get_instance(hass).db_url diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 1411b8bc78..bb175ee00b 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -4,6 +4,7 @@ from http import HTTPStatus import logging from typing import TYPE_CHECKING +import async_timeout from pysqueezebox import Server, async_discover import voluptuous as vol @@ -130,7 +131,8 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # no host specified, see if we can discover an unconfigured LMS server try: - await asyncio.wait_for(self._discover(), timeout=TIMEOUT) + async with async_timeout.timeout(TIMEOUT): + await self._discover() return await self.async_step_edit() except asyncio.TimeoutError: errors["base"] = "no_server_found" diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 22812f06ed..d3fae39bc4 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -469,7 +469,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): await self._player.async_set_power(True) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the play_media command to the media player.""" index = None @@ -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/__init__.py b/homeassistant/components/starlink/__init__.py index ceb962c88c..c59269d2e0 100644 --- a/homeassistant/components/starlink/__init__.py +++ b/homeassistant/components/starlink/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import StarlinkUpdateCoordinator -PLATFORMS: list[Platform] = [ +PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, diff --git a/homeassistant/components/starlink/diagnostics.py b/homeassistant/components/starlink/diagnostics.py new file mode 100644 index 0000000000..10711e7155 --- /dev/null +++ b/homeassistant/components/starlink/diagnostics.py @@ -0,0 +1,21 @@ +"""Fetches diagnostic data for Starlink systems.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import StarlinkUpdateCoordinator + +TO_REDACT = {"id"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for Starlink config entries.""" + coordinator: StarlinkUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + return async_redact_data(asdict(coordinator.data), TO_REDACT) diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index af745c6f15..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 @@ -66,7 +72,8 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( icon="mdi:speedometer", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.MILLISECONDS, - value_fn=lambda data: round(data.status["pop_ping_latency_ms"]), + suggested_display_precision=0, + value_fn=lambda data: data.status["pop_ping_latency_ms"], ), StarlinkSensorEntityDescription( key="azimuth", @@ -75,7 +82,9 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=DEGREE, - value_fn=lambda data: round(data.status["direction_azimuth"]), + entity_registry_enabled_default=False, + suggested_display_precision=0, + value_fn=lambda data: data.status["direction_azimuth"], ), StarlinkSensorEntityDescription( key="elevation", @@ -84,23 +93,29 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=DEGREE, - value_fn=lambda data: round(data.status["direction_elevation"]), + entity_registry_enabled_default=False, + suggested_display_precision=0, + value_fn=lambda data: data.status["direction_elevation"], ), StarlinkSensorEntityDescription( key="uplink_throughput", name="Uplink throughput", icon="mdi:upload", state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, - value_fn=lambda data: round(data.status["uplink_throughput_bps"]), + suggested_display_precision=0, + value_fn=lambda data: data.status["uplink_throughput_bps"], ), StarlinkSensorEntityDescription( key="downlink_throughput", name="Downlink throughput", icon="mdi:download", state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, - value_fn=lambda data: round(data.status["downlink_throughput_bps"]), + suggested_display_precision=0, + value_fn=lambda data: data.status["downlink_throughput_bps"], ), StarlinkSensorEntityDescription( key="last_boot_time", @@ -110,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/statistics/manifest.json b/homeassistant/components/statistics/manifest.json index 6a41dec447..04b5277ecf 100644 --- a/homeassistant/components/statistics/manifest.json +++ b/homeassistant/components/statistics/manifest.json @@ -2,7 +2,7 @@ "domain": "statistics", "name": "Statistics", "after_dependencies": ["recorder"], - "codeowners": ["@fabaff", "@ThomDietrich"], + "codeowners": ["@ThomDietrich"], "documentation": "https://www.home-assistant.io/integrations/statistics", "iot_class": "local_polling", "quality_scale": "internal" diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 9a87129e5d..078eb59fe7 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -7,7 +7,7 @@ import contextlib from datetime import datetime, timedelta import logging import statistics -from typing import Any, Literal, cast +from typing import Any, cast import voluptuous as vol @@ -410,7 +410,7 @@ class StatisticsSensor(SensorEntity): return None @property - def state_class(self) -> Literal[SensorStateClass.MEASUREMENT] | None: + def state_class(self) -> SensorStateClass | None: """Return the state class of this entity.""" if self._state_characteristic in STATS_NOT_A_NUMBER: return None diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py index 2629962565..72f1cd2abb 100644 --- a/homeassistant/components/steam_online/__init__.py +++ b/homeassistant/components/steam_online/__init__.py @@ -4,34 +4,13 @@ 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] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Steam integration.""" - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Steam from a config entry.""" coordinator = SteamDataUpdateCoordinator(hass) @@ -47,20 +26,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/steam_online/strings.json b/homeassistant/components/steam_online/strings.json index 63dc7cce22..1b431795ea 100644 --- a/homeassistant/components/steam_online/strings.json +++ b/homeassistant/components/steam_online/strings.json @@ -35,11 +35,5 @@ "error": { "unauthorized": "Friends list restricted: Please refer to the documentation on how to see all other friends" } - }, - "issues": { - "removed_yaml": { - "title": "The Steam YAML configuration has been removed", - "description": "Configuring Steam using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/stookalert/binary_sensor.py b/homeassistant/components/stookalert/binary_sensor.py index 70a25c2bfd..d3920d3f0e 100644 --- a/homeassistant/components/stookalert/binary_sensor.py +++ b/homeassistant/components/stookalert/binary_sensor.py @@ -1,4 +1,4 @@ -"""This integration provides support for Stookalert Binary Sensor.""" +"""Support for Stookalert Binary Sensor.""" from __future__ import annotations from datetime import timedelta diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index 9eb70fda7e..cd84bec11b 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -1,4 +1,4 @@ -"""This integration provides support for Stookwijzer Sensor.""" +"""Support for Stookwijzer Sensor.""" from __future__ import annotations from datetime import timedelta 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/__init__.py b/homeassistant/components/subaru/__init__.py index 3e72b079ad..49ad3cf0d9 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -66,7 +66,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: vehicle_info = {} for vin in controller.get_vehicles(): - vehicle_info[vin] = get_vehicle_info(controller, vin) + if controller.get_subscription_status(vin): + vehicle_info[vin] = get_vehicle_info(controller, vin) async def async_update_data(): """Fetch data from API endpoint.""" @@ -116,10 +117,6 @@ async def refresh_subaru_data(config_entry, vehicle_info, controller): for vehicle in vehicle_info.values(): vin = vehicle[VEHICLE_VIN] - # Active subscription required - if not vehicle[VEHICLE_HAS_SAFETY_SERVICE]: - continue - # Optionally send an "update" remote command to vehicle (throttled with update_interval) if config_entry.options.get(CONF_UPDATE_ENABLED, False): await update_subaru(vehicle, controller) diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py index 3de4930a69..42badfc018 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -28,11 +28,12 @@ VEHICLE_HAS_REMOTE_START = "has_res" VEHICLE_HAS_REMOTE_SERVICE = "has_remote" VEHICLE_HAS_SAFETY_SERVICE = "has_safety" VEHICLE_LAST_UPDATE = "last_update" -VEHICLE_STATUS = "status" +VEHICLE_STATUS = "vehicle_status" API_GEN_1 = "g1" API_GEN_2 = "g2" +API_GEN_3 = "g3" MANUFACTURER = "Subaru" PLATFORMS = [ diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 1aade94654..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.0"] + "requirements": ["subarulink==0.7.6"] } diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index 5479f56cf9..6c8e8fc100 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -31,12 +31,12 @@ from homeassistant.util.unit_system import ( from . import get_device_info from .const import ( API_GEN_2, + API_GEN_3, DOMAIN, ENTRY_COORDINATOR, ENTRY_VEHICLES, VEHICLE_API_GEN, VEHICLE_HAS_EV, - VEHICLE_HAS_SAFETY_SERVICE, VEHICLE_STATUS, VEHICLE_VIN, ) @@ -51,7 +51,7 @@ FUEL_CONSUMPTION_MILES_PER_GALLON = "mi/gal" L_PER_GAL = VolumeConverter.convert(1, UnitOfVolume.GALLONS, UnitOfVolume.LITERS) KM_PER_MI = DistanceConverter.convert(1, UnitOfLength.MILES, UnitOfLength.KILOMETERS) -# Sensor available to "Subaru Safety Plus" subscribers with Gen1 or Gen2 vehicles +# Sensor available for Gen1 or Gen2 vehicles SAFETY_SENSORS = [ SensorEntityDescription( key=sc.ODOMETER, @@ -63,7 +63,7 @@ SAFETY_SENSORS = [ ), ] -# Sensors available to "Subaru Safety Plus" subscribers with Gen2 vehicles +# Sensors available to subscribers with Gen2/Gen3 vehicles API_GEN_2_SENSORS = [ SensorEntityDescription( key=sc.AVG_FUEL_CONSUMPTION, @@ -110,7 +110,18 @@ API_GEN_2_SENSORS = [ ), ] -# Sensors available to "Subaru Safety Plus" subscribers with PHEV vehicles +# Sensors available for Gen3 vehicles +API_GEN_3_SENSORS = [ + SensorEntityDescription( + key=sc.REMAINING_FUEL_PERCENT, + icon="mdi:gas-station", + name="Fuel level", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), +] + +# Sensors available to subscribers with PHEV vehicles EV_SENSORS = [ SensorEntityDescription( key=sc.EV_DISTANCE_TO_EMPTY, @@ -156,14 +167,16 @@ def create_vehicle_sensors( ) -> list[SubaruSensor]: """Instantiate all available sensors for the vehicle.""" sensor_descriptions_to_add = [] - if vehicle_info[VEHICLE_HAS_SAFETY_SERVICE]: - sensor_descriptions_to_add.extend(SAFETY_SENSORS) + sensor_descriptions_to_add.extend(SAFETY_SENSORS) - if vehicle_info[VEHICLE_API_GEN] == API_GEN_2: - sensor_descriptions_to_add.extend(API_GEN_2_SENSORS) + if vehicle_info[VEHICLE_API_GEN] in [API_GEN_2, API_GEN_3]: + sensor_descriptions_to_add.extend(API_GEN_2_SENSORS) - if vehicle_info[VEHICLE_HAS_EV]: - sensor_descriptions_to_add.extend(EV_SENSORS) + if vehicle_info[VEHICLE_API_GEN] == API_GEN_3: + sensor_descriptions_to_add.extend(API_GEN_3_SENSORS) + + if vehicle_info[VEHICLE_HAS_EV]: + sensor_descriptions_to_add.extend(EV_SENSORS) return [ SubaruSensor( 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..344e0c2179 --- /dev/null +++ b/homeassistant/components/sun/sensor.py @@ -0,0 +1,143 @@ +"""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, EntityCategory +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 + _attr_entity_category = EntityCategory.DIAGNOSTIC + 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 cdcaa416ed..3d0374f1de 100644 --- a/homeassistant/components/sun/strings.json +++ b/homeassistant/components/sun/strings.json @@ -10,10 +10,25 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, - "state": { + "entity_component": { "_": { - "above_horizon": "Above horizon", - "below_horizon": "Below horizon" + "name": "[%key:component::sun::title%]", + "state": { + "above_horizon": "Above horizon", + "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/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 29da03b262..900117a54b 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -36,7 +36,7 @@ def get_scanner( class SwisscomDeviceScanner(DeviceScanner): - """This class queries a router running Swisscom Internet-Box firmware.""" + """Class which queries a router running Swisscom Internet-Box firmware.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index 7ea84e649e..a7934ba420 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -16,10 +16,19 @@ "turned_off": "{entity_name} turned off" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "name": "[%key:component::switch::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "switch": { + "name": "[%key:component::switch::entity_component::_::name%]" + }, + "outlet": { + "name": "Outlet" } } } diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index 102319cec9..ef64a86c6e 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -5,6 +5,7 @@ import logging import voluptuous as vol +from homeassistant.components.homeassistant import exposed_entities from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import Event, HomeAssistant, callback @@ -104,17 +105,39 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Unload a config entry.""" - # Unhide the wrapped entry if registered + """Unload a config entry. + + This will unhide the wrapped entity and restore assistant expose settings. + """ registry = er.async_get(hass) try: - entity_id = er.async_validate_entity_id(registry, entry.options[CONF_ENTITY_ID]) + switch_entity_id = er.async_validate_entity_id( + registry, entry.options[CONF_ENTITY_ID] + ) except vol.Invalid: # The source entity has been removed from the entity registry return - if not (entity_entry := registry.async_get(entity_id)): + if not (switch_entity_entry := registry.async_get(switch_entity_id)): return - if entity_entry.hidden_by == er.RegistryEntryHider.INTEGRATION: - registry.async_update_entity(entity_id, hidden_by=None) + # Unhide the wrapped entity + if switch_entity_entry.hidden_by == er.RegistryEntryHider.INTEGRATION: + registry.async_update_entity(switch_entity_id, hidden_by=None) + + switch_as_x_entries = er.async_entries_for_config_entry(registry, entry.entry_id) + if not switch_as_x_entries: + return + + switch_as_x_entry = switch_as_x_entries[0] + + # Restore assistant expose settings + expose_settings = exposed_entities.async_get_entity_settings( + hass, switch_as_x_entry.entity_id + ) + for assistant, settings in expose_settings.items(): + if (should_expose := settings.get("should_expose")) is None: + continue + exposed_entities.async_expose_entity( + hass, assistant, switch_entity_id, should_expose + ) diff --git a/homeassistant/components/switch_as_x/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 bc24460a10..a73271bdc8 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -10,34 +11,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 = None, - entity_category: EntityCategory | None = 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.""" @@ -50,7 +74,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: @@ -67,10 +91,46 @@ 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 or not ( + wrapped_switch := registry.async_get(self._switch_entity_id) + ): + return + + def copy_custom_name(wrapped_switch: er.RegistryEntry) -> None: + """Copy the name set by user from the wrapped entity.""" + if wrapped_switch.name is None: + return + registry.async_update_entity(self.entity_id, name=wrapped_switch.name) + + def copy_expose_settings() -> None: + """Copy assistant expose settings from the wrapped entity. + + Also unexpose the wrapped entity if exposed. + """ + expose_settings = exposed_entities.async_get_entity_settings( + self.hass, self._switch_entity_id + ) + for assistant, settings in expose_settings.items(): + if (should_expose := settings.get("should_expose")) is None: + continue + exposed_entities.async_expose_entity( + self.hass, assistant, self.entity_id, should_expose + ) + exposed_entities.async_expose_entity( + self.hass, assistant, self._switch_entity_id, False + ) + + copy_custom_name(wrapped_switch) + copy_expose_settings() class BaseToggleEntity(BaseEntity, ToggleEntity): diff --git a/homeassistant/components/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..0a0cf40ca8 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_unlocked_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/humidifier.py b/homeassistant/components/switchbot/humidifier.py index 2bb71bacea..148e4c3545 100644 --- a/homeassistant/components/switchbot/humidifier.py +++ b/homeassistant/components/switchbot/humidifier.py @@ -14,7 +14,7 @@ from homeassistant.components.humidifier import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import SwitchbotDataUpdateCoordinator @@ -25,9 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Switchbot based on a config entry.""" coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 16d4e1cccd..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.1"] + "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/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index 67749ea0c5..befbf00f8b 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import switchbot @@ -9,7 +10,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import DOMAIN @@ -22,9 +23,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Switchbot based on a config entry.""" coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] @@ -63,7 +62,7 @@ class SwitchBotSwitch(SwitchbotSwitchedEntity, SwitchEntity, RestoreEntity): return self._device.is_on() @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { **super().extra_state_attributes, diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index bc35298979..abb18a19ed 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_DEVICE_ID, EVENT_HOMEASSISTANT_STOP, Platfo from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, - device_registry, + device_registry as dr, update_coordinator, ) from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -165,10 +165,10 @@ class SwitcherDataUpdateCoordinator( @callback def async_setup(self) -> None: """Set up the coordinator.""" - dev_reg = device_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) dev_reg.async_get_or_create( config_entry_id=self.entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac_address)}, + connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}, identifiers={(DOMAIN, self.device_id)}, manufacturer="Switcher", name=self.name, diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index a8e4f503d1..ec2f4c0bc9 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -132,9 +132,7 @@ class SwitcherThermostatButtonEntity( self._attr_name = f"{coordinator.name} {description.name}" self._attr_unique_id = f"{coordinator.mac_address}-{description.key}" self._attr_device_info = DeviceInfo( - connections={ - (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) - } + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) async def async_press(self) -> None: diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index 57d4d9977f..be966d67ee 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -29,7 +29,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -94,9 +94,7 @@ class SwitcherClimateEntity( self._attr_name = coordinator.name self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_device_info = DeviceInfo( - connections={ - (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) - } + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) self._attr_min_temp = remote.min_temperature diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index 584f3d7124..1d72184ad4 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -17,7 +17,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -70,9 +70,7 @@ class SwitcherCoverEntity( self._attr_name = coordinator.name self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_device_info = DeviceInfo( - connections={ - (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) - } + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) self._update_data() diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 422adf6c51..823f2c5463 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -1,11 +1,11 @@ { "domain": "switcher_kis", "name": "Switcher", - "codeowners": ["@tomerfi", "@thecode"], + "codeowners": ["@thecode"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/switcher_kis/", "iot_class": "local_push", "loggers": ["aioswitcher"], "quality_scale": "platinum", - "requirements": ["aioswitcher==3.2.1"] + "requirements": ["aioswitcher==3.3.0"] } diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index c75d27d67d..2c74f14cb5 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfElectricCurrent, UnitOfPower from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -118,9 +118,7 @@ class SwitcherSensorEntity( f"{coordinator.device_id}-{coordinator.mac_address}-{attribute}" ) self._attr_device_info = { - "connections": { - (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) - } + "connections": {(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} } @property diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 9d1b5d4bdc..caed3c3c32 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, - device_registry, + device_registry as dr, entity_platform, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -92,9 +92,7 @@ class SwitcherBaseSwitchEntity( self._attr_name = coordinator.name self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_device_info = DeviceInfo( - connections={ - (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) - } + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) @callback diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index c17a26794d..ecda3addcb 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -147,8 +147,10 @@ async def async_remove_config_entry_device( api = data.api serial = api.information.serial storage = api.storage - # get_all_cameras does not do I/O - all_cameras: list[SynoCamera] = api.surveillance_station.get_all_cameras() + all_cameras: list[SynoCamera] = [] + if api.surveillance_station is not None: + # get_all_cameras does not do I/O + all_cameras = api.surveillance_station.get_all_cameras() device_ids = chain( (camera.id for camera in all_cameras), storage.volumes_ids, 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/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 9342849b2f..36eb37b788 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Mapping -from ipaddress import ip_address +from ipaddress import ip_address as ip import logging from typing import Any, cast from urllib.parse import urlparse @@ -38,6 +38,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.util.network import is_ip_address as is_ip from .const import ( CONF_DEVICE_TOKEN, @@ -99,14 +100,6 @@ def _ordered_shared_schema( } -def _is_valid_ip(text: str) -> bool: - try: - ip_address(text) - except ValueError: - return False - return True - - def format_synology_mac(mac: str) -> str: """Format a mac address to the format used by Synology DSM.""" return mac.replace(":", "").replace("-", "").upper() @@ -284,16 +277,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): break self._abort_if_unique_id_configured() - fqdn_with_ssl_verification = ( - existing_entry - and not _is_valid_ip(existing_entry.data[CONF_HOST]) - and existing_entry.data[CONF_VERIFY_SSL] - ) - if ( existing_entry + and is_ip(existing_entry.data[CONF_HOST]) + and is_ip(host) and existing_entry.data[CONF_HOST] != host - and not fqdn_with_ssl_verification + and ip(existing_entry.data[CONF_HOST]).version == ip(host).version ): _LOGGER.info( "Update host from '%s' to '%s' for NAS '%s' via discovery", diff --git a/homeassistant/components/synology_dsm/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 06bfd166bb..2929864732 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation @@ -26,6 +25,7 @@ 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 . import SynoApi @@ -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, @@ -349,27 +369,20 @@ class SynoDSMUtilSensor(SynoDSMSensor): """Representation a Synology Utilisation sensor.""" @property - def native_value(self) -> Any | None: + def native_value(self) -> StateType: """Return the state.""" attr = getattr(self._api.utilisation, self.entity_description.key) if callable(attr): attr = attr() - if attr is None: - return None - - # Data (RAM) - if self.native_unit_of_measurement == UnitOfInformation.MEGABYTES: - return round(attr / 1024.0**2, 1) - - # Network - if self.native_unit_of_measurement == UnitOfDataRate.KILOBYTES_PER_SECOND: - return round(attr / 1024.0, 1) # CPU load average - if self.native_unit_of_measurement == ENTITY_UNIT_LOAD: + if ( + isinstance(attr, int) + and self.native_unit_of_measurement == ENTITY_UNIT_LOAD + ): return round(attr / 100, 2) - return attr + return attr # type: ignore[no-any-return] @property def available(self) -> bool: @@ -393,17 +406,10 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor): super().__init__(api, coordinator, description, device_id) @property - def native_value(self) -> Any | None: + 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) - - return attr + return attr # type: ignore[no-any-return] class SynoDSMInfoSensor(SynoDSMSensor): @@ -421,7 +427,7 @@ class SynoDSMInfoSensor(SynoDSMSensor): self._last_boot: datetime | None = None @property - def native_value(self) -> Any | None: + def native_value(self) -> StateType | datetime: """Return the state.""" attr = getattr(self._api.information, self.entity_description.key) if attr is None: @@ -434,4 +440,4 @@ class SynoDSMInfoSensor(SynoDSMSensor): self._previous_uptime = attr return self._last_boot - return attr + 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/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py index 15c61ff0a3..e67f7ecf34 100644 --- a/homeassistant/components/synology_srm/device_tracker.py +++ b/homeassistant/components/synology_srm/device_tracker.py @@ -80,7 +80,7 @@ def get_scanner( class SynologySrmDeviceScanner(DeviceScanner): - """This class scans for devices connected to a Synology SRM router.""" + """Scanner for devices connected to a Synology SRM router.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index a8d3a4372c..1c5d4a4204 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -30,7 +30,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceClassName, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MODULES @@ -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, @@ -275,13 +279,17 @@ class SystemBridgeEntity(CoordinatorEntity[SystemBridgeDataUpdateCoordinator]): coordinator: SystemBridgeDataUpdateCoordinator, api_port: int, key: str, - name: str | None, + name: str | DeviceClassName | None, ) -> None: """Initialize the System Bridge entity.""" super().__init__(coordinator) self._hostname = coordinator.data.system.hostname self._key = f"{self._hostname}_{key}" + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME as name. + assert name is not DEVICE_CLASS_NAME self._name = f"{self._hostname} {name}" self._configuration_url = ( f"http://{self._hostname}:{api_port}/app/settings.html" diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 8feb111428..bb83d90235 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -29,7 +29,7 @@ class SystemBridgeBinarySensorEntityDescription(BinarySensorEntityDescription): BASE_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] = ( SystemBridgeBinarySensorEntityDescription( key="version_available", - name="New Version Available", + name="New version available", device_class=BinarySensorDeviceClass.UPDATE, value=lambda data: data.system.version_newer_available, ), @@ -38,7 +38,7 @@ BASE_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] BATTERY_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] = ( SystemBridgeBinarySensorEntityDescription( key="battery_is_charging", - name="Battery Is Charging", + name="Battery is charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, value=lambda data: data.battery.is_charging, ), 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 320c09a6f0..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, @@ -186,6 +187,15 @@ class SystemBridgeDataUpdateCoordinator( await self.websocket_client.connect( session=async_get_clientsession(self.hass), ) + + 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) + ) except AuthenticationException as exception: self.last_update_success = False self.logger.error("Authentication failed for %s: %s", self.title, exception) @@ -211,12 +221,6 @@ class SystemBridgeDataUpdateCoordinator( self.last_update_success = False self.async_update_listeners() - self.hass.async_create_task(self._listen_for_data()) - - await self.websocket_client.register_data_listener( - RegisterDataListener(modules=MODULES) - ) - self.last_update_success = True self.async_update_listeners() diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 6146a32fd8..7462966ae3 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["systembridgeconnector"], "quality_scale": "silver", - "requirements": ["systembridgeconnector==3.4.4"], + "requirements": ["systembridgeconnector==3.4.8"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index bc02c9f1cd..a6bf29ac54 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -51,8 +51,8 @@ class SystemBridgeSensorEntityDescription(SensorEntityDescription): def battery_time_remaining(data: SystemBridgeCoordinatorData) -> datetime | None: """Return the battery time remaining.""" - if data.battery.sensors_secsleft is not None: - return utcnow() + timedelta(seconds=data.battery.sensors_secsleft) + if (value := getattr(data.battery, "sensors_secsleft", None)) is not None: + return utcnow() + timedelta(seconds=value) return None @@ -65,29 +65,29 @@ def cpu_speed(data: SystemBridgeCoordinatorData) -> float | None: def gpu_core_clock_speed(data: SystemBridgeCoordinatorData, key: str) -> float | None: """Return the GPU core clock speed.""" - if getattr(data.gpu, f"{key}_core_clock") is not None: - return round(getattr(data.gpu, f"{key}_core_clock")) + if (value := getattr(data.gpu, f"{key}_core_clock", None)) is not None: + return round(value) return None def gpu_memory_clock_speed(data: SystemBridgeCoordinatorData, key: str) -> float | None: """Return the GPU memory clock speed.""" - if getattr(data.gpu, f"{key}_memory_clock") is not None: - return round(getattr(data.gpu, f"{key}_memory_clock")) + if (value := getattr(data.gpu, f"{key}_memory_clock", None)) is not None: + return round(value) return None def gpu_memory_free(data: SystemBridgeCoordinatorData, key: str) -> float | None: """Return the free GPU memory.""" - if getattr(data.gpu, f"{key}_memory_free") is not None: - return round(getattr(data.gpu, f"{key}_memory_free") / 10**3, 2) + if (value := getattr(data.gpu, f"{key}_memory_free", None)) is not None: + return round(value) return None def gpu_memory_used(data: SystemBridgeCoordinatorData, key: str) -> float | None: """Return the used GPU memory.""" - if getattr(data.gpu, f"{key}_memory_used") is not None: - return round(getattr(data.gpu, f"{key}_memory_used") / 10**3, 2) + if (value := getattr(data.gpu, f"{key}_memory_used", None)) is not None: + return round(value) return None @@ -95,14 +95,11 @@ def gpu_memory_used_percentage( data: SystemBridgeCoordinatorData, key: str ) -> float | None: """Return the used GPU memory percentage.""" - if ( - getattr(data.gpu, f"{key}_memory_used") is not None - and getattr(data.gpu, f"{key}_memory_total") is not None + if ((used := getattr(data.gpu, f"{key}_memory_used", None)) is not None) and ( + (total := getattr(data.gpu, f"{key}_memory_total", None)) is not None ): return round( - getattr(data.gpu, f"{key}_memory_used") - / getattr(data.gpu, f"{key}_memory_total") - * 100, + used / total * 100, 2, ) return None @@ -125,7 +122,7 @@ def memory_used(data: SystemBridgeCoordinatorData) -> float | None: BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( SystemBridgeSensorEntityDescription( key="boot_time", - name="Boot Time", + name="Boot time", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:av-timer", value=lambda data: datetime.fromtimestamp( @@ -134,7 +131,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="cpu_speed", - name="CPU Speed", + name="CPU speed", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.GIGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, @@ -143,7 +140,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="cpu_temperature", - name="CPU Temperature", + name="CPU temperature", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -152,7 +149,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="cpu_voltage", - name="CPU Voltage", + name="CPU voltage", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -162,13 +159,12 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( SystemBridgeSensorEntityDescription( key="kernel", name="Kernel", - state_class=SensorStateClass.MEASUREMENT, icon="mdi:devices", value=lambda data: data.system.platform, ), SystemBridgeSensorEntityDescription( key="memory_free", - name="Memory Free", + name="Memory free", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -177,7 +173,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="memory_used_percentage", - name="Memory Used %", + name="Memory used %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", @@ -185,7 +181,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="memory_used", - name="Memory Used", + name="Memory used", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, @@ -195,8 +191,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="os", - name="Operating System", - state_class=SensorStateClass.MEASUREMENT, + name="Operating system", icon="mdi:devices", value=lambda data: f"{data.system.platform} {data.system.platform_version}", ), @@ -216,7 +211,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="version_latest", - name="Latest Version", + name="Latest version", icon="mdi:counter", value=lambda data: data.system.version_latest, ), @@ -233,9 +228,8 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="battery_time_remaining", - name="Battery Time Remaining", + name="Battery time remaining", device_class=SensorDeviceClass.TIMESTAMP, - state_class=SensorStateClass.MEASUREMENT, value=battery_time_remaining, ), ) @@ -261,12 +255,12 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"filesystem_{partition.replace(':', '')}", - name=f"{partition} Space Used", + name=f"{partition} space used", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", value=lambda data, p=partition: getattr( - data.disk, f"usage_{p}_percent" + data.disk, f"usage_{p}_percent", None ), ), entry.data[CONF_PORT], @@ -283,15 +277,17 @@ async def async_setup_entry( SystemBridgeSensor(coordinator, description, entry.data[CONF_PORT]) ) - displays = [] - for display in coordinator.data.display.displays: - displays.append( + displays: list[dict[str, str]] = [] + if coordinator.data.display.displays is not None: + displays.extend( { "key": display, "name": getattr(coordinator.data.display, f"{display}_name").replace( "Display ", "" ), - }, + } + for display in coordinator.data.display.displays + if hasattr(coordinator.data.display, f"{display}_name") ) display_count = len(displays) @@ -300,7 +296,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key="displays_connected", - name="Displays Connected", + name="Displays connected", state_class=SensorStateClass.MEASUREMENT, icon="mdi:monitor", value=lambda _, count=display_count: count, @@ -316,12 +312,12 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"display_{display['name']}_resolution_x", - name=f"Display {display['name']} Resolution X", + name=f"Display {display['name']} resolution x", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PIXELS, icon="mdi:monitor", value=lambda data, k=display["key"]: getattr( - data.display, f"{k}_resolution_horizontal" + data.display, f"{k}_resolution_horizontal", None ), ), entry.data[CONF_PORT], @@ -330,12 +326,12 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"display_{display['name']}_resolution_y", - name=f"Display {display['name']} Resolution Y", + name=f"Display {display['name']} resolution y", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PIXELS, icon="mdi:monitor", value=lambda data, k=display["key"]: getattr( - data.display, f"{k}_resolution_vertical" + data.display, f"{k}_resolution_vertical", None ), ), entry.data[CONF_PORT], @@ -344,26 +340,28 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"display_{display['name']}_refresh_rate", - name=f"Display {display['name']} Refresh Rate", + name=f"Display {display['name']} refresh rate", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, icon="mdi:monitor", value=lambda data, k=display["key"]: getattr( - data.display, f"{k}_refresh_rate" + data.display, f"{k}_refresh_rate", None ), ), entry.data[CONF_PORT], ), ] - gpus = [] - for gpu in coordinator.data.gpu.gpus: - gpus.append( + gpus: list[dict[str, str]] = [] + if coordinator.data.gpu.gpus is not None: + gpus.extend( { "key": gpu, "name": getattr(coordinator.data.gpu, f"{gpu}_name"), - }, + } + for gpu in coordinator.data.gpu.gpus + if hasattr(coordinator.data.gpu, f"{gpu}_name") ) for index, gpu in enumerate(gpus): @@ -373,7 +371,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_core_clock_speed", - name=f"{gpu['name']} Clock Speed", + name=f"{gpu['name']} clock speed", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, @@ -387,7 +385,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_memory_clock_speed", - name=f"{gpu['name']} Memory Clock Speed", + name=f"{gpu['name']} memory clock speed", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, @@ -401,7 +399,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_memory_free", - name=f"{gpu['name']} Memory Free", + name=f"{gpu['name']} memory free", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -414,7 +412,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_memory_used_percentage", - name=f"{gpu['name']} Memory Used %", + name=f"{gpu['name']} memory used %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", @@ -428,7 +426,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_memory_used", - name=f"{gpu['name']} Memory Used", + name=f"{gpu['name']} memory used", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, @@ -442,13 +440,13 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_fan_speed", - name=f"{gpu['name']} Fan Speed", + name=f"{gpu['name']} fan speed", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, icon="mdi:fan", value=lambda data, k=gpu["key"]: getattr( - data.gpu, f"{k}_fan_speed" + data.gpu, f"{k}_fan_speed", None ), ), entry.data[CONF_PORT], @@ -457,12 +455,14 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_power_usage", - name=f"{gpu['name']} Power Usage", + name=f"{gpu['name']} power usage", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, - value=lambda data, k=gpu["key"]: getattr(data.gpu, f"{k}_power"), + value=lambda data, k=gpu["key"]: getattr( + data.gpu, f"{k}_power", None + ), ), entry.data[CONF_PORT], ), @@ -470,13 +470,13 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_temperature", - name=f"{gpu['name']} Temperature", + name=f"{gpu['name']} temperature", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value=lambda data, k=gpu["key"]: getattr( - data.gpu, f"{k}_temperature" + data.gpu, f"{k}_temperature", None ), ), entry.data[CONF_PORT], @@ -485,12 +485,12 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_usage_percentage", - name=f"{gpu['name']} Usage %", + name=f"{gpu['name']} usage %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", value=lambda data, k=gpu["key"]: getattr( - data.gpu, f"{k}_core_load" + data.gpu, f"{k}_core_load", None ), ), entry.data[CONF_PORT], @@ -509,7 +509,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", - value=lambda data, k=index: getattr(data.cpu, f"usage_{k}"), + value=lambda data, k=index: getattr(data.cpu, f"usage_{k}", None), ), entry.data[CONF_PORT], ), 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/__init__.py b/homeassistant/components/tado/__init__.py index 9146d4d83d..691ca63965 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -268,6 +268,7 @@ class TadoConnector: self.tado.setAway() elif presence == PRESET_HOME: self.tado.setHome() + self.update_zones() def set_zone_overlay( self, 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/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 72eb9c8e28..4d50bc35c3 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -48,7 +48,7 @@ Device = namedtuple("Device", ["mac", "name"]) class TadoDeviceScanner(DeviceScanner): - """This class gets geofenced devices from Tado.""" + """Scanner for geofenced devices from Tado.""" def __init__(self, hass, config): """Initialize the scanner.""" 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 8f58587939..211ae4cd1f 100644 --- a/homeassistant/components/tado/services.yaml +++ b/homeassistant/components/tado/services.yaml @@ -21,7 +21,6 @@ set_climate_timer: description: Choose this or Overlay. Set the time period for the change if you want to be specific. Alternatively use Overlay required: false example: "01:30:00" - default: "01:00:00" selector: text: requested_overlay: @@ -29,7 +28,6 @@ set_climate_timer: description: Choose this or Time Period. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on tado app setting required: false example: "MANUAL" - default: "TADO_DEFAULT" selector: select: options: @@ -43,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/manifest.json b/homeassistant/components/tasmota/manifest.json index 5360a5cda0..a5a8ed2f0d 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["hatasmota==0.6.3"] + "requirements": ["hatasmota==0.6.5"] } 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/tellduslive/config_flow.py b/homeassistant/components/tellduslive/config_flow.py index a181bce11f..c87b3998a2 100644 --- a/homeassistant/components/tellduslive/config_flow.py +++ b/homeassistant/components/tellduslive/config_flow.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_HOST -from homeassistant.util.json import load_json +from homeassistant.util.json import load_json_object from .const import ( APPLICATION_NAME, @@ -137,7 +137,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user() conf = await self.hass.async_add_executor_job( - load_json, self.hass.config.path(TELLDUS_CONFIG_FILE) + load_json_object, self.hass.config.path(TELLDUS_CONFIG_FILE) ) host = next(iter(conf)) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index e36ca75ed0..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" @@ -172,7 +173,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._tilt_script = Script(hass, tilt_action, friendly_name, DOMAIN) optimistic = config.get(CONF_OPTIMISTIC) self._optimistic = optimistic or ( - not self._template and not self._position_template + optimistic is None and not self._template and not self._position_template ) tilt_optimistic = config.get(CONF_TILT_OPTIMISTIC) self._tilt_optimistic = tilt_optimistic or not self._tilt_template @@ -233,8 +234,15 @@ class CoverTemplate(TemplateEntity, CoverEntity): if not self._position_template: self._position = None + self._is_opening = False + self._is_closing = False + @callback def _update_position(self, result): + if result is None: + self._position = None + return + try: state = float(result) except ValueError as err: @@ -253,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: @@ -270,8 +282,11 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._tilt_value = state @property - def is_closed(self) -> bool: + def is_closed(self) -> bool | None: """Return if the cover is closed.""" + if self._position is None: + return None + return self._position == 0 @property 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/text/device_action.py b/homeassistant/components/text/device_action.py index 3d14da9bdb..89fbbc7fbc 100644 --- a/homeassistant/components/text/device_action.py +++ b/homeassistant/components/text/device_action.py @@ -11,7 +11,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -32,11 +32,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Text.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions: list[dict[str, str]] = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json index 0f5ddf5b33..034f1ab315 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -4,5 +4,28 @@ "action_type": { "set_value": "Set value for {entity_name}" } + }, + "entity_component": { + "_": { + "name": "[%key:component::text::title%]", + "state_attributes": { + "max": { + "name": "Max length" + }, + "min": { + "name": "Min length" + }, + "mode": { + "name": "Mode", + "state": { + "text": "Text", + "password": "Password" + } + }, + "pattern": { + "name": "Pattern" + } + } + } } } diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index 4af21ec8e1..e42ee4478e 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -46,7 +46,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> ThomsonDeviceScanner class ThomsonDeviceScanner(DeviceScanner): - """This class queries a router running THOMSON firmware.""" + """Class which queries a router running THOMSON firmware.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/thread/__init__.py b/homeassistant/components/thread/__init__.py index 345fca854d..4fc8847981 100644 --- a/homeassistant/components/thread/__init__.py +++ b/homeassistant/components/thread/__init__.py @@ -6,13 +6,19 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .dataset_store import DatasetEntry, async_add_dataset, async_get_preferred_dataset +from .dataset_store import ( + DatasetEntry, + async_add_dataset, + async_get_dataset, + async_get_preferred_dataset, +) from .websocket_api import async_setup as async_setup_ws_api __all__ = [ "DOMAIN", "DatasetEntry", "async_add_dataset", + "async_get_dataset", "async_get_preferred_dataset", ] diff --git a/homeassistant/components/thread/config_flow.py b/homeassistant/components/thread/config_flow.py index 978b4c1077..b294dfa51e 100644 --- a/homeassistant/components/thread/config_flow.py +++ b/homeassistant/components/thread/config_flow.py @@ -1,6 +1,9 @@ """Config flow for the Thread integration.""" from __future__ import annotations +from typing import Any + +from homeassistant.components import onboarding, zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.data_entry_flow import FlowResult @@ -16,4 +19,27 @@ class ThreadConfigFlow(ConfigFlow, domain=DOMAIN): self, import_data: dict[str, str] | None = None ) -> FlowResult: """Set up by import from async_setup.""" + await self._async_handle_discovery_without_unique_id() return self.async_create_entry(title="Thread", data={}) + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Set up by import from async_setup.""" + await self._async_handle_discovery_without_unique_id() + return self.async_create_entry(title="Thread", data={}) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Set up because the user has border routers.""" + await self._async_handle_discovery_without_unique_id() + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm the setup.""" + if user_input is not None or not onboarding.async_is_onboarded(self.hass): + return self.async_create_entry(title="Thread", data={}) + return self.async_show_form(step_id="confirm") diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index b9a27b617e..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 @@ -159,6 +184,14 @@ async def async_add_dataset(hass: HomeAssistant, source: str, tlv: str) -> None: store.async_add(source, tlv) +async def async_get_dataset(hass: HomeAssistant, dataset_id: str) -> str | None: + """Get a dataset.""" + store = await async_get_store(hass) + if (entry := store.async_get(dataset_id)) is None: + return None + return entry.tlv + + async def async_get_preferred_dataset(hass: HomeAssistant) -> str | None: """Get the preferred dataset.""" store = await async_get_store(hass) diff --git a/homeassistant/components/thread/diagnostics.py b/homeassistant/components/thread/diagnostics.py new file mode 100644 index 0000000000..8dc5dd4304 --- /dev/null +++ b/homeassistant/components/thread/diagnostics.py @@ -0,0 +1,220 @@ +"""Diagnostics support for Thread networks. + +When triaging Matter and HomeKit issues you often need to check for problems with the Thread network. + +This report helps spot and rule out: + +* Is the users border router visible at all? +* Is the border router actually announcing any routes? The user could have a network boundary like + VLANs or WiFi isolation that is blocking the RA packets. +* Alternatively, if user isn't on HAOS they could have accept_ra_rt_info_max_plen set incorrectly. +* Are there any bogus routes that could be interfering. If routes don't expire they can build up. + When you have 10 routes and only 2 border routers something has gone wrong. + +This does not do any connectivity checks. So user could have all their border routers visible, but +some of their thread accessories can't be pinged, but it's still a thread problem. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, TypedDict + +from python_otbr_api.tlv_parser import MeshcopTLVType + +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .dataset_store import async_get_store +from .discovery import async_read_zeroconf_cache + +if TYPE_CHECKING: + from pyroute2 import NDB + + +class Neighbour(TypedDict): + """A neighbour cache entry (ip neigh).""" + + lladdr: str + state: int + probes: int + + +class Route(TypedDict): + """A route table entry (ip -6 route).""" + + metrics: int + priority: int + is_nexthop: bool + + +class Router(TypedDict): + """A border router.""" + + server: str | None + addresses: list[str] + neighbours: dict[str, Neighbour] + thread_version: str | None + model: str | None + vendor: str | None + routes: dict[str, Route] + + +class Network(TypedDict): + """A thread network.""" + + name: str | None + routers: dict[str, Router] + prefixes: set[str] + unexpected_routers: set[str] + + +def _get_possible_thread_routes( + ndb: NDB, +) -> tuple[dict[str, dict[str, Route]], dict[str, set[str]]]: + # Build a list of possible thread routes + # Right now, this is ipv6 /64's that have a gateway + # We cross reference with zerconf data to confirm which via's are known border routers + routes: dict[str, dict[str, Route]] = {} + reverse_routes: dict[str, set[str]] = {} + + for record in ndb.routes: + # Limit to IPV6 routes + if record.family != 10: + continue + # Limit to /64 prefixes + if record.dst_len != 64: + continue + # Limit to routes with a via + if not record.gateway and not record.nh_gateway: + continue + gateway = record.gateway or record.nh_gateway + route = routes.setdefault(gateway, {}) + route[record.dst] = { + "metrics": record.metrics, + "priority": record.priority, + # NM creates "nexthop" routes - a single route with many via's + # Kernel creates many routes with a single via + "is_nexthop": record.nh_gateway is not None, + } + reverse_routes.setdefault(record.dst, set()).add(gateway) + return routes, reverse_routes + + +def _get_neighbours(ndb: NDB) -> dict[str, Neighbour]: + # Build a list of neighbours + neighbours: dict[str, Neighbour] = { + record.dst: { + "lladdr": record.lladdr, + "state": record.state, + "probes": record.probes, + } + for record in ndb.neighbours + } + return neighbours + + +def _get_routes_and_neighbors(): + """Get the routes and neighbours from pyroute2.""" + # Import in the executor since import NDB can take a while + from pyroute2 import ( # pylint: disable=no-name-in-module, import-outside-toplevel + NDB, + ) + + with NDB() as ndb: + routes, reverse_routes = _get_possible_thread_routes(ndb) + neighbours = _get_neighbours(ndb) + + return routes, reverse_routes, neighbours + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for all known thread networks.""" + networks: dict[str, Network] = {} + + # Start with all networks that HA knows about + store = await async_get_store(hass) + for record in store.datasets.values(): + if not record.extended_pan_id: + continue + network = networks.setdefault( + record.extended_pan_id, + { + "name": record.network_name, + "routers": {}, + "prefixes": set(), + "unexpected_routers": set(), + }, + ) + if mlp := record.dataset.get(MeshcopTLVType.MESHLOCALPREFIX): + network["prefixes"].add(f"{mlp[0:4]}:{mlp[4:8]}:{mlp[8:12]}:{mlp[12:16]}") + + # Find all routes currently act that might be thread related, so we can match them to + # border routers as we process the zeroconf data. + # + # Also find all neighbours + routes, reverse_routes, neighbours = await hass.async_add_executor_job( + _get_routes_and_neighbors + ) + + aiozc = await zeroconf.async_get_async_instance(hass) + for data in async_read_zeroconf_cache(aiozc): + if not data.extended_pan_id: + continue + + network = networks.setdefault( + data.extended_pan_id, + { + "name": data.network_name, + "routers": {}, + "prefixes": set(), + "unexpected_routers": set(), + }, + ) + + if not data.server: + continue + + router = network["routers"][data.server] = { + "server": data.server, + "addresses": data.addresses or [], + "neighbours": {}, + "thread_version": data.thread_version, + "model": data.model_name, + "vendor": data.vendor_name, + "routes": {}, + } + + # For every address this border router hass, see if we have seen + # it in the route table as a via - these are the routes its + # announcing via RA + if data.addresses: + for address in data.addresses: + if address in routes: + router["routes"].update(routes[address]) + + if address in neighbours: + router["neighbours"][address] = neighbours[address] + + network["prefixes"].update(router["routes"].keys()) + + # Find unexpected via's. + # Collect all router addresses and then for each prefix, find via's that aren't + # a known router for that prefix. + for network in networks.values(): + routers = set() + + for router in network["routers"].values(): + routers.update(router["addresses"]) + + for prefix in network["prefixes"]: + if prefix not in reverse_routes: + continue + if ghosts := reverse_routes[prefix] - routers: + network["unexpected_routers"] = ghosts + + return { + "networks": networks, + } diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 2001626ca1..1006a44d5d 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -4,9 +4,11 @@ from __future__ import annotations from collections.abc import Callable import dataclasses import logging +from typing import cast -from zeroconf import ServiceListener, Zeroconf -from zeroconf.asyncio import AsyncZeroconf +from python_otbr_api.mdns import StateBitmap +from zeroconf import BadTypeInNameException, DNSPointer, ServiceListener, Zeroconf +from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf from homeassistant.components import zeroconf from homeassistant.core import HomeAssistant @@ -15,24 +17,106 @@ _LOGGER = logging.getLogger(__name__) KNOWN_BRANDS: dict[str | None, str] = { "Apple Inc.": "apple", + "eero": "eero", "Google Inc.": "google", "HomeAssistant": "homeassistant", + "Home Assistant": "homeassistant", } THREAD_TYPE = "_meshcop._udp.local." +CLASS_IN = 1 +TYPE_PTR = 12 @dataclasses.dataclass class ThreadRouterDiscoveryData: """Thread router discovery data.""" + addresses: list[str] | None brand: str | None + extended_address: str | None extended_pan_id: str | None model_name: str | None network_name: str | None server: str | None + thread_version: str | None + unconfigured: bool | None vendor_name: str | None +def async_discovery_data_from_service( + service: AsyncServiceInfo, +) -> ThreadRouterDiscoveryData: + """Get a ThreadRouterDiscoveryData from an AsyncServiceInfo.""" + + def try_decode(value: bytes | None) -> str | None: + """Try decoding UTF-8.""" + if value is None: + return None + try: + return value.decode() + except UnicodeDecodeError: + return None + + ext_addr = service.properties.get(b"xa") + ext_pan_id = service.properties.get(b"xp") + network_name = try_decode(service.properties.get(b"nn")) + model_name = try_decode(service.properties.get(b"mn")) + server = service.server + vendor_name = try_decode(service.properties.get(b"vn")) + thread_version = try_decode(service.properties.get(b"tv")) + unconfigured = None + brand = KNOWN_BRANDS.get(vendor_name) + if brand == "homeassistant": + # Attempt to detect incomplete configuration + if (state_bitmap_b := service.properties.get(b"sb")) is not None: + try: + state_bitmap = StateBitmap.from_bytes(state_bitmap_b) + if not state_bitmap.is_active: + unconfigured = True + except ValueError: + _LOGGER.debug("Failed to decode state bitmap in service %s", service) + if service.properties.get(b"at") is None: + unconfigured = True + + return ThreadRouterDiscoveryData( + addresses=service.parsed_addresses(), + brand=brand, + extended_address=ext_addr.hex() if ext_addr is not None else None, + extended_pan_id=ext_pan_id.hex() if ext_pan_id is not None else None, + model_name=model_name, + network_name=network_name, + server=server, + thread_version=thread_version, + unconfigured=unconfigured, + vendor_name=vendor_name, + ) + + +def async_read_zeroconf_cache(aiozc: AsyncZeroconf) -> list[ThreadRouterDiscoveryData]: + """Return all meshcop records already in the zeroconf cache.""" + results = [] + + records = aiozc.zeroconf.cache.async_all_by_details(THREAD_TYPE, TYPE_PTR, CLASS_IN) + for record in records: + record = cast(DNSPointer, record) + + try: + info = AsyncServiceInfo(THREAD_TYPE, record.alias) + except BadTypeInNameException as ex: + _LOGGER.debug( + "Ignoring record with bad type in name: %s: %s", record.alias, ex + ) + continue + + if not info.load_from_cache(aiozc.zeroconf): + # data is not fully in the cache, so ignore for now + continue + + results.append(async_discovery_data_from_service(info)) + + return results + + class ThreadRouterDiscovery: """mDNS based Thread router discovery.""" @@ -83,15 +167,6 @@ class ThreadRouterDiscovery: _LOGGER.debug("_add_update_service failed to add %s, %s", type_, name) return - def try_decode(value: bytes | None) -> str | None: - """Try decoding UTF-8.""" - if value is None: - return None - try: - return value.decode() - except UnicodeDecodeError: - return None - _LOGGER.debug("_add_update_service %s %s", name, service) # We use the extended mac address as key, bail out if it's missing try: @@ -99,19 +174,8 @@ class ThreadRouterDiscovery: except (KeyError, UnicodeDecodeError) as err: _LOGGER.debug("_add_update_service failed to parse service %s", err) return - ext_pan_id = service.properties.get(b"xp") - network_name = try_decode(service.properties.get(b"nn")) - model_name = try_decode(service.properties.get(b"mn")) - server = service.server - vendor_name = try_decode(service.properties.get(b"vn")) - data = ThreadRouterDiscoveryData( - brand=KNOWN_BRANDS.get(vendor_name), - extended_pan_id=ext_pan_id.hex() if ext_pan_id is not None else None, - model_name=model_name, - network_name=network_name, - server=server, - vendor_name=vendor_name, - ) + + data = async_discovery_data_from_service(service) if name in self._known_routers and self._known_routers[name] == ( extended_mac_address, data, diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index a6e823de57..3d61315f3d 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,5 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==1.0.3"] + "requirements": ["python-otbr-api==1.0.9", "pyroute2==0.7.5"], + "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/components/thread/strings.json b/homeassistant/components/thread/strings.json new file mode 100644 index 0000000000..0a9cf0004b --- /dev/null +++ b/homeassistant/components/thread/strings.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + } + } +} diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 5edea3a61a..60941426b7 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -20,6 +20,7 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_discover_routers) websocket_api.async_register_command(hass, ws_get_dataset) websocket_api.async_register_command(hass, ws_list_datasets) + websocket_api.async_register_command(hass, ws_set_preferred_dataset) @websocket_api.require_admin @@ -49,6 +50,32 @@ async def ws_add_dataset( connection.send_result(msg["id"]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "thread/set_preferred_dataset", + vol.Required("dataset_id"): str, + } +) +@websocket_api.async_response +async def ws_set_preferred_dataset( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Add a thread dataset.""" + dataset_id = msg["dataset_id"] + + store = await dataset_store.async_get_store(hass) + try: + store.preferred_dataset = dataset_id + except KeyError: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "unknown dataset" + ) + return + + connection.send_result(msg["id"]) + + @websocket_api.require_admin @websocket_api.websocket_command( { @@ -118,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, @@ -160,7 +188,7 @@ async def ws_discover_routers( @callback def router_removed(key: str) -> None: - """Forward router discovery or update to websocket.""" + """Forward router removed to websocket.""" connection.send_message( websocket_api.event_message( diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 8cec85bf20..538655ec0c 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -2,12 +2,14 @@ from __future__ import annotations import logging +from typing import Any import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -19,7 +21,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, 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 @@ -93,12 +95,15 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Threshold sensor.""" - entity_id = config.get(CONF_ENTITY_ID) - name = config.get(CONF_NAME) - lower = config.get(CONF_LOWER) - upper = config.get(CONF_UPPER) - hysteresis = config.get(CONF_HYSTERESIS) - device_class = config.get(CONF_DEVICE_CLASS) + entity_id: str = config[CONF_ENTITY_ID] + name: str = config[CONF_NAME] + lower: float | None = config.get(CONF_LOWER) + upper: float | None = config.get(CONF_UPPER) + hysteresis: float = config[CONF_HYSTERESIS] + device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) + + if lower is None and upper is None: + raise ValueError("Lower or Upper thresholds not provided") async_add_entities( [ @@ -109,28 +114,47 @@ 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.""" _attr_should_poll = False def __init__( - self, hass, entity_id, name, lower, upper, hysteresis, device_class, unique_id - ): + self, + hass: HomeAssistant, + entity_id: str, + name: str, + lower: float | None, + upper: float | None, + hysteresis: float, + device_class: BinarySensorDeviceClass | None, + unique_id: str | None, + ) -> None: """Initialize the Threshold sensor.""" self._attr_unique_id = unique_id self._entity_id = entity_id self._name = name - self._threshold_lower = lower - self._threshold_upper = upper - self._hysteresis = hysteresis + 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 - self._state = None - self.sensor_value = None + self._state: bool | None = None + self.sensor_value: float | None = None - def _update_sensor_state(): + def _update_sensor_state() -> None: """Handle sensor state changes.""" if (new_state := hass.states.get(self._entity_id)) is None: return @@ -148,7 +172,7 @@ class ThresholdSensor(BinarySensorEntity): self._update_state() @callback - def async_threshold_sensor_state_listener(event): + def async_threshold_sensor_state_listener(event: Event) -> None: """Handle sensor state changes.""" _update_sensor_state() self.async_write_ha_state() @@ -161,82 +185,92 @@ class ThresholdSensor(BinarySensorEntity): _update_sensor_state() @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if sensor is on.""" return self._state @property - def device_class(self): + def device_class(self) -> BinarySensorDeviceClass | None: """Return the sensor class of the sensor.""" return self._device_class @property - def threshold_type(self): - """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 - if self._threshold_upper is not None: - return TYPE_UPPER - - @property - def extra_state_attributes(self): + 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 - def _update_state(self): + def _update_state(self) -> None: """Update the state.""" - def below(threshold): + def below(sensor_value: float, threshold: float) -> bool: """Determine if the sensor value is below a threshold.""" - return self.sensor_value < (threshold - self._hysteresis) + return sensor_value < (threshold - self._hysteresis) - def above(threshold): + def above(sensor_value: float, threshold: float) -> bool: """Determine if the sensor value is above a threshold.""" - return self.sensor_value > (threshold + self._hysteresis) + return sensor_value > (threshold + self._hysteresis) if self.sensor_value is None: self._state_position = POSITION_UNKNOWN - self._state = False + self._state = None + return - elif self.threshold_type == TYPE_LOWER: - if below(self._threshold_lower): + 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._threshold_lower): + elif above(self.sensor_value, self._threshold_lower): self._state_position = POSITION_ABOVE self._state = False + return - elif self.threshold_type == TYPE_UPPER: - if above(self._threshold_upper): + 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 above(self.sensor_value, self._threshold_upper): self._state_position = POSITION_ABOVE self._state = True - elif below(self._threshold_upper): + elif below(self.sensor_value, self._threshold_upper): self._state_position = POSITION_BELOW self._state = False + return - elif self.threshold_type == TYPE_RANGE: - if below(self._threshold_lower): + if self.threshold_type == TYPE_RANGE: + if self._state is None: + self._state = True + self._state_position = POSITION_IN_RANGE + + if below(self.sensor_value, self._threshold_lower): self._state_position = POSITION_BELOW self._state = False - if above(self._threshold_upper): + if above(self.sensor_value, self._threshold_upper): self._state_position = POSITION_ABOVE self._state = False - elif above(self._threshold_lower) and below(self._threshold_upper): + elif above(self.sensor_value, self._threshold_lower) and below( + self.sensor_value, self._threshold_upper + ): self._state_position = POSITION_IN_RANGE self._state = True + return diff --git a/homeassistant/components/threshold/config_flow.py b/homeassistant/components/threshold/config_flow.py index fbb1287230..31d51fee3f 100644 --- a/homeassistant/components/threshold/config_flow.py +++ b/homeassistant/components/threshold/config_flow.py @@ -76,4 +76,5 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" - return options[CONF_NAME] + name: str = options[CONF_NAME] + return name diff --git a/homeassistant/components/threshold/manifest.json b/homeassistant/components/threshold/manifest.json index f149bda05d..60ef45c845 100644 --- a/homeassistant/components/threshold/manifest.json +++ b/homeassistant/components/threshold/manifest.json @@ -1,7 +1,7 @@ { "domain": "threshold", "name": "Threshold", - "codeowners": ["@fabaff"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/threshold", "integration_type": "helper", diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 4d9c056068..6bd68e17c4 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -53,17 +53,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await tibber_connection.update_info() - if not tibber_connection.name: - raise ConfigEntryNotReady("Could not fetch Tibber data.") - except asyncio.TimeoutError as err: - raise ConfigEntryNotReady from err - except aiohttp.ClientError as err: - _LOGGER.error("Error connecting to Tibber: %s ", err) - return False + except ( + asyncio.TimeoutError, + aiohttp.ClientError, + tibber.RetryableHttpException, + ) as err: + raise ConfigEntryNotReady("Unable to connect") from err except tibber.InvalidLogin as exp: _LOGGER.error("Failed to login. %s", exp) return False + except tibber.FatalHttpException: + return False await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py index d0adc0391a..fbd2345fb8 100644 --- a/homeassistant/components/tibber/config_flow.py +++ b/homeassistant/components/tibber/config_flow.py @@ -16,6 +16,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) +ERR_TIMEOUT = "timeout" +ERR_CLIENT = "cannot_connect" +ERR_TOKEN = "invalid_access_token" class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -43,11 +46,15 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await tibber_connection.update_info() except asyncio.TimeoutError: - errors[CONF_ACCESS_TOKEN] = "timeout" - except aiohttp.ClientError: - errors[CONF_ACCESS_TOKEN] = "cannot_connect" + errors[CONF_ACCESS_TOKEN] = ERR_TIMEOUT except tibber.InvalidLogin: - errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + errors[CONF_ACCESS_TOKEN] = ERR_TOKEN + except ( + aiohttp.ClientError, + tibber.RetryableHttpException, + tibber.FatalHttpException, + ): + errors[CONF_ACCESS_TOKEN] = ERR_CLIENT if errors: return self.async_show_form( diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 0e23729df7..1b6c5e3045 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.26.13"] + "requirements": ["pyTibber==0.27.2"] } diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index dc6a58b6b8..a2f1db7536 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -6,7 +6,7 @@ import datetime from datetime import timedelta import logging from random import randrange -from typing import Any +from typing import Any, cast import aiohttp import tibber @@ -41,9 +41,11 @@ from homeassistant.helpers.device_registry import async_get as async_get_dev_reg from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_get as async_get_entity_reg +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, + UpdateFailed, ) from homeassistant.util import Throttle, dt as dt_util @@ -214,7 +216,6 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( key="month_cost", name="Monthly cost", device_class=SensorDeviceClass.MONETARY, - state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="peak_hour", @@ -426,9 +427,9 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity["TibberDataCoordinator"]) self._device_name = self._home_name @property - def native_value(self) -> Any: + def native_value(self) -> StateType: """Return the value of the sensor.""" - return getattr(self._tibber_home, self.entity_description.key) + return getattr(self._tibber_home, self.entity_description.key) # type: ignore[no-any-return] class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"]): @@ -560,6 +561,8 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): class TibberDataCoordinator(DataUpdateCoordinator[None]): """Handle Tibber data and insert statistics.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, tibber_connection: tibber.Tibber) -> None: """Initialize the data handler.""" super().__init__( @@ -572,9 +575,17 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Update data via API.""" - await self._tibber_connection.fetch_consumption_data_active_homes() - await self._tibber_connection.fetch_production_data_active_homes() - await self._insert_statistics() + try: + await self._tibber_connection.fetch_consumption_data_active_homes() + await self._tibber_connection.fetch_production_data_active_homes() + await self._insert_statistics() + except tibber.RetryableHttpException as err: + raise UpdateFailed(f"Error communicating with API ({err.status})") from err + except tibber.FatalHttpException: + # Fatal error. Reload config entry to show correct error. + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) async def _insert_statistics(self) -> None: """Insert Tibber statistics.""" @@ -604,7 +615,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): 5 * 365 * 24, production=is_production ) - _sum = 0 + _sum = 0.0 last_stats_time = None else: # hourly_consumption/production_data contains the last 30 days @@ -626,13 +637,14 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): self.hass, start, None, - [statistic_id], + {statistic_id}, "hour", None, {"sum"}, ) - _sum = stat[statistic_id][0]["sum"] - last_stats_time = stat[statistic_id][0]["start"] + first_stat = stat[statistic_id][0] + _sum = cast(float, first_stat["sum"]) + last_stats_time = first_stat["start"] statistics = [] 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/time/__init__.py b/homeassistant/components/time/__init__.py new file mode 100644 index 0000000000..26d40191fb --- /dev/null +++ b/homeassistant/components/time/__init__.py @@ -0,0 +1,109 @@ +"""Component to allow setting time as platforms.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import time, timedelta +import logging +from typing import final + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TIME +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, SERVICE_SET_VALUE + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +_LOGGER = logging.getLogger(__name__) + +__all__ = ["DOMAIN", "TimeEntity", "TimeEntityDescription"] + + +async def _async_set_value(entity: TimeEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new date.""" + return await entity.async_set_value(service_call.data[ATTR_TIME]) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Time entities.""" + component = hass.data[DOMAIN] = EntityComponent[TimeEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_SET_VALUE, {vol.Required(ATTR_TIME): cv.time}, _async_set_value + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[TimeEntity] = 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[TimeEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class TimeEntityDescription(EntityDescription): + """A class that describes time entities.""" + + +class TimeEntity(Entity): + """Representation of a Time entity.""" + + entity_description: TimeEntityDescription + _attr_native_value: time | None + _attr_device_class: None = None + _attr_state: None = None + + @property + @final + def device_class(self) -> None: + """Return the device class for the entity.""" + return None + + @property + @final + def state_attributes(self) -> None: + """Return the state attributes.""" + return None + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if self.native_value is None: + return None + return self.native_value.isoformat() + + @property + def native_value(self) -> time | None: + """Return the value reported by the time.""" + return self._attr_native_value + + def set_value(self, value: time) -> None: + """Change the time.""" + raise NotImplementedError() + + async def async_set_value(self, value: time) -> None: + """Change the time.""" + await self.hass.async_add_executor_job(self.set_value, value) diff --git a/homeassistant/components/time/const.py b/homeassistant/components/time/const.py new file mode 100644 index 0000000000..23901915c9 --- /dev/null +++ b/homeassistant/components/time/const.py @@ -0,0 +1,5 @@ +"""Provides the constants needed for the component.""" + +DOMAIN = "time" + +SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/time/manifest.json b/homeassistant/components/time/manifest.json new file mode 100644 index 0000000000..f3c243e000 --- /dev/null +++ b/homeassistant/components/time/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "time", + "name": "Time", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/time", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/time/services.yaml b/homeassistant/components/time/services.yaml new file mode 100644 index 0000000000..a8c843ab55 --- /dev/null +++ b/homeassistant/components/time/services.yaml @@ -0,0 +1,14 @@ +set_value: + name: Set Time + description: Set the time for a time entity. + target: + entity: + domain: time + fields: + time: + name: Time + description: The time to set. + required: true + example: "22:15" + selector: + time: diff --git a/homeassistant/components/time/strings.json b/homeassistant/components/time/strings.json new file mode 100644 index 0000000000..e8d92a30e2 --- /dev/null +++ b/homeassistant/components/time/strings.json @@ -0,0 +1,8 @@ +{ + "title": "Time", + "entity_component": { + "_": { + "name": "[%key:component::time::title%]" + } + } +} 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 985cea0aa6..217de09a53 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -1,9 +1,33 @@ { - "state": { + "entity_component": { "_": { - "active": "[%key:common::state::active%]", - "idle": "[%key:common::state::idle%]", - "paused": "[%key:common::state::paused%]" + "name": "Timer", + "state": { + "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 0a822d0515..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__) @@ -94,7 +94,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ), vol.Optional( CONF_PROJECT_LABEL_WHITELIST, default=[] - ): vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]), + ): vol.All(cv.ensure_list, [vol.All(cv.string)]), } ) ] @@ -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,15 +451,14 @@ class TodoistProjectData: LABELS: [], OVERDUE: False, PRIORITY: data.priority, - START: dt.utcnow(), + START: dt.now(), SUMMARY: data.content, } # All task Labels (optional parameter). task[LABELS] = [ - label.name.lower() for label in self._labels if label.id in data.labels + label.name for label in self._labels if label.name in data.labels ] - if self._label_whitelist and ( not any(label in task[LABELS] for label in self._label_whitelist) ): @@ -582,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 @@ -591,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 @@ -629,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: @@ -670,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/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py index e10bc3b81d..da64157dad 100644 --- a/homeassistant/components/tomato/device_tracker.py +++ b/homeassistant/components/tomato/device_tracker.py @@ -49,7 +49,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> TomatoDeviceScanner: class TomatoDeviceScanner(DeviceScanner): - """This class queries a wireless router running Tomato firmware.""" + """Class which queries a wireless router running Tomato firmware.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 2b7d466d2f..2321854374 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -32,6 +32,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DEVICE_CLASS_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from homeassistant.util.unit_conversion import DistanceConverter, SpeedConverter @@ -349,6 +350,10 @@ class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): """Initialize Tomorrow.io Sensor Entity.""" super().__init__(config_entry, coordinator, api_version) self.entity_description = description + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + assert description.name is not DEVICE_CLASS_NAME self._attr_name = f"{self._config_entry.data[CONF_NAME]} - {description.name}" self._attr_unique_id = ( f"{self._config_entry.unique_id}_{slugify(description.name)}" diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 3b06f5d38b..90dd466045 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -183,7 +183,7 @@ SENSOR_ENTITIES: tuple[ToonSensorEntityDescription, ...] = ( section="gas_usage", measurement="day_cost", device_class=SensorDeviceClass.MONETARY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, native_unit_of_measurement=CURRENCY_EUR, icon="mdi:gas-cylinder", cls=ToonGasMeterDeviceSensor, @@ -233,7 +233,7 @@ SENSOR_ENTITIES: tuple[ToonSensorEntityDescription, ...] = ( section="power_usage", measurement="day_cost", device_class=SensorDeviceClass.MONETARY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, native_unit_of_measurement=CURRENCY_EUR, icon="mdi:power-plug", cls=ToonElectricityMeterDeviceSensor, @@ -358,7 +358,7 @@ SENSOR_ENTITIES: tuple[ToonSensorEntityDescription, ...] = ( section="water_usage", measurement="day_cost", device_class=SensorDeviceClass.MONETARY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, native_unit_of_measurement=CURRENCY_EUR, icon="mdi:water-pump", entity_registry_enabled_default=False, diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py index 32e0b3573f..ef252d54e4 100644 --- a/homeassistant/components/totalconnect/binary_sensor.py +++ b/homeassistant/components/totalconnect/binary_sensor.py @@ -1,74 +1,78 @@ """Interfaces with TotalConnect sensors.""" +import logging + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN +LOW_BATTERY = "low_battery" +TAMPER = "tamper" +POWER = "power" +ZONE = "zone" + +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up TotalConnect device sensors based on a config entry.""" - sensors = [] + sensors: list = [] client_locations = hass.data[DOMAIN][entry.entry_id].client.locations for location_id, location in client_locations.items(): - for zone_id, zone in location.zones.items(): - sensors.append(TotalConnectBinarySensor(zone_id, location_id, zone)) + sensors.append(TotalConnectAlarmLowBatteryBinarySensor(location)) + sensors.append(TotalConnectAlarmTamperBinarySensor(location)) + sensors.append(TotalConnectAlarmPowerBinarySensor(location)) + + for zone in location.zones.values(): + sensors.append(TotalConnectZoneSecurityBinarySensor(location_id, zone)) + + if not zone.is_type_button(): + sensors.append(TotalConnectLowBatteryBinarySensor(location_id, zone)) + sensors.append(TotalConnectTamperBinarySensor(location_id, zone)) async_add_entities(sensors, True) -class TotalConnectBinarySensor(BinarySensorEntity): +class TotalConnectZoneBinarySensor(BinarySensorEntity): """Represent an TotalConnect zone.""" - def __init__(self, zone_id, location_id, zone): + def __init__(self, location_id, zone): """Initialize the TotalConnect status.""" - self._zone_id = zone_id self._location_id = location_id self._zone = zone - self._name = self._zone.description - self._unique_id = f"{location_id} {zone_id}" - self._is_on = None - self._is_tampered = None - self._is_low_battery = None + self._attr_name = f"{zone.description}{self.entity_description.name}" + self._attr_unique_id = ( + f"{location_id}_{zone.zoneid}_{self.entity_description.key}" + ) + self._attr_is_on = None + self._attr_extra_state_attributes = { + "zone_id": self._zone.zoneid, + "location_id": self._location_id, + "partition": self._zone.partition, + } - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - @property - def name(self): - """Return the name of the device.""" - return self._name +class TotalConnectZoneSecurityBinarySensor(TotalConnectZoneBinarySensor): + """Represent an TotalConnect security zone.""" - def update(self) -> None: - """Return the state of the device.""" - self._is_tampered = self._zone.is_tampered() - self._is_low_battery = self._zone.is_low_battery() - - if self._zone.is_faulted() or self._zone.is_triggered(): - self._is_on = True - else: - self._is_on = False - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._is_on + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=ZONE, name="" + ) @property def device_class(self): - """Return the class of this device, from BinarySensorDeviceClass.""" - if self._zone.is_type_security(): - return BinarySensorDeviceClass.DOOR + """Return the class of this zone.""" if self._zone.is_type_fire(): return BinarySensorDeviceClass.SMOKE if self._zone.is_type_carbon_monoxide(): @@ -77,16 +81,108 @@ class TotalConnectBinarySensor(BinarySensorEntity): return BinarySensorDeviceClass.MOTION if self._zone.is_type_medical(): return BinarySensorDeviceClass.SAFETY + # "security" type is a generic category so test for it last + if self._zone.is_type_security(): + return BinarySensorDeviceClass.DOOR + + _LOGGER.error( + "TotalConnect zone %s reported an unexpected device class", + self._zone.zoneid, + ) return None - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attributes = { - "zone_id": self._zone_id, - "location_id": self._location_id, - "low_battery": self._is_low_battery, - "tampered": self._is_tampered, - "partition": self._zone.partition, + def update(self): + """Return the state of the device.""" + if self._zone.is_faulted() or self._zone.is_triggered(): + self._attr_is_on = True + else: + self._attr_is_on = False + + +class TotalConnectLowBatteryBinarySensor(TotalConnectZoneBinarySensor): + """Represent an TotalConnect zone low battery status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=LOW_BATTERY, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + name=" low battery", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._zone.is_low_battery() + + +class TotalConnectTamperBinarySensor(TotalConnectZoneBinarySensor): + """Represent an TotalConnect zone tamper status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=TAMPER, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + name=f" {TAMPER}", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._zone.is_tampered() + + +class TotalConnectAlarmBinarySensor(BinarySensorEntity): + """Represent an TotalConnect alarm device binary sensors.""" + + def __init__(self, location): + """Initialize the TotalConnect alarm device binary sensor.""" + self._location = location + self._attr_name = f"{location.location_name}{self.entity_description.name}" + self._attr_unique_id = f"{location.location_id}_{self.entity_description.key}" + self._attr_is_on = None + self._attr_extra_state_attributes = { + "location_id": self._location.location_id, } - return attributes + + +class TotalConnectAlarmLowBatteryBinarySensor(TotalConnectAlarmBinarySensor): + """Represent an TotalConnect Alarm low battery status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=LOW_BATTERY, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + name=" low battery", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._location.is_low_battery() + + +class TotalConnectAlarmTamperBinarySensor(TotalConnectAlarmBinarySensor): + """Represent an TotalConnect alarm tamper status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=TAMPER, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + name=f" {TAMPER}", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._location.is_cover_tampered() + + +class TotalConnectAlarmPowerBinarySensor(TotalConnectAlarmBinarySensor): + """Represent an TotalConnect alarm power status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=POWER, + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + name=f" {POWER}", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = not self._location.is_ac_loss() diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index a820a7a034..8e0d58b7b7 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total_connect_client==2023.1"] + "requirements": ["total_connect_client==2023.2"] } 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/light.py b/homeassistant/components/tplink/light.py index 4d7d7a6acc..e4f91f282f 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -23,10 +23,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired as kelvin_to_mired, - color_temperature_mired_to_kelvin as mired_to_kelvin, -) from . import legacy_device_id from .const import DOMAIN @@ -206,18 +202,6 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): return brightness, transition - async def _async_set_color_temp( - self, color_temp_mireds: int, brightness: int | None, transition: int | None - ) -> None: - # Handle temp conversion mireds -> kelvin being slightly outside of valid range - kelvin = mired_to_kelvin(color_temp_mireds) - kelvin_range = self.device.valid_temperature_range - color_tmp = max(kelvin_range.min, min(kelvin_range.max, kelvin)) - _LOGGER.debug("Changing color temp to %s", color_tmp) - await self.device.set_color_temp( - color_tmp, brightness=brightness, transition=transition - ) - async def _async_set_hsv( self, hs_color: tuple[int, int], brightness: int | None, transition: int | None ) -> None: @@ -232,15 +216,17 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): if brightness is not None: await self.device.set_brightness(brightness, transition=transition) return - await self.device.turn_on(transition=transition) # type: ignore[arg-type] + await self.device.turn_on(transition=transition) @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" brightness, transition = self._async_extract_brightness_transition(**kwargs) - if ATTR_COLOR_TEMP in kwargs: - await self._async_set_color_temp( - int(kwargs[ATTR_COLOR_TEMP]), brightness, transition + if ATTR_COLOR_TEMP_KELVIN in kwargs: + await self.device.set_color_temp( + int(kwargs[ATTR_COLOR_TEMP_KELVIN]), + brightness=brightness, + transition=transition, ) if ATTR_HS_COLOR in kwargs: await self._async_set_hsv(kwargs[ATTR_HS_COLOR], brightness, transition) @@ -255,19 +241,19 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): await self.device.turn_off(transition=transition) @property - def min_mireds(self) -> int: + def min_color_temp_kelvin(self) -> int: """Return minimum supported color temperature.""" - return kelvin_to_mired(self.device.valid_temperature_range.max) + return cast(int, self.device.valid_temperature_range.min) @property - def max_mireds(self) -> int: + def max_color_temp_kelvin(self) -> int: """Return maximum supported color temperature.""" - return kelvin_to_mired(self.device.valid_temperature_range.min) + return cast(int, self.device.valid_temperature_range.max) @property - def color_temp(self) -> int | None: - """Return the color temperature of this light in mireds for HA.""" - return kelvin_to_mired(self.device.color_temp) + def color_temp_kelvin(self) -> int: + """Return the color temperature of this light.""" + return cast(int, self.device.color_temp) @property def brightness(self) -> int | None: @@ -281,9 +267,9 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): return hue, saturation @property - def supported_color_modes(self) -> set[ColorMode | str] | None: + def supported_color_modes(self) -> set[ColorMode]: """Return list of available color modes.""" - modes: set[ColorMode | str] = set() + modes: set[ColorMode] = set() if self.device.is_variable_color_temp: modes.add(ColorMode.COLOR_TEMP) if self.device.is_color: @@ -338,18 +324,19 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): """Turn the light on.""" brightness, transition = self._async_extract_brightness_transition(**kwargs) if ATTR_EFFECT in kwargs: - await self.device.set_effect(kwargs[ATTR_EFFECT]) - # We need to set the brightness separately until upstream allows defining it for set_effect. - if brightness is not None: - await self._async_turn_on_with_brightness(brightness, transition) - elif ATTR_COLOR_TEMP in kwargs: + await self.device.set_effect( + kwargs[ATTR_EFFECT], brightness=brightness, transition=transition + ) + elif ATTR_COLOR_TEMP_KELVIN in kwargs: if self.effect: # If there is an effect in progress # we have to set an HSV value to clear the effect # before we can set a color temp await self.device.set_hsv(0, 0, brightness) - await self._async_set_color_temp( - int(kwargs[ATTR_COLOR_TEMP]), brightness, transition + await self.device.set_color_temp( + int(kwargs[ATTR_COLOR_TEMP_KELVIN]), + brightness=brightness, + transition=transition, ) elif ATTR_HS_COLOR in kwargs: await self._async_set_hsv(kwargs[ATTR_HS_COLOR], brightness, transition) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 0a2a2331cb..6683e7e458 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -137,5 +137,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa==0.5.0"] + "requirements": ["python-kasa==0.5.1"] } diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 1e7db69cc9..824ea8df42 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -16,8 +16,9 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .config_flow import CONF_SITE, create_omada_client from .const import DOMAIN +from .controller import OmadaSiteController -PLATFORMS: list[Platform] = [Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.UPDATE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -43,12 +44,11 @@ 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])) - - hass.data[DOMAIN][entry.entry_id] = site_client + site_client = await client.get_site_client(OmadaSite("", entry.data[CONF_SITE])) + controller = OmadaSiteController(hass, site_client) + hass.data[DOMAIN][entry.entry_id] = controller await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True diff --git a/homeassistant/components/tplink_omada/config_flow.py b/homeassistant/components/tplink_omada/config_flow.py index 6b958b7d25..f6a75abe6d 100644 --- a/homeassistant/components/tplink_omada/config_flow.py +++ b/homeassistant/components/tplink_omada/config_flow.py @@ -3,9 +3,12 @@ from __future__ import annotations from collections.abc import Mapping import logging +import re from types import MappingProxyType from typing import Any, NamedTuple +from urllib.parse import urlsplit +from aiohttp import CookieJar from tplink_omada_client.exceptions import ( ConnectionFailed, LoginFailed, @@ -20,7 +23,10 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import ( + async_create_clientsession, + async_get_clientsession, +) from .const import DOMAIN @@ -42,11 +48,26 @@ async def create_omada_client( hass: HomeAssistant, data: MappingProxyType[str, Any] ) -> OmadaClient: """Create a TP-Link Omada client API for the given config entry.""" - host = data[CONF_HOST] + + host: str = data[CONF_HOST] verify_ssl = bool(data[CONF_VERIFY_SSL]) + + if not host.lower().startswith(("http://", "https://")): + host = "https://" + host + host_parts = urlsplit(host) + if ( + host_parts.hostname + and re.fullmatch(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", host_parts.hostname) + is not None + ): + # TP-Link API uses cookies for login session, so an unsafe cookie jar is required for IP addresses + websession = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)) + else: + websession = async_get_clientsession(hass, verify_ssl=verify_ssl) + username = data[CONF_USERNAME] password = data[CONF_PASSWORD] - websession = async_get_clientsession(hass, verify_ssl=verify_ssl) + return OmadaClient(host, username, password, websession=websession) diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py new file mode 100644 index 0000000000..508a8b914d --- /dev/null +++ b/homeassistant/components/tplink_omada/controller.py @@ -0,0 +1,58 @@ +"""Controller for sharing Omada API coordinators between platforms.""" + +from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails +from tplink_omada_client.omadasiteclient import OmadaSiteClient + +from homeassistant.core import HomeAssistant + +from .coordinator import OmadaCoordinator + +POLL_SWITCH_PORT = 300 + + +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: + """Controller for the Omada SDN site.""" + + def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: + """Create the controller.""" + self._hass = hass + self._omada_client = omada_client + + self._switch_port_coordinators: dict[str, OmadaSwitchPortCoordinator] = {} + + @property + def omada_client(self) -> OmadaSiteClient: + """Get the connected client API for the site to manage.""" + return self._omada_client + + def get_switch_port_coordinator( + self, switch: OmadaSwitch + ) -> 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] = 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 6950e3b6d7..3ff73501bd 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -1,12 +1,11 @@ """Generic Omada API coordinator.""" -from collections.abc import Awaitable, Callable from datetime import timedelta import logging from typing import Generic, TypeVar import async_timeout from tplink_omada_client.exceptions import OmadaClientException -from tplink_omada_client.omadaclient import OmadaClient +from tplink_omada_client.omadaclient import OmadaSiteClient from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -22,23 +21,27 @@ class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): def __init__( self, hass: HomeAssistant, - omada_client: OmadaClient, - update_func: Callable[[OmadaClient], Awaitable[dict[str, T]]], + omada_client: OmadaSiteClient, + name: str, + poll_delay: int = 300, ) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, - name="Omada API Data", - update_interval=timedelta(seconds=300), + name=f"Omada API Data - {name}", + 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/entity.py b/homeassistant/components/tplink_omada/entity.py index 3e7f21409b..41cb1c6918 100644 --- a/homeassistant/components/tplink_omada/entity.py +++ b/homeassistant/components/tplink_omada/entity.py @@ -1,23 +1,23 @@ """Base entity definitions.""" -from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails +from typing import Generic, TypeVar -from homeassistant.helpers import device_registry +from tplink_omada_client.devices import OmadaDevice + +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import OmadaCoordinator +T = TypeVar("T") -class OmadaSwitchDeviceEntity( - CoordinatorEntity[OmadaCoordinator[OmadaSwitchPortDetails]] -): - """Common base class for all entities attached to Omada network switches.""" - def __init__( - self, coordinator: OmadaCoordinator[OmadaSwitchPortDetails], device: OmadaSwitch - ) -> None: - """Initialize the switch.""" +class OmadaDeviceEntity(CoordinatorEntity[OmadaCoordinator[T]], Generic[T]): + """Common base class for all entities associated with Omada SDN Devices.""" + + def __init__(self, coordinator: OmadaCoordinator[T], device: OmadaDevice) -> None: + """Initialize the device.""" super().__init__(coordinator) self.device = device @@ -25,7 +25,7 @@ class OmadaSwitchDeviceEntity( def device_info(self) -> DeviceInfo: """Return information about the device.""" return DeviceInfo( - connections={(device_registry.CONNECTION_NETWORK_MAC, self.device.mac)}, + connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, identifiers={(DOMAIN, (self.device.mac))}, manufacturer="TP-Link", model=self.device.model_display_name, diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 005589a2f9..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.0"] + "requirements": ["tplink-omada-client==1.2.4"] } diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index dd5ee3168d..830f75b6a9 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -1,12 +1,11 @@ """Support for TPLink Omada device toggle options.""" from __future__ import annotations -from functools import partial from typing import Any from tplink_omada_client.definitions import PoEMode from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails -from tplink_omada_client.omadasiteclient import OmadaSiteClient, SwitchPortOverrides +from tplink_omada_client.omadasiteclient import SwitchPortOverrides from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -15,27 +14,20 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import OmadaCoordinator -from .entity import OmadaSwitchDeviceEntity +from .controller import OmadaSiteController, OmadaSwitchPortCoordinator +from .entity import OmadaDeviceEntity POE_SWITCH_ICON = "mdi:ethernet" -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} - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches.""" - omada_client: OmadaSiteClient = hass.data[DOMAIN][config_entry.entry_id] + controller: OmadaSiteController = hass.data[DOMAIN][config_entry.entry_id] + omada_client = controller.omada_client # Naming fun. Omada switches, as in the network hardware network_switches = await omada_client.get_switches() @@ -44,10 +36,7 @@ async def async_setup_entry( for switch in [ ns for ns in network_switches if ns.device_capabilities.supports_poe ]: - coordinator = OmadaCoordinator[OmadaSwitchPortDetails]( - hass, omada_client, partial(poll_switch_state, network_switch=switch) - ) - + coordinator = controller.get_switch_port_coordinator(switch) await coordinator.async_request_refresh() for idx, port_id in enumerate(coordinator.data): @@ -67,7 +56,9 @@ def get_port_base_name(port: OmadaSwitchPortDetails) -> str: return f"Port {port.port} ({port.name})" -class OmadaNetworkSwitchPortPoEControl(OmadaSwitchDeviceEntity, SwitchEntity): +class OmadaNetworkSwitchPortPoEControl( + OmadaDeviceEntity[OmadaSwitchPortDetails], SwitchEntity +): """Representation of a PoE control toggle on a single network port on a switch.""" _attr_has_entity_name = True @@ -76,7 +67,7 @@ class OmadaNetworkSwitchPortPoEControl(OmadaSwitchDeviceEntity, SwitchEntity): 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 new file mode 100644 index 0000000000..685ad9c576 --- /dev/null +++ b/homeassistant/components/tplink_omada/update.py @@ -0,0 +1,149 @@ +"""Support for TPLink Omada device firmware updates.""" +from __future__ import annotations + +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 .const import DOMAIN +from .controller import OmadaSiteController +from .coordinator import OmadaCoordinator +from .entity import OmadaDeviceEntity + +POLL_DELAY_IDLE = 6 * 60 * 60 +POLL_DELAY_UPGRADE = 60 + + +class FirmwareUpdateStatus(NamedTuple): + """Firmware update information for Omada SDN devices.""" + + device: OmadaListDevice + firmware: OmadaFirmwareUpdate | None + + +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 + ) + ) + return updates + + 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( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches.""" + controller: OmadaSiteController = hass.data[DOMAIN][config_entry.entry_id] + omada_client = controller.omada_client + + devices = await omada_client.get_devices() + + coordinator = OmadaFirmwareUpdateCoodinator(hass, omada_client) + + async_add_entities(OmadaDeviceUpdate(coordinator, device) for device in devices) + await coordinator.async_request_refresh() + + +class OmadaDeviceUpdate( + OmadaDeviceEntity[FirmwareUpdateStatus], + UpdateEntity, +): + """Firmware update status for Omada SDN devices.""" + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES + ) + _attr_has_entity_name = True + _attr_name = "Firmware update" + + def __init__( + self, + coordinator: OmadaFirmwareUpdateCoodinator, + device: OmadaListDevice, + ) -> None: + """Initialize the update entity.""" + super().__init__(coordinator, device) + + self._mac = device.mac + self._omada_client = coordinator.omada_client + + self._attr_unique_id = f"{device.mac}_firmware" + + def release_notes(self) -> str | None: + """Get the release notes for the latest update.""" + 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.""" + 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() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + status = self.coordinator.data[self._mac] + + 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 bbc089de0c..9ed7922fa1 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -36,9 +36,8 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval @@ -153,7 +152,7 @@ async def async_setup_entry( ] = async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) # Restore previously loaded devices - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_ids = { identifier[1] for device in dev_reg.devices.values() @@ -244,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/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 20dc2ed951..1e9b63bb32 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -108,30 +108,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._host = host return await self.async_step_auth() - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Import a config entry.""" - self._async_abort_entries_match({CONF_HOST: user_input["host"]}) - - # Happens if user has host directly in configuration.yaml - if "key" not in user_input: - self._host = user_input["host"] - return await self.async_step_auth() - - try: - data = await get_gateway_info( - self.hass, - user_input["host"], - # Old config format had a fixed identity - user_input.get("identity", "homeassistant"), - user_input["key"], - ) - - return await self._entry_from_data(data) - except AuthError: - # If we fail to connect, just pass it on to discovery - self._host = user_input["host"] - return await self.async_step_auth() - async def _entry_from_data(self, data: dict[str, Any]) -> FlowResult: """Create an entry from data.""" host = data[CONF_HOST] diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 689964cb15..81cce80aa7 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .base_class import TradfriBaseEntity @@ -108,7 +108,7 @@ SENSOR_DESCRIPTIONS_FAN: tuple[TradfriSensorEntityDescription, ...] = ( @callback def _migrate_old_unique_ids(hass: HomeAssistant, old_unique_id: str, key: str) -> None: """Migrate unique IDs to the new format.""" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_id = ent_reg.async_get_entity_id(Platform.SENSOR, DOMAIN, old_unique_id) diff --git a/homeassistant/components/trafikverket_ferry/config_flow.py b/homeassistant/components/trafikverket_ferry/config_flow.py index a1f984e555..2fb6cfb642 100644 --- a/homeassistant/components/trafikverket_ferry/config_flow.py +++ b/homeassistant/components/trafikverket_ferry/config_flow.py @@ -5,6 +5,7 @@ from collections.abc import Mapping from typing import Any from pytrafikverket import TrafikverketFerry +from pytrafikverket.exceptions import InvalidAuthentication, NoFerryFound import voluptuous as vol from homeassistant import config_entries @@ -16,9 +17,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN from .util import create_unique_id -ERROR_INVALID_AUTH = "Source: Security, message: Invalid authentication" -ERROR_INVALID_ROUTE = "No FerryAnnouncement found" - DATA_SCHEMA = vol.Schema( { vol.Required(CONF_API_KEY): selector.TextSelector( @@ -81,13 +79,12 @@ class TVFerryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.validate_input( api_key, self.entry.data[CONF_FROM], self.entry.data[CONF_TO] ) - except ValueError as err: - if str(err) == ERROR_INVALID_AUTH: - errors["base"] = "invalid_auth" - elif str(err) == ERROR_INVALID_ROUTE: - errors["base"] = "invalid_route" - else: - errors["base"] = "cannot_connect" + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoFerryFound: + errors["base"] = "invalid_route" + except Exception: # pylint: disable=broad-exception-caught + errors["base"] = "cannot_connect" else: self.hass.config_entries.async_update_entry( self.entry, @@ -126,13 +123,12 @@ class TVFerryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await self.validate_input(api_key, ferry_from, ferry_to) - except ValueError as err: - if str(err) == ERROR_INVALID_AUTH: - errors["base"] = "invalid_auth" - elif str(err) == ERROR_INVALID_ROUTE: - errors["base"] = "invalid_route" - else: - errors["base"] = "cannot_connect" + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoFerryFound: + errors["base"] = "invalid_route" + except Exception: # pylint: disable=broad-exception-caught + errors["base"] = "cannot_connect" else: if not errors: unique_id = create_unique_id( diff --git a/homeassistant/components/trafikverket_ferry/coordinator.py b/homeassistant/components/trafikverket_ferry/coordinator.py index 7c2c64d49f..926b57092a 100644 --- a/homeassistant/components/trafikverket_ferry/coordinator.py +++ b/homeassistant/components/trafikverket_ferry/coordinator.py @@ -6,11 +6,13 @@ import logging from typing import Any from pytrafikverket import TrafikverketFerry +from pytrafikverket.exceptions import InvalidAuthentication, NoFerryFound from pytrafikverket.trafikverket_ferry import FerryStop from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt @@ -82,10 +84,12 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator): ] = await self._ferry_api.async_get_next_ferry_stops( self._from, self._to, when, 3 ) - except ValueError as error: + except NoFerryFound as error: raise UpdateFailed( f"Departure {when} encountered a problem: {error}" ) from error + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed(error) from error states = { "departure_time": routedata[0].departure_time, diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 2dabb29c4d..5822566505 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.2.3"] + "requirements": ["pytrafikverket==0.3.3"] } diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index 0bedb7141c..8047cf2046 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -2,6 +2,11 @@ from __future__ import annotations from pytrafikverket import TrafikverketTrain +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleTrainStationsFound, + NoTrainStationFound, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY @@ -21,9 +26,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: to_station = await train_api.async_get_train_station(entry.data[CONF_TO]) from_station = await train_api.async_get_train_station(entry.data[CONF_FROM]) - except ValueError as error: - if "Invalid authentication" in error.args[0]: - raise ConfigEntryAuthFailed from error + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed from error + except (NoTrainStationFound, MultipleTrainStationsFound) as error: raise ConfigEntryNotReady( f"Problem when trying station {entry.data[CONF_FROM]} to" f" {entry.data[CONF_TO]}. Error: {error} " diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index c620e26414..fc23d3b953 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -5,6 +5,11 @@ from collections.abc import Mapping from typing import Any from pytrafikverket import TrafikverketTrain +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleTrainStationsFound, + NoTrainStationFound, +) import voluptuous as vol from homeassistant import config_entries @@ -12,23 +17,30 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) import homeassistant.util.dt as dt_util from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN from .util import create_unique_id -ERROR_INVALID_AUTH = "Source: Security, message: Invalid authentication" -ERROR_INVALID_STATION = "Could not find a station with the specified name" -ERROR_MULTIPLE_STATION = "Found multiple stations with the specified name" - DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_FROM): cv.string, - vol.Required(CONF_TO): cv.string, - vol.Optional(CONF_TIME): cv.string, - vol.Required(CONF_WEEKDAY, default=WEEKDAYS): cv.multi_select( - {day: day for day in WEEKDAYS} + vol.Required(CONF_API_KEY): TextSelector(), + vol.Required(CONF_FROM): TextSelector(), + vol.Required(CONF_TO): TextSelector(), + vol.Optional(CONF_TIME): TextSelector(), + vol.Required(CONF_WEEKDAY, default=WEEKDAYS): SelectSelector( + SelectSelectorConfig( + options=WEEKDAYS, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_WEEKDAY, + ) ), } ) @@ -75,15 +87,14 @@ class TVTrainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.validate_input( api_key, self.entry.data[CONF_FROM], self.entry.data[CONF_TO] ) - except ValueError as err: - if str(err) == ERROR_INVALID_AUTH: - errors["base"] = "invalid_auth" - elif str(err) == ERROR_INVALID_STATION: - errors["base"] = "invalid_station" - elif str(err) == ERROR_MULTIPLE_STATION: - errors["base"] = "more_stations" - else: - errors["base"] = "cannot_connect" + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoTrainStationFound: + errors["base"] = "invalid_station" + except MultipleTrainStationsFound: + errors["base"] = "more_stations" + except Exception: # pylint: disable=broad-exception-caught + errors["base"] = "cannot_connect" else: self.hass.config_entries.async_update_entry( self.entry, @@ -120,15 +131,14 @@ class TVTrainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await self.validate_input(api_key, train_from, train_to) - except ValueError as err: - if str(err) == ERROR_INVALID_AUTH: - errors["base"] = "invalid_auth" - elif str(err) == ERROR_INVALID_STATION: - errors["base"] = "invalid_station" - elif str(err) == ERROR_MULTIPLE_STATION: - errors["base"] = "more_stations" - else: - errors["base"] = "cannot_connect" + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoTrainStationFound: + errors["base"] = "invalid_station" + except MultipleTrainStationsFound: + errors["base"] = "more_stations" + except Exception: # pylint: disable=broad-exception-caught + errors["base"] = "cannot_connect" else: if train_time: if bool(dt_util.parse_time(train_time) is None): diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index bdd3dd9ab4..7b8369cec1 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.2.3"] + "requirements": ["pytrafikverket==0.3.3"] } diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index 678a0af229..f48127b3e9 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -3,9 +3,13 @@ from __future__ import annotations from datetime import date, datetime, time, timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any from pytrafikverket import TrafikverketTrain +from pytrafikverket.exceptions import ( + MultipleTrainAnnouncementFound, + NoTrainAnnouncementFound, +) from pytrafikverket.trafikverket_train import StationInfo, TrainStop from homeassistant.components.sensor import SensorDeviceClass, SensorEntity @@ -119,6 +123,8 @@ class TrainSensor(SensorEntity): name=name, configuration_url="https://api.trafikinfo.trafikverket.se/", ) + if TYPE_CHECKING: + assert from_station.name and to_station.name self._attr_unique_id = create_unique_id( from_station.name, to_station.name, departuretime, weekday ) @@ -134,6 +140,7 @@ class TrainSensor(SensorEntity): ) try: if self._time: + _LOGGER.debug("%s, %s, %s", self._from_station, self._to_station, when) _state = await self._train_api.async_get_train_stop( self._from_station, self._to_station, when ) @@ -141,7 +148,7 @@ class TrainSensor(SensorEntity): _state = await self._train_api.async_get_next_train_stop( self._from_station, self._to_station, when ) - except ValueError as error: + except (NoTrainAnnouncementFound, MultipleTrainAnnouncementFound) as error: _LOGGER.error("Departure %s encountered a problem: %s", when, error) if not _state: @@ -153,6 +160,8 @@ class TrainSensor(SensorEntity): self._attr_available = True # The original datetime doesn't provide a timezone so therefore attaching it here. + if TYPE_CHECKING: + assert _state.advertised_time_at_location self._attr_native_value = dt.as_utc(_state.advertised_time_at_location) if _state.time_at_location: self._attr_native_value = dt.as_utc(_state.time_at_location) @@ -165,7 +174,7 @@ class TrainSensor(SensorEntity): """Return extra state attributes.""" attributes: dict[str, Any] = { - ATTR_DEPARTURE_STATE: state.get_state().name, + ATTR_DEPARTURE_STATE: state.get_state().value, ATTR_CANCELED: state.canceled, ATTR_DELAY_TIME: None, ATTR_PLANNED_TIME: None, diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index 6f6ed44f7a..6c67d88115 100644 --- a/homeassistant/components/trafikverket_train/strings.json +++ b/homeassistant/components/trafikverket_train/strings.json @@ -28,5 +28,18 @@ } } } + }, + "selector": { + "weekday": { + "options": { + "mon": "Monday", + "tue": "Tuesday", + "wed": "Wednesday", + "thu": "Thursday", + "fri": "Friday", + "sat": "Saturday", + "sun": "Sunday" + } + } } } diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index 345d625c7c..f8f8629804 100644 --- a/homeassistant/components/trafikverket_weatherstation/config_flow.py +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -1,6 +1,11 @@ """Adds config flow for Trafikverket Weather integration.""" from __future__ import annotations +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleWeatherStationsFound, + NoWeatherStationFound, +) from pytrafikverket.trafikverket_weather import TrafikverketWeather import voluptuous as vol @@ -20,15 +25,11 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): entry: config_entries.ConfigEntry - async def validate_input(self, sensor_api: str, station: str) -> str: + async def validate_input(self, sensor_api: str, station: str) -> None: """Validate input from user input.""" web_session = async_get_clientsession(self.hass) weather_api = TrafikverketWeather(web_session, sensor_api) - try: - await weather_api.async_get_weather(station) - except ValueError as err: - return str(err) - return "connected" + await weather_api.async_get_weather(station) async def async_step_user( self, user_input: dict[str, str] | None = None @@ -41,8 +42,17 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] station = user_input[CONF_STATION] - validate = await self.validate_input(api_key, station) - if validate == "connected": + try: + await self.validate_input(api_key, station) + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoWeatherStationFound: + errors["base"] = "invalid_station" + except MultipleWeatherStationsFound: + errors["base"] = "more_stations" + except Exception: # pylint: disable=broad-exception-caught + errors["base"] = "cannot_connect" + else: return self.async_create_entry( title=name, data={ @@ -50,14 +60,6 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_STATION: station, }, ) - if validate == "Source: Security, message: Invalid authentication": - errors["base"] = "invalid_auth" - elif validate == "Could not find a weather station with the specified name": - errors["base"] = "invalid_station" - elif validate == "Found multiple weather stations with the specified name": - errors["base"] = "more_stations" - else: - errors["base"] = "cannot_connect" return self.async_show_form( step_id="user", diff --git a/homeassistant/components/trafikverket_weatherstation/coordinator.py b/homeassistant/components/trafikverket_weatherstation/coordinator.py index 990dcc0bc0..40c551089d 100644 --- a/homeassistant/components/trafikverket_weatherstation/coordinator.py +++ b/homeassistant/components/trafikverket_weatherstation/coordinator.py @@ -4,11 +4,17 @@ from __future__ import annotations from datetime import timedelta import logging +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleWeatherStationsFound, + NoWeatherStationFound, +) from pytrafikverket.trafikverket_weather import TrafikverketWeather, WeatherStationInfo from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -38,6 +44,8 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[WeatherStationInfo]): """Fetch data from Trafikverket.""" try: weatherdata = await self._weather_api.async_get_weather(self._station) - except ValueError as error: + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed from error + except (NoWeatherStationFound, MultipleWeatherStationsFound) as error: raise UpdateFailed from error return weatherdata diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 9b7dad395f..014637b99f 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.2.3"] + "requirements": ["pytrafikverket==0.3.3"] } diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index c8eed559f7..6dfd6a5e7c 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime +from typing import TYPE_CHECKING from homeassistant.components.sensor import ( SensorDeviceClass, @@ -190,7 +191,9 @@ class TrafikverketWeatherStation( def native_value(self) -> StateType | datetime: """Return state of sensor.""" if self.entity_description.api_key == "measure_time": - return _to_datetime(self.coordinator.data.measure_time) + if TYPE_CHECKING: + assert self.coordinator.data.measure_time + return self.coordinator.data.measure_time state: StateType = getattr( self.coordinator.data, self.entity_description.api_key @@ -204,4 +207,6 @@ class TrafikverketWeatherStation( @property def available(self) -> bool: """Return if entity is available.""" + if TYPE_CHECKING: + assert self.coordinator.data.active return self.coordinator.data.active and super().available diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 765755d124..d8623e7bbe 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -7,7 +7,11 @@ import logging from typing import Any import transmission_rpc -from transmission_rpc.error import TransmissionError +from transmission_rpc.error import ( + TransmissionAuthError, + TransmissionConnectError, + TransmissionError, +) import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -137,14 +141,13 @@ async def get_api(hass, entry): _LOGGER.debug("Successfully connected to %s", host) return api + except TransmissionAuthError as error: + _LOGGER.error("Credentials for Transmission client are not valid") + raise AuthenticationError from error + except TransmissionConnectError as error: + _LOGGER.error("Connecting to the Transmission client %s failed", host) + raise CannotConnect from error except TransmissionError as error: - if "401: Unauthorized" in str(error): - _LOGGER.error("Credentials for Transmission client are not valid") - raise AuthenticationError from error - if "111: Connection refused" in str(error): - _LOGGER.error("Connecting to the Transmission client %s failed", host) - raise CannotConnect from error - _LOGGER.error(error) raise UnknownError from error diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index 742ef874a3..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 @@ -44,3 +44,7 @@ DATA_UPDATED = "transmission_data_updated" EVENT_STARTED_TORRENT = "transmission_started_torrent" EVENT_REMOVED_TORRENT = "transmission_removed_torrent" EVENT_DOWNLOADED_TORRENT = "transmission_downloaded_torrent" + +STATE_UP_DOWN = "up_down" +STATE_SEEDING = "seeding" +STATE_DOWNLOADING = "downloading" 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 b1ff20627e..184d05faeb 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -20,6 +20,9 @@ from .const import ( CONF_ORDER, DOMAIN, STATE_ATTR_TORRENT_INFO, + STATE_DOWNLOADING, + STATE_SEEDING, + STATE_UP_DOWN, SUPPORTED_ORDER_MODES, ) @@ -106,34 +109,39 @@ 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): """Representation of a Transmission status sensor.""" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = [STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING] + _attr_translation_key = "transmission_status" + 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 = "Up/Down" + self._state = STATE_UP_DOWN elif upload > 0 and download == 0: - self._state = "Seeding" + self._state = STATE_SEEDING elif upload == 0 and download > 0: - self._state = "Downloading" + self._state = STATE_DOWNLOADING else: self._state = STATE_IDLE else: @@ -191,8 +199,8 @@ def _torrents_info(torrents, order, limit, statuses=None): torrents = SUPPORTED_ORDER_MODES[order](torrents) for torrent in torrents[:limit]: info = infos[torrent.name] = { - "added_date": torrent.addedDate, - "percent_done": f"{torrent.percentDone * 100:.2f}", + "added_date": torrent.date_added, + "percent_done": f"{torrent.percent_done * 100:.2f}", "status": torrent.status, "id": torrent.id, } diff --git a/homeassistant/components/transmission/services.yaml b/homeassistant/components/transmission/services.yaml index 66f4daf200..34a8852841 100644 --- a/homeassistant/components/transmission/services.yaml +++ b/homeassistant/components/transmission/services.yaml @@ -26,12 +26,6 @@ remove_torrent: selector: config_entry: integration: transmission - name: - name: Name - description: Instance name as entered during entry config - example: Transmission - selector: - text: id: name: ID description: ID of a torrent @@ -56,12 +50,6 @@ start_torrent: selector: config_entry: integration: transmission - name: - name: Name - description: Instance name as entered during entry config - example: Transmission - selector: - text: id: name: ID description: ID of a torrent @@ -79,12 +67,6 @@ stop_torrent: selector: config_entry: integration: transmission - name: - name: Name - description: Instance name as entered during entry config - example: Transmission - selector: - text: id: name: ID description: ID of a torrent diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 2cf9fafff4..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%]" }, @@ -40,5 +40,17 @@ } } } + }, + "entity": { + "sensor": { + "transmission_status": { + "state": { + "idle": "Idle", + "up_down": "Up/Down", + "seeding": "Seeding", + "downloading": "Downloading" + } + } + } } } 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/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index ec62da3765..6a30c1b62b 100644 --- a/homeassistant/components/travisci/sensor.py +++ b/homeassistant/components/travisci/sensor.py @@ -1,4 +1,4 @@ -"""This component provides HA sensor support for Travis CI framework.""" +"""Component providing HA sensor support for Travis CI framework.""" from __future__ import annotations from datetime import timedelta diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 0d253d7d94..7a55fb9b77 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,85 @@ 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_get_engine) + 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 +271,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 +407,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 +461,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 +477,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 +521,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 +535,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 +557,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 +570,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 +597,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 +608,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 +725,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 +766,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 +790,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 +825,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 +841,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 +868,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 +885,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 +922,120 @@ 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/get", + vol.Required("engine_id"): str, + } +) +@callback +def websocket_get_engine( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Get text to speech engine info.""" + component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] + + engine_id = msg["engine_id"] + provider_info: dict[str, Any] + + provider: TextToSpeechEntity | Provider | None = next( + (entity for entity in component.entities if entity.entity_id == engine_id), None + ) + if not provider: + provider = manager.providers.get(engine_id) + + if not provider: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"tts engine {engine_id} not found", + ) + return + + provider_info = { + "engine_id": engine_id, + "supported_languages": provider.supported_languages, + } + + connection.send_message( + websocket_api.result_message(msg["id"], {"provider": provider_info}) + ) + + +@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 1a2d0c526d..44b3494ca7 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -1,7 +1,7 @@ """Support for the Tuya lights.""" from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field import json from typing import Any, cast @@ -59,7 +59,9 @@ class TuyaLightEntityDescription(LightEntityDescription): color_data: DPCode | tuple[DPCode, ...] | None = None color_mode: DPCode | None = None color_temp: DPCode | tuple[DPCode, ...] | None = None - default_color_type: ColorTypeData = DEFAULT_COLOR_TYPE_DATA + default_color_type: ColorTypeData = field( + default_factory=lambda: DEFAULT_COLOR_TYPE_DATA + ) LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { @@ -169,6 +171,15 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj": ( + TuyaLightEntityDescription( + key=DPCode.LIGHT, + name="Backlight", + entity_category=EntityCategory.CONFIG, + ), + ), # Air conditioner # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n "kt": ( @@ -219,7 +230,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { ), TuyaLightEntityDescription( key=DPCode.BASIC_INDICATOR, - name="Indicator Light", + name="Indicator light", entity_category=EntityCategory.CONFIG, ), ), @@ -497,9 +508,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 += [ @@ -540,11 +556,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/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 020099ba5d..a2cd2d5fc4 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -834,7 +834,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.TOTAL_CLEAN_AREA, - name="Total Cleaning Area", + name="Total cleaning area", icon="mdi:texture-box", state_class=SensorStateClass.TOTAL_INCREASING, ), diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 1b2fdca326..a7245913e7 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -34,7 +34,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.WARM, - name="Heat Preservation", + name="Heat preservation", entity_category=EntityCategory.CONFIG, ), ), @@ -57,7 +57,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "cwwsq": ( SwitchEntityDescription( key=DPCode.SLOW_FEED, - name="Slow Feed", + name="Slow feed", icon="mdi:speedometer-slow", entity_category=EntityCategory.CONFIG, ), @@ -89,7 +89,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.UV, - name="UV Sterilization", + name="UV sterilization", icon="mdi:lightbulb", entity_category=EntityCategory.CONFIG, ), @@ -109,7 +109,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "dlq": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -152,7 +152,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.SWITCH_6, - name="Sleep Aid", + name="Sleep aid", icon="mdi:power-sleep", ), ), @@ -176,7 +176,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "kg": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -283,7 +283,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.UV, - name="UV Sterilization", + name="UV sterilization", icon="mdi:minus-circle-outline", entity_category=EntityCategory.CONFIG, ), @@ -299,7 +299,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -325,7 +325,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "pc": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -409,7 +409,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -419,13 +419,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "sd": ( SwitchEntityDescription( key=DPCode.SWITCH_DISTURB, - name="Do Not Disturb", + name="Do not disturb", icon="mdi:minus-circle", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.VOICE_SWITCH, - name="Mute Voice", + name="Mute voice", icon="mdi:account-voice", entity_category=EntityCategory.CONFIG, ), @@ -444,38 +444,38 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "sp": ( SwitchEntityDescription( key=DPCode.WIRELESS_BATTERYLOCK, - name="Battery Lock", + name="Battery lock", icon="mdi:battery-lock", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.CRY_DETECTION_SWITCH, icon="mdi:emoticon-cry", - name="Cry Detection", + name="Cry detection", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.DECIBEL_SWITCH, icon="mdi:microphone-outline", - name="Sound Detection", + name="Sound detection", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.RECORD_SWITCH, icon="mdi:record-rec", - name="Video Recording", + name="Video recording", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.MOTION_RECORD, icon="mdi:record-rec", - name="Motion Recording", + name="Motion recording", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.BASIC_PRIVATE, icon="mdi:eye-off", - name="Privacy Mode", + name="Privacy mode", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( @@ -487,25 +487,25 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { SwitchEntityDescription( key=DPCode.BASIC_OSD, icon="mdi:watermark", - name="Time Watermark", + name="Time watermark", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.BASIC_WDR, icon="mdi:watermark", - name="Wide Dynamic Range", + name="Wide dynamic range", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.MOTION_TRACKING, icon="mdi:motion-sensor", - name="Motion Tracking", + name="Motion tracking", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.MOTION_SWITCH, icon="mdi:motion-sensor", - name="Motion Alarm", + name="Motion alarm", entity_category=EntityCategory.CONFIG, ), ), @@ -542,7 +542,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -552,7 +552,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "tyndj": ( SwitchEntityDescription( key=DPCode.SWITCH_SAVE_ENERGY, - name="Energy Saving", + name="Energy saving", icon="mdi:leaf", entity_category=EntityCategory.CONFIG, ), @@ -562,13 +562,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "wkf": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.WINDOW_CHECK, - name="Open Window Detection", + name="Open window detection", icon="mdi:window-open", entity_category=EntityCategory.CONFIG, ), @@ -636,13 +636,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.OXYGEN, - name="Oxygen Bar", + name="Oxygen bar", icon="mdi:molecule", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.FAN_COOL, - name="Natural Wind", + name="Natural wind", icon="mdi:weather-windy", entity_category=EntityCategory.CONFIG, ), @@ -654,7 +654,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 27fe764b1e..7827fb061e 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -190,9 +190,14 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): self._send_command([{"code": DPCode.SUCTION, "value": fan_speed}]) def send_command( - self, command: str, params: dict | list | None = None, **kwargs: Any + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, ) -> None: """Send raw command.""" if not params: raise ValueError("Params cannot be omitted for Tuya vacuum commands") + if not isinstance(params, list): + raise TypeError("Params must be a list for Tuya vacuum commands") self._send_command([{"code": command, "value": params[0]}]) diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py index d368505173..e4ecbd9d86 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -1,7 +1,7 @@ """Support for Twente Milieu Calendar.""" from __future__ import annotations -from datetime import date, datetime +from datetime import date, datetime, timedelta from twentemilieu import WasteType @@ -58,7 +58,7 @@ class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity): CalendarEvent( summary=WASTE_TYPE_TO_DESCRIPTION[waste_type], start=waste_date, - end=waste_date, + end=waste_date + timedelta(days=1), ) for waste_date in waste_dates if start_date.date() <= waste_date <= end_date.date() @@ -89,7 +89,7 @@ class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity): self._event = CalendarEvent( summary=WASTE_TYPE_TO_DESCRIPTION[next_waste_pickup_type], start=next_waste_pickup_date, - end=next_waste_pickup_date, + end=next_waste_pickup_date + timedelta(days=1), ) super()._handle_coordinator_update() diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index f5745734f1..cfacc9072f 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["twentemilieu"], "quality_scale": "platinum", - "requirements": ["twentemilieu==0.6.1"] + "requirements": ["twentemilieu==1.0.0"] } diff --git a/homeassistant/components/ubus/device_tracker.py b/homeassistant/components/ubus/device_tracker.py index 20b0ad6593..48d5b4bd6f 100644 --- a/homeassistant/components/ubus/device_tracker.py +++ b/homeassistant/components/ubus/device_tracker.py @@ -68,7 +68,7 @@ def _refresh_on_access_denied(func): class UbusDeviceScanner(DeviceScanner): - """This class queries a wireless router running OpenWrt firmware. + """Class which queries a wireless router running OpenWrt firmware. Adapted from Tomato scanner. """ 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 2721e254de..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_CLIENT_REMOVED, 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,14 +22,17 @@ 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, entity_registry as er, ) from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_entries_for_config_entry from homeassistant.helpers.event import async_track_time_interval @@ -70,46 +71,36 @@ from .errors import AuthenticationRequired, CannotConnect RETRY_TIMER = 15 CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) -CLIENT_CONNECTED = ( - EventKey.WIRED_CLIENT_CONNECTED, - EventKey.WIRELESS_CLIENT_CONNECTED, - EventKey.WIRELESS_GUEST_CONNECTED, -) -DEVICE_CONNECTED = ( - EventKey.ACCESS_POINT_CONNECTED, - EventKey.GATEWAY_CONNECTED, - EventKey.SWITCH_CONNECTED, -) - 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 @@ -124,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. @@ -143,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: @@ -198,99 +191,66 @@ class UniFiController: @callback def async_load_entities(description: UnifiEntityDescription) -> None: """Load and subscribe to UniFi endpoints.""" - entities: list[UnifiEntity] = [] api_handler = description.api_handler_fn(self.api) + @callback + def async_add_unifi_entity(obj_ids: list[str]) -> None: + """Add UniFi entity.""" + async_add_entities( + [ + unifi_platform_entity(obj_id, self, description) + for obj_id in obj_ids + if (description.key, obj_id) not in self.known_objects + if description.allowed_fn(self, obj_id) + if description.supported_fn(self, obj_id) + ] + ) + + async_add_unifi_entity(list(api_handler)) + @callback def async_create_entity(event: ItemEvent, obj_id: str) -> None: - """Create UniFi entity.""" - if not description.allowed_fn( - self, obj_id - ) or not description.supported_fn(self, obj_id): - return - - entity = unifi_platform_entity(obj_id, self, description) - if event == ItemEvent.ADDED: - async_add_entities([entity]) - return - entities.append(entity) - - for obj_id in api_handler: - async_create_entity(ItemEvent.CHANGED, obj_id) - async_add_entities(entities) + """Create new UniFi entity on event.""" + async_add_unifi_entity([obj_id]) api_handler.subscribe(async_create_entity, ItemEvent.ADDED) + @callback + def async_options_updated() -> None: + """Load new entities based on changed options.""" + async_add_unifi_entity(list(api_handler)) + + self.config_entry.async_on_unload( + async_dispatcher_connect( + self.hass, self.signal_options_update, async_options_updated + ) + ) + for description in descriptions: 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: - if DATA_EVENT in data: - clients_connected = set() - devices_connected = set() - wireless_clients_connected = False - - for event in data[DATA_EVENT]: - if event.key in CLIENT_CONNECTED: - clients_connected.add(event.mac) - - if not wireless_clients_connected and event.key in ( - EventKey.WIRELESS_CLIENT_CONNECTED, - EventKey.WIRELESS_GUEST_CONNECTED, - ): - wireless_clients_connected = True - - elif event.key in DEVICE_CONNECTED: - devices_connected.add(event.mac) - - if wireless_clients_connected: - self.update_wireless_clients() - if clients_connected or devices_connected: - async_dispatcher_send( - self.hass, - self.signal_update, - clients_connected, - devices_connected, - ) - - elif DATA_CLIENT_REMOVED in data: - async_dispatcher_send( - self.hass, self.signal_remove, data[DATA_CLIENT_REMOVED] - ) + 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: """Integration specific event to signal a change in connection status.""" return f"unifi-reachable-{self.config_entry.entry_id}" - @property - def signal_update(self) -> str: - """Event specific per UniFi entry to signal new data.""" - return f"unifi-update-{self.config_entry.entry_id}" - - @property - def signal_remove(self) -> str: - """Event specific per UniFi entry to signal removal of entities.""" - return f"unifi-remove-{self.config_entry.entry_id}" - @property def signal_options_update(self) -> str: """Event specific per UniFi entry to signal new options.""" @@ -301,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() @@ -349,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) @@ -379,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() @@ -425,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") @@ -447,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 @@ -481,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 c845b6d5d3..296857e1cf 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -2,30 +2,36 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import timedelta import logging -from typing import Generic, TypeVar +from typing import Any, Generic 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 SOURCE_DATA, SOURCE_EVENT +from aiounifi.models.api import ApiItemT +from aiounifi.models.client import Client from aiounifi.models.device import Device -from aiounifi.models.event import EventKey +from aiounifi.models.event import Event, EventKey -from homeassistant.components.device_tracker import DOMAIN, ScannerEntity, SourceType +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 from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController -from .entity import UnifiEntity, UnifiEntityDescription -from .unifi_client import UniFiClientBase +from .entity import ( + HandlerT, + UnifiEntity, + UnifiEntityDescription, + async_device_available_fn, +) LOGGER = logging.getLogger(__name__) @@ -48,7 +54,6 @@ CLIENT_CONNECTED_ATTRIBUTES = [ ] CLIENT_STATIC_ATTRIBUTES = [ - "hostname", "mac", "name", "oui", @@ -58,6 +63,7 @@ CLIENT_STATIC_ATTRIBUTES = [ CLIENT_CONNECTED_ALL_ATTRIBUTES = CLIENT_CONNECTED_ATTRIBUTES + CLIENT_STATIC_ATTRIBUTES WIRED_CONNECTION = (EventKey.WIRED_CLIENT_CONNECTED,) +WIRED_DISCONNECTION = (EventKey.WIRED_CLIENT_DISCONNECTED,) WIRELESS_CONNECTION = ( EventKey.WIRELESS_CLIENT_CONNECTED, EventKey.WIRELESS_CLIENT_ROAM, @@ -66,17 +72,57 @@ WIRELESS_CONNECTION = ( EventKey.WIRELESS_GUEST_ROAM, EventKey.WIRELESS_GUEST_ROAM_RADIO, ) - - -_DataT = TypeVar("_DataT", bound=Device) -_HandlerT = TypeVar("_HandlerT", bound=Devices) +WIRELESS_DISCONNECTION = ( + EventKey.WIRELESS_CLIENT_DISCONNECTED, + EventKey.WIRELESS_GUEST_DISCONNECTED, +) @callback -def async_device_available_fn(controller: UniFiController, obj_id: str) -> bool: +def async_client_allowed_fn(controller: UniFiController, obj_id: str) -> bool: + """Check if client is allowed.""" + if not controller.option_track_clients: + return False + + client = controller.api.clients[obj_id] + if client.mac not in controller.wireless_clients: + if not controller.option_track_wired_clients: + return False + + elif ( + client.essid + and controller.option_ssid_filter + and client.essid not in controller.option_ssid_filter + ): + return False + + return True + + +@callback +def async_client_is_connected_fn(controller: UniFiController, obj_id: str) -> bool: """Check if device object is disabled.""" - device = controller.api.devices[obj_id] - return controller.available and not device.disabled + client = controller.api.clients[obj_id] + + if controller.wireless_clients.is_wireless(client) and client.is_wired: + if not controller.option_ignore_wired_bug: + return False # Wired bug in action + + if ( + not client.is_wired + and client.essid + and controller.option_ssid_filter + and client.essid not in controller.option_ssid_filter + ): + return False + + if ( + dt_util.utcnow() - dt_util.utc_from_timestamp(client.last_seen or 0) + > controller.option_detection_time + ): + return False + + return True @callback @@ -89,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] @@ -100,13 +146,36 @@ 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.""" ENTITY_DESCRIPTIONS: tuple[UnifiTrackerEntityDescription, ...] = ( + UnifiTrackerEntityDescription[Clients, Client]( + key="Client device scanner", + has_entity_name=True, + allowed_fn=async_client_allowed_fn, + api_handler_fn=lambda api: api.clients, + available_fn=lambda controller, obj_id: controller.available, + device_info_fn=lambda api, obj_id: None, + event_is_on=(WIRED_CONNECTION + WIRELESS_CONNECTION), + event_to_subscribe=( + WIRED_CONNECTION + + WIRED_DISCONNECTION + + WIRELESS_CONNECTION + + WIRELESS_DISCONNECTION + ), + heartbeat_timedelta_fn=lambda controller, _: controller.option_detection_time, + is_connected_fn=async_client_is_connected_fn, + name_fn=lambda client: client.name or client.hostname, + object_fn=lambda api, obj_id: api.clients[obj_id], + 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: api.clients[obj_id].hostname, + ), UnifiTrackerEntityDescription[Devices, Device]( key="Device scanner", has_entity_name=True, @@ -140,239 +209,13 @@ async def async_setup_entry( UnifiScannerEntity, ENTITY_DESCRIPTIONS, async_add_entities ) - controller.entities[DOMAIN] = {CLIENT_TRACKER: set(), DEVICE_TRACKER: set()} - @callback - def items_added( - clients: set = controller.api.clients, devices: set = controller.api.devices - ) -> None: - """Update the values of the controller.""" - if controller.option_track_clients: - add_client_entities(controller, async_add_entities, clients) - - for signal in (controller.signal_update, controller.signal_options_update): - config_entry.async_on_unload( - async_dispatcher_connect(hass, signal, items_added) - ) - - items_added() - - -@callback -def add_client_entities(controller, async_add_entities, clients): - """Add new client tracker entities from the controller.""" - trackers = [] - - for mac in clients: - if mac in controller.entities[DOMAIN][UniFiClientTracker.TYPE] or not ( - client := controller.api.clients.get(mac) - ): - continue - - if mac not in controller.wireless_clients: - if not controller.option_track_wired_clients: - continue - elif ( - client.essid - and controller.option_ssid_filter - and client.essid not in controller.option_ssid_filter - ): - continue - - trackers.append(UniFiClientTracker(client, controller)) - - async_add_entities(trackers) - - -class UniFiClientTracker(UniFiClientBase, ScannerEntity): - """Representation of a network client.""" - - DOMAIN = DOMAIN - TYPE = CLIENT_TRACKER - - def __init__(self, client, controller): - """Set up tracked client.""" - super().__init__(client, controller) - - self._controller_connection_state_changed = False - - self._only_listen_to_data_source = False - - last_seen = client.last_seen or 0 - self.schedule_update = self._is_connected = ( - self.is_wired == client.is_wired - and dt_util.utcnow() - dt_util.utc_from_timestamp(float(last_seen)) - < controller.option_detection_time - ) - - @callback - def _async_log_debug_data(self, method: str) -> None: - """Print debug data about entity.""" - if not LOGGER.isEnabledFor(logging.DEBUG): - return - last_seen = self.client.last_seen or 0 - LOGGER.debug( - "%s [%s, %s] [%s %s] [%s] %s (%s)", - method, - self.entity_id, - self.client.mac, - self.schedule_update, - self._is_connected, - dt_util.utc_from_timestamp(float(last_seen)), - dt_util.utcnow() - dt_util.utc_from_timestamp(float(last_seen)), - last_seen, - ) - - async def async_added_to_hass(self) -> None: - """Watch object when added.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{self.controller.signal_heartbeat_missed}_{self.unique_id}", - self._make_disconnected, - ) - ) - await super().async_added_to_hass() - self._async_log_debug_data("added_to_hass") - - async def async_will_remove_from_hass(self) -> None: - """Disconnect object when removed.""" - self.controller.async_heartbeat(self.unique_id) - await super().async_will_remove_from_hass() - - @callback - def async_signal_reachable_callback(self) -> None: - """Call when controller connection state change.""" - self._controller_connection_state_changed = True - super().async_signal_reachable_callback() - - @callback - def async_update_callback(self) -> None: - """Update the clients state.""" - - if self._controller_connection_state_changed: - self._controller_connection_state_changed = False - - if self.controller.available: - self.schedule_update = True - - else: - self.controller.async_heartbeat(self.unique_id) - super().async_update_callback() - - elif ( - self.client.last_updated == SOURCE_DATA - and self.is_wired == self.client.is_wired - ): - self._is_connected = True - self.schedule_update = True - self._only_listen_to_data_source = True - - elif ( - self.client.last_updated == SOURCE_EVENT - and not self._only_listen_to_data_source - ): - if (self.is_wired and self.client.event.key in WIRED_CONNECTION) or ( - not self.is_wired and self.client.event.key in WIRELESS_CONNECTION - ): - self._is_connected = True - self.schedule_update = False - self.controller.async_heartbeat(self.unique_id) - super().async_update_callback() - - else: - self.schedule_update = True - - self._async_log_debug_data("update_callback") - - if self.schedule_update: - self.schedule_update = False - self.controller.async_heartbeat( - self.unique_id, dt_util.utcnow() + self.controller.option_detection_time - ) - - super().async_update_callback() - - @callback - def _make_disconnected(self, *_): - """No heart beat by device.""" - self._is_connected = False - self.async_write_ha_state() - self._async_log_debug_data("make_disconnected") - - @property - def is_connected(self): - """Return true if the client is connected to the network.""" - if ( - not self.is_wired - and self.client.essid - and self.controller.option_ssid_filter - and self.client.essid not in self.controller.option_ssid_filter - ): - return False - - return self._is_connected - - @property - def source_type(self) -> SourceType: - """Return the source type of the client.""" - return SourceType.ROUTER - - @property - def unique_id(self) -> str: - """Return a unique identifier for this client.""" - return f"{self.client.mac}-{self.controller.site}" - - @property - def extra_state_attributes(self): - """Return the client state attributes.""" - raw = self.client.raw - - attributes_to_check = CLIENT_STATIC_ATTRIBUTES - if self.is_connected: - attributes_to_check = CLIENT_CONNECTED_ALL_ATTRIBUTES - - attributes = {k: raw[k] for k in attributes_to_check if k in raw} - attributes["is_wired"] = self.is_wired - - return attributes - - @property - def ip_address(self) -> str: - """Return the primary ip address of the device.""" - return self.client.raw.get("ip") - - @property - def mac_address(self) -> str: - """Return the mac address of the device.""" - return self.client.raw.get("mac") - - @property - def hostname(self) -> str: - """Return hostname of the device.""" - return self.client.raw.get("hostname") - - async def options_updated(self) -> None: - """Config entry options are updated, remove entity if option is disabled.""" - if not self.controller.option_track_clients: - await self.remove_item({self.client.mac}) - - elif self.is_wired: - if not self.controller.option_track_wired_clients: - await self.remove_item({self.client.mac}) - - elif ( - self.controller.option_ssid_filter - and self.client.essid not in self.controller.option_ssid_filter - ): - await self.remove_item({self.client.mac}) - - -class UnifiScannerEntity(UnifiEntity[_HandlerT, _DataT], ScannerEntity): +class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): """Representation of a UniFi scanner.""" entity_description: UnifiTrackerEntityDescription + _event_is_on: tuple[EventKey, ...] _ignore_events: bool _is_connected: bool @@ -383,8 +226,15 @@ class UnifiScannerEntity(UnifiEntity[_HandlerT, _DataT], ScannerEntity): Initiate is_connected. """ description = self.entity_description + self._event_is_on = description.event_is_on or () self._ignore_events = False self._is_connected = description.is_connected_fn(self.controller, self._obj_id) + if self.is_connected: + self.controller.async_heartbeat( + self.unique_id, + dt_util.utcnow() + + description.heartbeat_timedelta_fn(self.controller, self._obj_id), + ) @property def is_connected(self) -> bool: @@ -417,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() @@ -452,13 +302,33 @@ class UnifiScannerEntity(UnifiEntity[_HandlerT, _DataT], ScannerEntity): + description.heartbeat_timedelta_fn(self.controller, self._obj_id), ) + @callback + def async_event_callback(self, event: Event) -> None: + """Event subscription callback.""" + if event.mac != self._obj_id or self._ignore_events: + return + + if event.key in self._event_is_on: + self.controller.async_heartbeat(self.unique_id) + self._is_connected = True + self.async_write_ha_state() + return + + self.controller.async_heartbeat( + self.unique_id, + dt_util.utcnow() + + self.entity_description.heartbeat_timedelta_fn( + self.controller, self._obj_id + ), + ) + 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"{self.controller.signal_heartbeat_missed}_{self._obj_id}", + f"{self.controller.signal_heartbeat_missed}_{self.unique_id}", self._make_disconnected, ) ) @@ -467,3 +337,20 @@ class UnifiScannerEntity(UnifiEntity[_HandlerT, _DataT], ScannerEntity): """Disconnect object when removed.""" await super().async_will_remove_from_hass() self.controller.async_heartbeat(self.unique_id) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return the client state attributes.""" + if self.entity_description.key != "Client device scanner": + return None + + client = self.entity_description.object_fn(self.controller.api, self._obj_id) + raw = client.raw + + attributes_to_check = CLIENT_STATIC_ATTRIBUTES + if self.is_connected: + attributes_to_check = CLIENT_CONNECTED_ALL_ATTRIBUTES + + attributes = {k: raw[k] for k in attributes_to_check if k in raw} + + return attributes diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 783950310e..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,13 +91,15 @@ 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 self.controller = controller self.entity_description = description + controller.known_objects.add((description.key, obj_id)) + self._removed = False self._attr_available = description.available_fn(controller, obj_id) @@ -118,6 +115,13 @@ class UnifiEntity(Entity, Generic[HandlerT, DataT]): description = self.entity_description handler = description.api_handler_fn(self.controller.api) + @callback + def unregister_object() -> None: + """Remove object ID from known_objects when unloaded.""" + self.controller.known_objects.discard((description.key, self._obj_id)) + + self.async_on_remove(unregister_object) + # New data from handler self.async_on_remove( handler.subscribe( 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/unifi/unifi_client.py b/homeassistant/components/unifi/unifi_client.py deleted file mode 100644 index 6c13bb9785..0000000000 --- a/homeassistant/components/unifi/unifi_client.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Base class for UniFi clients.""" -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo - -from .unifi_entity_base import UniFiBase - - -class UniFiClientBase(UniFiBase): - """Base class for UniFi clients (without device info).""" - - def __init__(self, client, controller) -> None: - """Set up client.""" - super().__init__(client, controller) - - self._is_wired = client.mac not in controller.wireless_clients - self.client = self._item - - @property - def is_wired(self): - """Return if the client is wired. - - Allows disabling logic to keep track of clients affected by UniFi wired bug marking wireless devices as wired. This is useful when running a network not only containing UniFi APs. - """ - if self._is_wired and self.client.mac in self.controller.wireless_clients: - self._is_wired = False - - if self.controller.option_ignore_wired_bug: - return self.client.is_wired - - return self._is_wired - - @property - def unique_id(self): - """Return a unique identifier for this switch.""" - return f"{self.TYPE}-{self.client.mac}" - - @property - def name(self) -> str: - """Return the name of the client.""" - return self.client.name or self.client.hostname - - @property - def available(self) -> bool: - """Return if controller is available.""" - return self.controller.available - - -class UniFiClient(UniFiClientBase): - """Base class for UniFi clients (with device info).""" - - @property - def device_info(self) -> DeviceInfo: - """Return a client description for device registry.""" - return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self.client.mac)}, - default_manufacturer=self.client.oui, - default_name=self.client.name or self.client.hostname, - ) diff --git a/homeassistant/components/unifi/unifi_entity_base.py b/homeassistant/components/unifi/unifi_entity_base.py deleted file mode 100644 index 11b5eac2d3..0000000000 --- a/homeassistant/components/unifi/unifi_entity_base.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Base class for UniFi Network entities.""" -from __future__ import annotations - -from collections.abc import Callable -import logging -from typing import TYPE_CHECKING, Any - -from homeassistant.core import callback -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity - -if TYPE_CHECKING: - from .controller import UniFiController - -_LOGGER = logging.getLogger(__name__) - - -class UniFiBase(Entity): - """UniFi entity base class.""" - - _attr_should_poll = False - - DOMAIN = "" - TYPE = "" - - def __init__(self, item, controller: UniFiController) -> None: - """Set up UniFi Network entity base. - - Register mac to controller entities to cover disabled entities. - """ - self._item = item - self.controller = controller - self.controller.entities[self.DOMAIN][self.TYPE].add(self.key) - - @property - def key(self) -> Any: - """Return item key.""" - return self._item.mac - - async def async_added_to_hass(self) -> None: - """Entity created.""" - _LOGGER.debug( - "New %s entity %s (%s)", - self.TYPE, - self.entity_id, - self.key, - ) - signals: tuple[tuple[str, Callable[..., Any]], ...] = ( - (self.controller.signal_reachable, self.async_signal_reachable_callback), - (self.controller.signal_options_update, self.options_updated), - (self.controller.signal_remove, self.remove_item), - ) - for signal, method in signals: - self.async_on_remove(async_dispatcher_connect(self.hass, signal, method)) - self._item.register_callback(self.async_update_callback) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect object when removed.""" - _LOGGER.debug( - "Removing %s entity %s (%s)", - self.TYPE, - self.entity_id, - self.key, - ) - self._item.remove_callback(self.async_update_callback) - self.controller.entities[self.DOMAIN][self.TYPE].remove(self.key) - - @callback - def async_signal_reachable_callback(self) -> None: - """Call when controller connection state change.""" - self.async_update_callback() - - @callback - def async_update_callback(self) -> None: - """Update the entity's state.""" - _LOGGER.debug( - "Updating %s entity %s (%s)", - self.TYPE, - self.entity_id, - self.key, - ) - self.async_write_ha_state() - - async def options_updated(self) -> None: - """Config entry options are updated, remove entity if option is disabled.""" - raise NotImplementedError - - async def remove_item(self, keys: set) -> None: - """Remove entity if key is part of set.""" - if self.key not in keys: - return - - if self.registry_entry: - er.async_get(self.hass).async_remove(self.entity_id) - else: - await self.async_remove(force_remove=True) diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py index 42f83dad5d..13ebd0e33e 100644 --- a/homeassistant/components/unifi_direct/device_tracker.py +++ b/homeassistant/components/unifi_direct/device_tracker.py @@ -43,7 +43,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> UnifiDeviceScanner | class UnifiDeviceScanner(DeviceScanner): - """This class queries Unifi wireless access point.""" + """Class which queries Unifi wireless access point.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 1e9f168820..96d31872d0 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -14,6 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.issue_registry import IssueSeverity +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_ALLOW_EA, @@ -27,7 +28,6 @@ from .const import ( from .data import ProtectData, async_ufp_instance_for_config_entry_ids from .discovery import async_start_discovery from .migrate import async_migrate_data -from .repairs import async_create_repairs from .services import async_cleanup_services, async_setup_services from .utils import ( _async_unifi_mac_from_hass, @@ -41,10 +41,15 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the UniFi Protect.""" + # Only start discovery once regardless of how many entries they have + async_start_discovery(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the UniFi Protect config entries.""" - - async_start_discovery(hass) protect = async_create_api_client(hass, entry) _LOGGER.debug("Connect to UniFi Protect") data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry) @@ -122,7 +127,6 @@ async def _async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, data_service: ProtectData ) -> None: await async_migrate_data(hass, entry, data_service.api) - await async_create_repairs(hass, entry, data_service.api) await data_service.async_setup() if not data_service.last_update_success: diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index d61a47e8c7..7aa7c6d5cf 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -1,4 +1,4 @@ -"""This component provides binary sensors for UniFi Protect.""" +"""Component providing binary sensors for UniFi Protect.""" from __future__ import annotations from copy import copy 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 1828687c0d..885781c655 100644 --- a/homeassistant/components/unifiprotect/discovery.py +++ b/homeassistant/components/unifiprotect/discovery.py @@ -29,13 +29,22 @@ def async_start_discovery(hass: HomeAssistant) -> None: return domain_data[DISCOVERY] = True - async def _async_discovery(*_: Any) -> None: + async def _async_discovery() -> None: async_trigger_discovery(hass, await async_discover_devices()) - # Do not block startup since discovery takes 31s or more - hass.async_create_background_task(_async_discovery(), "unifiprotect-discovery") + @callback + def _async_start_background_discovery(*_: Any) -> None: + """Run discovery in the background.""" + hass.async_create_background_task(_async_discovery(), "unifiprotect-discovery") - async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL) + # 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, + cancel_on_shutdown=True, + ) async def async_discover_devices() -> list[UnifiDevice]: diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 134b55c4b0..9ca27c7174 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -22,7 +22,12 @@ from pyunifiprotect.data import ( from homeassistant.core import callback import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.entity import ( + DEVICE_CLASS_NAME, + DeviceInfo, + Entity, + EntityDescription, +) from .const import ( ATTR_EVENT_ID, @@ -199,6 +204,10 @@ class ProtectDeviceEntity(Entity): self.entity_description = description self._attr_unique_id = f"{self.device.mac}_{description.key}" name = description.name or "" + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + assert name is not DEVICE_CLASS_NAME self._attr_name = f"{self.device.display_name} {name.title()}" self._attr_attribution = DEFAULT_ATTRIBUTION diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index feb0be66ec..500b4b4703 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -1,4 +1,4 @@ -"""This component provides Lights for UniFi Protect.""" +"""Component providing Lights for UniFi Protect.""" from __future__ import annotations import logging diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 77570a1cb3..fcb30cdba5 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.9.0", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index ba6ae819dd..247e401b2c 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -1,4 +1,4 @@ -"""This component provides number entities for UniFi Protect.""" +"""Component providing number entities for UniFi Protect.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/unifiprotect/recorder.py b/homeassistant/components/unifiprotect/recorder.py new file mode 100644 index 0000000000..6603a0543f --- /dev/null +++ b/homeassistant/components/unifiprotect/recorder.py @@ -0,0 +1,12 @@ +"""Integration platform for recorder.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant, callback + +from .const import ATTR_EVENT_ID, ATTR_EVENT_SCORE + + +@callback +def exclude_attributes(hass: HomeAssistant) -> set[str]: + """Exclude event_id and event_score from being recorded in the database.""" + return {ATTR_EVENT_ID, ATTR_EVENT_SCORE} diff --git a/homeassistant/components/unifiprotect/repairs.py b/homeassistant/components/unifiprotect/repairs.py index 72f297c8c2..49473744d0 100644 --- a/homeassistant/components/unifiprotect/repairs.py +++ b/homeassistant/components/unifiprotect/repairs.py @@ -2,94 +2,24 @@ from __future__ import annotations -from functools import partial -from itertools import chain import logging -from typing import Any, cast +from typing import cast from pyunifiprotect import ProtectApiClient import voluptuous as vol from homeassistant import data_entry_flow -from homeassistant.components.automation import ( - EVENT_AUTOMATION_RELOADED, - automations_with_entity, -) from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow -from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_get as async_get_issue_registry, -) +from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry -from .const import CONF_ALLOW_EA, DOMAIN +from .const import CONF_ALLOW_EA from .utils import async_create_api_client _LOGGER = logging.getLogger(__name__) -async def async_create_repairs( - hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient -) -> None: - """Create any additional repairs for deprecations.""" - - await _deprecate_smart_sensor(hass, entry, protect) - entry.async_on_unload( - hass.bus.async_listen( - EVENT_AUTOMATION_RELOADED, - partial(_deprecate_smart_sensor, hass, entry, protect), - ) - ) - - -async def _deprecate_smart_sensor( - hass: HomeAssistant, - entry: ConfigEntry, - protect: ProtectApiClient, - *args: Any, - **kwargs: Any, -) -> None: - entity_registry = er.async_get(hass) - automations: dict[str, list[str]] = {} - scripts: dict[str, list[str]] = {} - for entity in er.async_entries_for_config_entry(entity_registry, entry.entry_id): - if ( - entity.domain == Platform.SENSOR - and entity.disabled_by is None - and "detected_object" in entity.unique_id - ): - entity_automations = automations_with_entity(hass, entity.entity_id) - entity_scripts = scripts_with_entity(hass, entity.entity_id) - if entity_automations: - automations[entity.entity_id] = entity_automations - if entity_scripts: - scripts[entity.entity_id] = entity_scripts - - if automations or scripts: - items = sorted( - set( - chain.from_iterable(list(automations.values()) + list(scripts.values())) - ) - ) - ir.async_create_issue( - hass, - DOMAIN, - "deprecate_smart_sensor", - is_fixable=False, - breaks_in_ha_version="2023.3.0", - severity=IssueSeverity.WARNING, - translation_key="deprecate_smart_sensor", - translation_placeholders={"items": "* `" + "`\n* `".join(items) + "`\n"}, - ) - else: - _LOGGER.debug("No found usages of Detected Object sensor") - ir.async_delete_issue(hass, DOMAIN, "deprecate_smart_sensor") - - class EAConfirm(RepairsFlow): """Handler for an issue fixing flow.""" diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 7bc54aa7af..753563023f 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -1,9 +1,8 @@ -"""This component provides select entities for UniFi Protect.""" +"""Component providing select entities for UniFi Protect.""" 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, - entity_platform, - issue_registry as ir, -) from homeassistant.helpers.dispatcher import async_dispatcher_connect -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( @@ -319,9 +301,7 @@ VIEWER_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up number entities for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] @@ -354,12 +334,6 @@ async def async_setup_entry( ) async_add_entities(entities) - platform = entity_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): @@ -430,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/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 7a31fffee4..783955b340 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -1,4 +1,4 @@ -"""This component provides sensors for UniFi Protect.""" +"""Component providing sensors for UniFi Protect.""" from __future__ import annotations from dataclasses import dataclass @@ -53,7 +53,6 @@ from .utils import async_dispatch_id as _ufpd, async_get_light_motion_current _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" -DEVICE_CLASS_DETECTION = "unifiprotect__detection" @dataclass @@ -524,14 +523,6 @@ NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ) EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = ( - ProtectSensorEventEntityDescription( - key="detected_object", - name="Detected Object", - device_class=DEVICE_CLASS_DETECTION, - entity_registry_enabled_default=False, - ufp_value="is_smart_detected", - ufp_event_obj="last_smart_detect_event", - ), ProtectSensorEventEntityDescription( key="smart_obj_licenseplate", name="License Plate Detected", diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 915c51b6c0..90a2d5167c 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -161,8 +161,9 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> camera = instance.bootstrap.get_device_from_mac(doorbell_mac) assert camera is not None doorbell_ids.add(camera.id) + data_before_changed = chime.dict_with_excludes() chime.camera_ids = sorted(doorbell_ids) - await chime.save_device() + await chime.save_device(data_before_changed) def async_setup_services(hass: HomeAssistant) -> None: diff --git a/homeassistant/components/unifiprotect/services.yaml b/homeassistant/components/unifiprotect/services.yaml index 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/switch.py b/homeassistant/components/unifiprotect/switch.py index 295b701426..ea2d8256cb 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -1,4 +1,4 @@ -"""This component provides Switches for UniFi Protect.""" +"""Component providing Switches for UniFi Protect.""" from __future__ import annotations from dataclasses import dataclass 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 c7cc0dd098..fd73ad33e1 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -44,8 +44,12 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, + RepeatMode, ) +from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.const import ( + ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_FEATURES, @@ -78,6 +82,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( TrackTemplate, @@ -93,6 +98,7 @@ ATTR_ACTIVE_CHILD = "active_child" CONF_ATTRS = "attributes" CONF_CHILDREN = "children" CONF_COMMANDS = "commands" +CONF_BROWSE_MEDIA_ENTITY = "browse_media_entity" STATES_ORDER = [ STATE_UNKNOWN, @@ -119,6 +125,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_ATTRS, default={}): vol.Or( cv.ensure_list(ATTRS_SCHEMA), ATTRS_SCHEMA ), + vol.Optional(CONF_BROWSE_MEDIA_ENTITY): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_TEMPLATE): cv.template, @@ -136,17 +143,7 @@ async def async_setup_platform( """Set up the universal media players.""" await async_setup_reload_service(hass, "universal", ["media_player"]) - player = UniversalMediaPlayer( - hass, - config.get(CONF_NAME), - config.get(CONF_CHILDREN), - config.get(CONF_COMMANDS), - config.get(CONF_ATTRS), - config.get(CONF_UNIQUE_ID), - config.get(CONF_DEVICE_CLASS), - config.get(CONF_STATE_TEMPLATE), - ) - + player = UniversalMediaPlayer(hass, config) async_add_entities([player]) @@ -158,30 +155,25 @@ class UniversalMediaPlayer(MediaPlayerEntity): def __init__( self, hass, - name, - children, - commands, - attributes, - unique_id=None, - device_class=None, - state_template=None, + config, ): """Initialize the Universal media device.""" self.hass = hass - self._name = name - self._children = children - self._cmds = commands + self._name = config.get(CONF_NAME) + self._children = config.get(CONF_CHILDREN) + self._cmds = config.get(CONF_COMMANDS) self._attrs = {} - for key, val in attributes.items(): + for key, val in config.get(CONF_ATTRS).items(): attr = list(map(str.strip, val.split("|", 1))) if len(attr) == 1: attr.append(None) self._attrs[key] = attr self._child_state = None self._state_template_result = None - self._state_template = state_template - self._device_class = device_class - self._attr_unique_id = unique_id + self._state_template = config.get(CONF_STATE_TEMPLATE) + self._device_class = config.get(CONF_DEVICE_CLASS) + self._attr_unique_id = config.get(CONF_UNIQUE_ID) + self._browse_media_entity = config.get(CONF_BROWSE_MEDIA_ENTITY) async def async_added_to_hass(self) -> None: """Subscribe to children and template state changes.""" @@ -302,6 +294,11 @@ class UniversalMediaPlayer(MediaPlayerEntity): """Return the name of universal player.""" return self._name + @property + def assumed_state(self) -> bool: + """Return True if unable to access real state of the entity.""" + return self._child_attr(ATTR_ASSUMED_STATE) + @property def state(self): """Return the current state of media player. @@ -497,6 +494,9 @@ class UniversalMediaPlayer(MediaPlayerEntity): if SERVICE_PLAY_MEDIA in self._cmds: flags |= MediaPlayerEntityFeature.PLAY_MEDIA + if self._browse_media_entity: + flags |= MediaPlayerEntityFeature.BROWSE_MEDIA + if SERVICE_CLEAR_PLAYLIST in self._cmds: flags |= MediaPlayerEntityFeature.CLEAR_PLAYLIST @@ -576,7 +576,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): await self._async_call_service(SERVICE_MEDIA_SEEK, data) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" data = {ATTR_MEDIA_CONTENT_TYPE: media_type, ATTR_MEDIA_CONTENT_ID: media_id} @@ -615,7 +615,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): data = {ATTR_MEDIA_SHUFFLE: shuffle} await self._async_call_service(SERVICE_SHUFFLE_SET, data, allow_override=True) - async def async_set_repeat(self, repeat: str) -> None: + async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set repeat mode.""" data = {ATTR_MEDIA_REPEAT: repeat} await self._async_call_service(SERVICE_REPEAT_SET, data, allow_override=True) @@ -628,6 +628,20 @@ class UniversalMediaPlayer(MediaPlayerEntity): # Delegate to turn_on or turn_off by default await super().async_toggle() + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Return a BrowseMedia instance.""" + entity_id = self._browse_media_entity + if not entity_id and self._child_state: + entity_id = self._child_state.entity_id + component: EntityComponent[MediaPlayerEntity] = self.hass.data[DOMAIN] + if entity_id and (entity := component.get_entity(entity_id)): + return await entity.async_browse_media(media_content_type, media_content_id) + raise NotImplementedError() + async def async_update(self) -> None: """Update state in HA.""" self._child_state = None diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index 3702751ef4..00cebe1e0d 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], - "requirements": ["upb_lib==0.5.3"] + "requirements": ["upb_lib==0.5.4"] } diff --git a/homeassistant/components/upc_connect/device_tracker.py b/homeassistant/components/upc_connect/device_tracker.py index 3025ea746d..2b5ee2915e 100644 --- a/homeassistant/components/upc_connect/device_tracker.py +++ b/homeassistant/components/upc_connect/device_tracker.py @@ -57,7 +57,7 @@ async def async_get_scanner( class UPCDeviceScanner(DeviceScanner): - """This class queries a router running UPC ConnectBox firmware.""" + """Class which queries a router running UPC ConnectBox firmware.""" def __init__(self, connect_box: ConnectBox) -> None: """Initialize the scanner.""" diff --git a/homeassistant/components/upcloud/config_flow.py b/homeassistant/components/upcloud/config_flow.py index e6868be29b..fda6c1d561 100644 --- a/homeassistant/components/upcloud/config_flow.py +++ b/homeassistant/components/upcloud/config_flow.py @@ -55,6 +55,9 @@ class UpCloudConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", user_input=user_input, errors=errors ) + self._abort_if_unique_id_configured( + updates={CONF_PASSWORD: user_input[CONF_PASSWORD]} + ) return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) @callback diff --git a/homeassistant/components/upcloud/strings.json b/homeassistant/components/upcloud/strings.json index 3daf70be59..fc52916e9b 100644 --- a/homeassistant/components/upcloud/strings.json +++ b/homeassistant/components/upcloud/strings.json @@ -11,6 +11,9 @@ "password": "[%key:common::config_flow::data::password%]" } } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, "options": { diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index c26d3968ae..776c2c59a3 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -6,5 +6,13 @@ "turned_on": "{entity_name} got an update available", "turned_off": "{entity_name} became up-to-date" } + }, + "entity_component": { + "_": { + "name": "[%key:component::update::title%]" + }, + "firmware": { + "name": "firmware" + } } } diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index ac9fe19f4e..5f77d58c5e 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from datetime import timedelta +import async_timeout from async_upnp_client.exceptions import UpnpConnectionError from homeassistant.components import ssdp @@ -11,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( CONFIG_ENTRY_HOST, @@ -70,7 +71,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: - await asyncio.wait_for(device_discovered_event.wait(), timeout=10) + async with async_timeout.timeout(10): + await device_discovered_event.wait() except asyncio.TimeoutError as err: raise ConfigEntryNotReady(f"Device not discovered: {usn}") from err finally: @@ -116,11 +118,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if device.serial_number: identifiers.add((IDENTIFIER_SERIAL_NUMBER, device.serial_number)) - connections = {(device_registry.CONNECTION_UPNP, device.udn)} + connections = {(dr.CONNECTION_UPNP, device.udn)} if device_mac_address: - connections.add((device_registry.CONNECTION_NETWORK_MAC, device_mac_address)) + connections.add((dr.CONNECTION_NETWORK_MAC, device_mac_address)) - dev_registry = device_registry.async_get(hass) + dev_registry = dr.async_get(hass) device_entry = dev_registry.async_get_device( identifiers=identifiers, connections=connections ) diff --git a/homeassistant/components/upnp/coordinator.py b/homeassistant/components/upnp/coordinator.py index 2820a58463..72e14ecc4f 100644 --- a/homeassistant/components/upnp/coordinator.py +++ b/homeassistant/components/upnp/coordinator.py @@ -1,7 +1,6 @@ """UPnP/IGD coordinator.""" -from datetime import timedelta -from typing import Any +from datetime import datetime, timedelta from async_upnp_client.exceptions import UpnpCommunicationError @@ -13,7 +12,9 @@ from .const import LOGGER from .device import Device -class UpnpDataUpdateCoordinator(DataUpdateCoordinator): +class UpnpDataUpdateCoordinator( + DataUpdateCoordinator[dict[str, str | datetime | int | float | None]] +): """Define an object to update data from UPNP device.""" def __init__( @@ -34,7 +35,9 @@ class UpnpDataUpdateCoordinator(DataUpdateCoordinator): update_interval=update_interval, ) - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data( + self, + ) -> dict[str, str | datetime | int | float | None]: """Update data.""" try: return await self.device.async_get_data() diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index ed06a9eb36..b62edbf9bc 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -1,6 +1,7 @@ """Home Assistant representation of an UPnP/IGD.""" from __future__ import annotations +from datetime import datetime from functools import partial from ipaddress import ip_address from typing import Any @@ -68,7 +69,9 @@ class Device: """Initialize UPnP/IGD device.""" self.hass = hass self._igd_device = igd_device - self.coordinator: DataUpdateCoordinator | None = None + self.coordinator: DataUpdateCoordinator[ + dict[str, str | datetime | int | float | None] + ] | None = None self.original_udn: str | None = None async def async_get_mac_address(self) -> str | None: @@ -134,7 +137,7 @@ class Device: """Get string representation.""" return f"IGD Device: {self.name}/{self.udn}::{self.device_type}" - async def async_get_data(self) -> dict[str, Any]: + async def async_get_data(self) -> dict[str, str | datetime | int | float | None]: """Get all data from device.""" _LOGGER.debug("Getting data for device: %s", self) igd_state = await self._igd_device.async_get_traffic_and_status_data() diff --git a/homeassistant/components/upnp/entity.py b/homeassistant/components/upnp/entity.py index b787018adc..cd39609d9d 100644 --- a/homeassistant/components/upnp/entity.py +++ b/homeassistant/components/upnp/entity.py @@ -13,7 +13,6 @@ from .coordinator import UpnpDataUpdateCoordinator class UpnpEntityDescription(EntityDescription): """UPnP entity description.""" - format: str = "s" unique_id: str | None = None value_key: str | None = None 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/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 1a374714be..6f0fe340f3 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from homeassistant.components.sensor import ( SensorDeviceClass, @@ -52,9 +53,9 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( icon="mdi:server-network", device_class=SensorDeviceClass.DATA_SIZE, native_unit_of_measurement=UnitOfInformation.BYTES, - format="d", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), UpnpSensorEntityDescription( key=BYTES_SENT, @@ -62,27 +63,27 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( icon="mdi:server-network", device_class=SensorDeviceClass.DATA_SIZE, native_unit_of_measurement=UnitOfInformation.BYTES, - format="d", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), UpnpSensorEntityDescription( key=PACKETS_RECEIVED, name=f"{DATA_PACKETS} received", icon="mdi:server-network", native_unit_of_measurement=DATA_PACKETS, - format="d", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), UpnpSensorEntityDescription( key=PACKETS_SENT, name=f"{DATA_PACKETS} sent", icon="mdi:server-network", native_unit_of_measurement=DATA_PACKETS, - format="d", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), UpnpSensorEntityDescription( key=ROUTER_IP, @@ -96,8 +97,8 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( icon="mdi:server-network", native_unit_of_measurement=UnitOfTime.SECONDS, entity_registry_enabled_default=False, - format="d", entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, ), UpnpSensorEntityDescription( key=WAN_STATUS, @@ -114,8 +115,8 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( icon="mdi:server-network", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - format=".1f", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), UpnpSensorEntityDescription( key=BYTES_SENT, @@ -125,8 +126,8 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( icon="mdi:server-network", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - format=".1f", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), UpnpSensorEntityDescription( key=PACKETS_RECEIVED, @@ -135,9 +136,9 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( name=f"{DATA_RATE_PACKETS_PER_SECOND} received", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, - format=".1f", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), UpnpSensorEntityDescription( key=PACKETS_SENT, @@ -146,9 +147,9 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( name=f"{DATA_RATE_PACKETS_PER_SECOND} sent", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, - format=".1f", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), ) @@ -180,10 +181,8 @@ class UpnpSensor(UpnpEntity, SensorEntity): entity_description: UpnpSensorEntityDescription @property - def native_value(self) -> str | None: + def native_value(self) -> str | datetime | int | float | None: """Return the state of the device.""" if (key := self.entity_description.value_key) is None: return None - if (value := self.coordinator.data[key]) is None: - return None - return format(value, self.entity_description.format) + return self.coordinator.data[key] 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/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py index 28927baf92..1c6c1b0423 100644 --- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py +++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py @@ -165,7 +165,9 @@ class UsgsEarthquakesFeedEntityManager: await self.async_update() # Trigger updates at regular intervals. - async_track_time_interval(self._hass, update, self._scan_interval) + async_track_time_interval( + self._hass, update, self._scan_interval, cancel_on_shutdown=True + ) _LOGGER.debug("Feed entity manager initialized") async def async_update(self) -> None: @@ -195,7 +197,7 @@ class UsgsEarthquakesFeedEntityManager: class UsgsEarthquakesEvent(GeolocationEvent): - """This represents an external event with USGS Earthquake data.""" + """Represents an external event with USGS Earthquake data.""" _attr_icon = "mdi:pulse" _attr_should_poll = False 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/vacuum/device_action.py b/homeassistant/components/vacuum/device_action.py index e8fe53b08a..9b53c76124 100644 --- a/homeassistant/components/vacuum/device_action.py +++ b/homeassistant/components/vacuum/device_action.py @@ -11,7 +11,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -31,11 +31,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Vacuum devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/vacuum/device_condition.py b/homeassistant/components/vacuum/device_condition.py index fa76dd800e..cf5b093466 100644 --- a/homeassistant/components/vacuum/device_condition.py +++ b/homeassistant/components/vacuum/device_condition.py @@ -12,7 +12,11 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -32,11 +36,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Vacuum devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/vacuum/device_trigger.py b/homeassistant/components/vacuum/device_trigger.py index c90aa1756e..6a2646922b 100644 --- a/homeassistant/components/vacuum/device_trigger.py +++ b/homeassistant/components/vacuum/device_trigger.py @@ -14,7 +14,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -35,11 +35,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Vacuum devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 033946735f..a27a60bba4 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -14,16 +14,19 @@ "dock": "Let {entity_name} return to the dock" } }, - "state": { + "entity_component": { "_": { - "cleaning": "Cleaning", - "docked": "Docked", - "error": "Error", - "idle": "[%key:common::state::idle%]", - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]", - "paused": "[%key:common::state::paused%]", - "returning": "Returning to dock" + "name": "[%key:component::vacuum::title%]", + "state": { + "cleaning": "Cleaning", + "docked": "Docked", + "error": "Error", + "idle": "[%key:common::state::idle%]", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "paused": "[%key:common::state::paused%]", + "returning": "Returning to dock" + } } } } diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 579accc809..6f8d00eb48 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -17,12 +17,12 @@ from vallox_websocket_api.vallox import ( ) import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.typing import ConfigType, StateType +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -159,22 +159,6 @@ class ValloxDataUpdateCoordinator(DataUpdateCoordinator[ValloxState]): """The DataUpdateCoordinator for Vallox.""" -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the integration from configuration.yaml (DEPRECATED).""" - if DOMAIN not in config: - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the client and boot the platforms.""" host = entry.data[CONF_HOST] diff --git a/homeassistant/components/vallox/config_flow.py b/homeassistant/components/vallox/config_flow.py index b9d29b1768..cfc5993797 100644 --- a/homeassistant/components/vallox/config_flow.py +++ b/homeassistant/components/vallox/config_flow.py @@ -40,38 +40,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, data: dict[str, Any]) -> FlowResult: - """Handle import from YAML.""" - # We need to use the name from the YAML configuration to avoid - # breaking existing entity IDs. - name = data.get(CONF_NAME, DEFAULT_NAME) - host = data[CONF_HOST] - - self._async_abort_entries_match({CONF_HOST: host}) - - reason = None - try: - await validate_host(self.hass, host) - except InvalidHost: - _LOGGER.error("An invalid host is configured for Vallox: %s", host) - reason = "invalid_host" - except ValloxApiException: - _LOGGER.error("Cannot connect to Vallox host %s", host) - reason = "cannot_connect" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - reason = "unknown" - else: - return self.async_create_entry( - title=name, - data={ - **data, - CONF_NAME: name, - }, - ) - - return self.async_abort(reason=reason) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: 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 fc451ff262..b2b1cb3162 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -13,9 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import device_registry -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.storage import STORAGE_DIR from .const import ( @@ -36,6 +34,7 @@ PLATFORMS = [ Platform.CLIMATE, Platform.COVER, Platform.LIGHT, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] @@ -55,10 +54,8 @@ async def velbus_connect_task( def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None: """Migrate old device indentifiers.""" - dev_reg = device_registry.async_get(hass) - devices: list[DeviceEntry] = device_registry.async_entries_for_config_entry( - dev_reg, entry_id - ) + dev_reg = dr.async_get(hass) + devices: list[dr.DeviceEntry] = dr.async_entries_for_config_entry(dev_reg, entry_id) for device in devices: old_identifier = list(next(iter(device.identifiers))) if len(old_identifier) > 2: @@ -213,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/light.py b/homeassistant/components/velbus/light.py index e89c81bc11..ca00a3134c 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -109,7 +109,7 @@ class VelbusButtonLight(VelbusEntity, LightEntity): self._attr_name = f"LED {self._channel.get_name()}" @property - def is_on(self) -> Any: + def is_on(self) -> bool: """Return true if the light is on.""" return self._channel.is_on() 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/vermont_castings/__init__.py b/homeassistant/components/vermont_castings/__init__.py new file mode 100644 index 0000000000..d2c0b77514 --- /dev/null +++ b/homeassistant/components/vermont_castings/__init__.py @@ -0,0 +1 @@ +"""Virtual integration for Vermont Castings fireplace.""" diff --git a/homeassistant/components/vermont_castings/manifest.json b/homeassistant/components/vermont_castings/manifest.json new file mode 100644 index 0000000000..301db38c8b --- /dev/null +++ b/homeassistant/components/vermont_castings/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "vermont_castings", + "name": "Vermont Castings", + "integration_type": "virtual", + "supported_by": "intellifire" +} diff --git a/homeassistant/components/version/const.py b/homeassistant/components/version/const.py index 1693f79ec6..bdebf9f025 100644 --- a/homeassistant/components/version/const.py +++ b/homeassistant/components/version/const.py @@ -69,6 +69,7 @@ BOARD_MAP: Final[dict[str, str]] = { "ASUS Tinkerboard": "tinker", "ODROID C2": "odroid-c2", "ODROID C4": "odroid-c4", + "ODROID M1": "odroid-m1", "ODROID N2": "odroid-n2", "ODROID XU4": "odroid-xu4", "Generic AArch64": "generic-aarch64", @@ -97,6 +98,7 @@ VALID_IMAGES: Final = [ "generic-x86-64", "intel-nuc", "odroid-c2", + "odroid-m1", "odroid-n2", "odroid-xu", "qemuarm-64", diff --git a/homeassistant/components/vesync/diagnostics.py b/homeassistant/components/vesync/diagnostics.py new file mode 100644 index 0000000000..8043e93b9e --- /dev/null +++ b/homeassistant/components/vesync/diagnostics.py @@ -0,0 +1,119 @@ +"""Diagnostics support for VeSync.""" +from __future__ import annotations + +from typing import Any + +from pyvesync import VeSync + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry + +from .common import VeSyncBaseDevice +from .const import DOMAIN, VS_MANAGER + +KEYS_TO_REDACT = {"manager", "uuid", "mac_id"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + manager: VeSync = hass.data[DOMAIN][VS_MANAGER] + + data = { + DOMAIN: { + "bulb_count": len(manager.bulbs), + "fan_count": len(manager.fans), + "outlets_count": len(manager.outlets), + "switch_count": len(manager.switches), + "timezone": manager.time_zone, + }, + "devices": { + "bulbs": [_redact_device_values(device) for device in manager.bulbs], + "fans": [_redact_device_values(device) for device in manager.fans], + "outlets": [_redact_device_values(device) for device in manager.outlets], + "switches": [_redact_device_values(device) for device in manager.switches], + }, + } + + return data + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + manager: VeSync = hass.data[DOMAIN][VS_MANAGER] + device_dict = _build_device_dict(manager) + vesync_device_id = next(iden[1] for iden in device.identifiers if iden[0] == DOMAIN) + + # Base device information, without sensitive information. + data = _redact_device_values(device_dict[vesync_device_id]) + + data["home_assistant"] = { + "name": device.name, + "name_by_user": device.name_by_user, + "disabled": device.disabled, + "disabled_by": device.disabled_by, + "entities": [], + } + + # Gather information how this VeSync device is represented in Home Assistant + entity_registry = er.async_get(hass) + hass_entities = er.async_entries_for_device( + entity_registry, + device_id=device.id, + include_disabled_entities=True, + ) + + for entity_entry in hass_entities: + state = hass.states.get(entity_entry.entity_id) + state_dict = None + if state: + state_dict = dict(state.as_dict()) + # The context doesn't provide useful information in this case. + state_dict.pop("context", None) + + data["home_assistant"]["entities"].append( + { + "domain": entity_entry.domain, + "entity_id": entity_entry.entity_id, + "entity_category": entity_entry.entity_category, + "device_class": entity_entry.device_class, + "original_device_class": entity_entry.original_device_class, + "name": entity_entry.name, + "original_name": entity_entry.original_name, + "icon": entity_entry.icon, + "original_icon": entity_entry.original_icon, + "unit_of_measurement": entity_entry.unit_of_measurement, + "state": state_dict, + "disabled": entity_entry.disabled, + "disabled_by": entity_entry.disabled_by, + } + ) + + return data + + +def _build_device_dict(manager: VeSync) -> dict: + """Build a dictionary of ALL VeSync devices.""" + device_dict = {x.cid: x for x in manager.switches} + device_dict.update({x.cid: x for x in manager.fans}) + device_dict.update({x.cid: x for x in manager.outlets}) + device_dict.update({x.cid: x for x in manager.bulbs}) + return device_dict + + +def _redact_device_values(device: VeSyncBaseDevice) -> dict: + """Rebuild and redact values of a VeSync device.""" + data = {} + for key, item in device.__dict__.items(): + if key not in KEYS_TO_REDACT: + data[key] = item + else: + data[key] = REDACTED + + return data 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/__init__.py b/homeassistant/components/vizio/__init__.py index 9fc40c40c2..d694f4b93f 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -66,8 +67,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_APPS not in hass.data[DOMAIN] and entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV ): - coordinator = VizioAppsDataUpdateCoordinator(hass) - await coordinator.async_refresh() + store: Store = Store(hass, 1, DOMAIN) + coordinator = VizioAppsDataUpdateCoordinator(hass, store) + await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][CONF_APPS] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -98,7 +100,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Define an object to hold Vizio app config data.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, store: Store) -> None: """Initialize.""" super().__init__( hass, @@ -107,31 +109,40 @@ class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]] update_interval=timedelta(days=1), update_method=self._async_update_data, ) - self.data = APPS self.fail_count = 0 self.fail_threshold = 10 + self.store = store + + async def async_config_entry_first_refresh(self) -> None: + """Refresh data for the first time when a config entry is setup.""" + self.data = await self.store.async_load() or APPS + await super().async_config_entry_first_refresh() async def _async_update_data(self) -> list[dict[str, Any]]: """Update data via library.""" - data = await gen_apps_list_from_url(session=async_get_clientsession(self.hass)) - if not data: - # For every failure, increase the fail count until we reach the threshold. - # We then log a warning, increase the threshold, and reset the fail count. - # This is here to prevent silent failures but to reduce repeat logs. - if self.fail_count == self.fail_threshold: - _LOGGER.warning( - ( - "Unable to retrieve the apps list from the external server " - "for the last %s days" - ), - self.fail_threshold, - ) - self.fail_count = 0 - self.fail_threshold += 10 - else: - self.fail_count += 1 - return self.data - # Reset the fail count and threshold when the data is successfully retrieved - self.fail_count = 0 - self.fail_threshold = 10 - return sorted(data, key=lambda app: app["name"]) + if data := await gen_apps_list_from_url( + session=async_get_clientsession(self.hass) + ): + # Reset the fail count and threshold when the data is successfully retrieved + self.fail_count = 0 + self.fail_threshold = 10 + # Store the new data if it has changed so we have it for the next restart + if data != self.data: + await self.store.async_save(data) + return data + # For every failure, increase the fail count until we reach the threshold. + # We then log a warning, increase the threshold, and reset the fail count. + # This is here to prevent silent failures but to reduce repeat logs. + if self.fail_count == self.fail_threshold: + _LOGGER.warning( + ( + "Unable to retrieve the apps list from the external server for the " + "last %s days" + ), + self.fail_threshold, + ) + self.fail_count = 0 + self.fail_threshold += 10 + else: + self.fail_count += 1 + return self.data diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index 572aba0829..e6812ed58b 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -4,10 +4,10 @@ "codeowners": ["@raman325"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vizio", - "integration_type": "hub", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["pyvizio"], "quality_scale": "platinum", - "requirements": ["pyvizio==0.1.57"], + "requirements": ["pyvizio==0.1.61"], "zeroconf": ["_viziocast._tcp.local."] } diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index e1ca306ddf..bf52f053b3 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -131,13 +131,15 @@ async def async_setup_entry( class VizioDevice(MediaPlayerEntity): """Media Player implementation which performs REST requests to device.""" + _attr_name: str + def __init__( self, config_entry: ConfigEntry, device: VizioAsync, name: str, device_class: MediaPlayerDeviceClass, - apps_coordinator: VizioAppsDataUpdateCoordinator, + apps_coordinator: VizioAppsDataUpdateCoordinator | None, ) -> None: """Initialize Vizio device.""" self._config_entry = config_entry @@ -330,17 +332,21 @@ class VizioDevice(MediaPlayerEntity): ) ) + if not self._apps_coordinator: + return + # Register callback for app list updates if device is a TV @callback - def apps_list_update(): + def apps_list_update() -> None: """Update list of all apps.""" + if not self._apps_coordinator: + return self._all_apps = self._apps_coordinator.data self.async_write_ha_state() - if self._attr_device_class == MediaPlayerDeviceClass.TV: - self.async_on_remove( - self._apps_coordinator.async_add_listener(apps_list_update) - ) + self.async_on_remove( + self._apps_coordinator.async_add_listener(apps_list_update) + ) @property def source(self) -> str | None: 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/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index a8e12cd771..80b9d75303 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -21,7 +21,6 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -293,14 +292,8 @@ class VlcDevice(MediaPlayerEntity): sourced_media = await media_source.async_resolve_media( self.hass, media_id, self.entity_id ) - media_type = sourced_media.mime_type media_id = sourced_media.url - if media_type != MediaType.MUSIC and not media_type.startswith("audio/"): - raise HomeAssistantError( - f"Invalid media type {media_type}. Only {MediaType.MUSIC} is supported" - ) - # If media ID is a relative URL, we serve it from HA. media_id = async_process_play_media_url( self.hass, media_id, for_supervisor_network=self._using_addon @@ -336,8 +329,4 @@ class VlcDevice(MediaPlayerEntity): 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, - content_filter=lambda item: item.media_content_type.startswith("audio/"), - ) + return await media_source.async_browse_media(self.hass, media_content_id) diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py new file mode 100644 index 0000000000..f29705cf41 --- /dev/null +++ b/homeassistant/components/voip/__init__.py @@ -0,0 +1,127 @@ +"""The Voice over IP integration.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from voip_utils import SIP_PORT + +from homeassistant.auth.const import GROUP_ID_USER +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import CONF_SIP_PORT, DOMAIN +from .devices import VoIPDevices +from .voip import HassVoipDatagramProtocol + +PLATFORMS = ( + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SWITCH, +) +_LOGGER = logging.getLogger(__name__) +_IP_WILDCARD = "0.0.0.0" + +__all__ = [ + "DOMAIN", + "async_setup_entry", + "async_unload_entry", + "async_remove_config_entry_device", +] + + +@dataclass +class DomainData: + """Domain data.""" + + transport: asyncio.DatagramTransport + protocol: HassVoipDatagramProtocol + devices: VoIPDevices + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up VoIP integration from a config entry.""" + # Make sure there is a valid user ID for VoIP in the config entry + if ( + "user" not in entry.data + or (await hass.auth.async_get_user(entry.data["user"])) is None + ): + voip_user = await hass.auth.async_create_system_user( + "Voice over IP", group_ids=[GROUP_ID_USER] + ) + hass.config_entries.async_update_entry( + entry, data={**entry.data, "user": voip_user.id} + ) + + sip_port = entry.options.get(CONF_SIP_PORT, SIP_PORT) + devices = VoIPDevices(hass, entry) + devices.async_setup() + transport, protocol = await _create_sip_server( + hass, + lambda: HassVoipDatagramProtocol(hass, devices), + sip_port, + ) + _LOGGER.debug("Listening for VoIP calls on port %s", sip_port) + + hass.data[DOMAIN] = DomainData(transport, protocol, devices) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def _create_sip_server( + hass: HomeAssistant, + protocol_factory: Callable[ + [], + asyncio.DatagramProtocol, + ], + sip_port: int, +) -> tuple[asyncio.DatagramTransport, HassVoipDatagramProtocol]: + transport, protocol = await hass.loop.create_datagram_endpoint( + protocol_factory, + local_addr=(_IP_WILDCARD, sip_port), + ) + + if not isinstance(protocol, HassVoipDatagramProtocol): + raise TypeError(f"Expected HassVoipDatagramProtocol, got {protocol}") + + return transport, protocol + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload VoIP.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + _LOGGER.debug("Shutting down VoIP server") + data = hass.data.pop(DOMAIN) + data.transport.close() + await data.protocol.wait_closed() + _LOGGER.debug("VoIP server shut down successfully") + + return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove device from a config entry.""" + return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove VoIP entry.""" + if "user" in entry.data and ( + user := await hass.auth.async_get_user(entry.data["user"]) + ): + await hass.auth.async_remove_user(user) diff --git a/homeassistant/components/voip/binary_sensor.py b/homeassistant/components/voip/binary_sensor.py new file mode 100644 index 0000000000..8eeefbd5d9 --- /dev/null +++ b/homeassistant/components/voip/binary_sensor.py @@ -0,0 +1,60 @@ +"""Binary sensor for VoIP.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .devices import VoIPDevice +from .entity import VoIPEntity + +if TYPE_CHECKING: + from . import DomainData + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP binary sensor entities.""" + domain_data: DomainData = hass.data[DOMAIN] + + @callback + def async_add_device(device: VoIPDevice) -> None: + """Add device.""" + async_add_entities([VoIPCallInProgress(device)]) + + domain_data.devices.async_add_new_device_listener(async_add_device) + + async_add_entities([VoIPCallInProgress(device) for device in domain_data.devices]) + + +class VoIPCallInProgress(VoIPEntity, BinarySensorEntity): + """Entity to represent voip call is in progress.""" + + entity_description = BinarySensorEntityDescription( + key="call_in_progress", + translation_key="call_in_progress", + ) + _attr_is_on = False + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + self.async_on_remove(self._device.async_listen_update(self._is_active_changed)) + + @callback + def _is_active_changed(self, device: VoIPDevice) -> None: + """Call when active state changed.""" + self._attr_is_on = self._device.is_active + self.async_write_ha_state() diff --git a/homeassistant/components/voip/config_flow.py b/homeassistant/components/voip/config_flow.py new file mode 100644 index 0000000000..3af15bd2c0 --- /dev/null +++ b/homeassistant/components/voip/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for VoIP integration.""" +from __future__ import annotations + +from typing import Any + +from voip_utils import SIP_PORT +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv + +from .const import CONF_SIP_PORT, DOMAIN + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for VoIP integration.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is None: + return self.async_show_form( + step_id="user", + ) + + return self.async_create_entry( + title="Voice over IP", + data=user_input, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return VoipOptionsFlowHandler(config_entry) + + +class VoipOptionsFlowHandler(config_entries.OptionsFlow): + """Handle VoIP options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_SIP_PORT, + default=self.config_entry.options.get( + CONF_SIP_PORT, + SIP_PORT, + ), + ): cv.port + } + ), + ) diff --git a/homeassistant/components/voip/const.py b/homeassistant/components/voip/const.py new file mode 100644 index 0000000000..b4ee5d8ce7 --- /dev/null +++ b/homeassistant/components/voip/const.py @@ -0,0 +1,15 @@ +"""Constants for the Voice over IP integration.""" + +DOMAIN = "voip" + +RATE = 16000 +WIDTH = 2 +CHANNELS = 1 +RTP_AUDIO_SETTINGS = { + "rate": RATE, + "width": WIDTH, + "channels": CHANNELS, + "sleep_ratio": 0.99, +} + +CONF_SIP_PORT = "sip_port" diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py new file mode 100644 index 0000000000..5da7a97ec2 --- /dev/null +++ b/homeassistant/components/voip/devices.py @@ -0,0 +1,155 @@ +"""Class to manage devices.""" +from __future__ import annotations + +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field + +from voip_utils import CallInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import DOMAIN + + +@dataclass +class VoIPDevice: + """Class to store device.""" + + voip_id: str + device_id: str + is_active: bool = False + update_listeners: list[Callable[[VoIPDevice], None]] = field(default_factory=list) + + @callback + def set_is_active(self, active: bool) -> None: + """Set active state.""" + self.is_active = active + for listener in self.update_listeners: + listener(self) + + @callback + def async_listen_update( + self, listener: Callable[[VoIPDevice], None] + ) -> Callable[[], None]: + """Listen for updates.""" + self.update_listeners.append(listener) + return lambda: self.update_listeners.remove(listener) + + @callback + def async_allow_call(self, hass: HomeAssistant) -> bool: + """Return if call is allowed.""" + ent_reg = er.async_get(hass) + + allowed_call_entity_id = ent_reg.async_get_entity_id( + "switch", DOMAIN, f"{self.voip_id}-allow_call" + ) + # If 2 requests come in fast, the device registry entry has been created + # but entity might not exist yet. + if allowed_call_entity_id is None: + return False + + if state := hass.states.get(allowed_call_entity_id): + return state.state == "on" + + return False + + +class VoIPDevices: + """Class to store devices.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize VoIP devices.""" + self.hass = hass + self.config_entry = config_entry + self._new_device_listeners: list[Callable[[VoIPDevice], None]] = [] + self.devices: dict[str, VoIPDevice] = {} + + @callback + def async_setup(self) -> None: + """Set up devices.""" + for device in dr.async_entries_for_config_entry( + dr.async_get(self.hass), self.config_entry.entry_id + ): + voip_id = next( + (item[1] for item in device.identifiers if item[0] == DOMAIN), None + ) + if voip_id is None: + continue + self.devices[voip_id] = VoIPDevice( + voip_id=voip_id, + device_id=device.id, + ) + + @callback + def async_device_removed(ev: Event) -> None: + """Handle device removed.""" + removed_id = ev.data["device_id"] + self.devices = { + voip_id: voip_device + for voip_id, voip_device in self.devices.items() + if voip_device.device_id != removed_id + } + + self.config_entry.async_on_unload( + self.hass.bus.async_listen( + dr.EVENT_DEVICE_REGISTRY_UPDATED, + async_device_removed, + callback(lambda ev: ev.data.get("action") == "remove"), + ) + ) + + @callback + def async_add_new_device_listener( + self, listener: Callable[[VoIPDevice], None] + ) -> None: + """Add a new device listener.""" + self._new_device_listeners.append(listener) + + @callback + def async_get_or_create(self, call_info: CallInfo) -> VoIPDevice: + """Get or create a device.""" + user_agent = call_info.headers.get("user-agent", "") + user_agent_parts = user_agent.split() + if len(user_agent_parts) == 3 and user_agent_parts[0] == "Grandstream": + manuf = user_agent_parts[0] + model = user_agent_parts[1] + fw_version = user_agent_parts[2] + else: + manuf = None + model = user_agent if user_agent else None + fw_version = None + + dev_reg = dr.async_get(self.hass) + voip_id = call_info.caller_ip + voip_device = self.devices.get(voip_id) + + if voip_device is not None: + device = dev_reg.async_get(voip_device.device_id) + if device and fw_version and device.sw_version != fw_version: + dev_reg.async_update_device(device.id, sw_version=fw_version) + + return voip_device + + device = dev_reg.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, voip_id)}, + name=voip_id, + manufacturer=manuf, + model=model, + sw_version=fw_version, + configuration_url=f"http://{call_info.caller_ip}", + ) + voip_device = self.devices[voip_id] = VoIPDevice( + voip_id=voip_id, + device_id=device.id, + ) + for listener in self._new_device_listeners: + listener(voip_device) + + return voip_device + + def __iter__(self) -> Iterator[VoIPDevice]: + """Iterate over devices.""" + return iter(self.devices.values()) diff --git a/homeassistant/components/voip/entity.py b/homeassistant/components/voip/entity.py new file mode 100644 index 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/error.pcm b/homeassistant/components/voip/error.pcm new file mode 100644 index 0000000000..3d93cdb14d Binary files /dev/null and b/homeassistant/components/voip/error.pcm differ diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json new file mode 100644 index 0000000000..345480da36 --- /dev/null +++ b/homeassistant/components/voip/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "voip", + "name": "Voice over IP", + "codeowners": ["@balloob", "@synesthesiam"], + "config_flow": true, + "dependencies": ["assist_pipeline"], + "documentation": "https://www.home-assistant.io/integrations/voip", + "iot_class": "local_push", + "quality_scale": "internal", + "requirements": ["voip-utils==0.0.7"] +} diff --git a/homeassistant/components/voip/not_configured.pcm b/homeassistant/components/voip/not_configured.pcm new file mode 100644 index 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..2bef9a1800 --- /dev/null +++ b/homeassistant/components/voip/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "step": { + "user": { + "description": "Receive Voice over IP calls to interact with Assist." + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "entity": { + "binary_sensor": { + "call_in_progress": { + "name": "Call in progress" + } + }, + "switch": { + "allow_call": { + "name": "Allow calls" + } + }, + "select": { + "pipeline": { + "name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sip_port": "SIP port" + } + } + } + } +} diff --git a/homeassistant/components/voip/switch.py b/homeassistant/components/voip/switch.py new file mode 100644 index 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..8b96941e00 --- /dev/null +++ b/homeassistant/components/voip/voip.py @@ -0,0 +1,475 @@ +"""Voice over IP (VoIP) implementation.""" +from __future__ import annotations + +import asyncio +from collections import deque +from collections.abc import AsyncIterable, MutableSequence, Sequence +from functools import partial +import logging +from pathlib import Path +import time +from typing import TYPE_CHECKING + +import async_timeout +from voip_utils import CallInfo, RtpDatagramProtocol, SdpInfo, VoipDatagramProtocol + +from homeassistant.components import stt, tts +from homeassistant.components.assist_pipeline import ( + Pipeline, + PipelineEvent, + PipelineEventType, + async_get_pipeline, + async_pipeline_from_audio_stream, + select as pipeline_select, +) +from homeassistant.components.assist_pipeline.vad import VoiceCommandSegmenter +from homeassistant.const import __version__ +from homeassistant.core import Context, HomeAssistant +from homeassistant.util.ulid import ulid + +from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH + +if TYPE_CHECKING: + from .devices import VoIPDevice, VoIPDevices + +_LOGGER = logging.getLogger(__name__) + + +def make_protocol( + hass: HomeAssistant, devices: VoIPDevices, call_info: CallInfo +) -> VoipDatagramProtocol: + """Plays a pre-recorded message if pipeline is misconfigured.""" + voip_device = devices.async_get_or_create(call_info) + pipeline_id = pipeline_select.get_chosen_pipeline( + hass, + DOMAIN, + voip_device.voip_id, + ) + pipeline = async_get_pipeline(hass, pipeline_id) + if ( + (pipeline is None) + or (pipeline.stt_engine is None) + or (pipeline.tts_engine is None) + ): + # Play pre-recorded message instead of failing + return PreRecordMessageProtocol( + hass, + "problem.pcm", + opus_payload_type=call_info.opus_payload_type, + ) + + # Pipeline is properly configured + return PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(user_id=devices.config_entry.data["user"]), + opus_payload_type=call_info.opus_payload_type, + ) + + +class HassVoipDatagramProtocol(VoipDatagramProtocol): + """HA UDP server for Voice over IP (VoIP).""" + + def __init__(self, hass: HomeAssistant, devices: VoIPDevices) -> None: + """Set up VoIP call handler.""" + super().__init__( + sdp_info=SdpInfo( + username="homeassistant", + id=time.monotonic_ns(), + session_name="voip_hass", + version=__version__, + ), + valid_protocol_factory=lambda call_info: make_protocol( + hass, devices, call_info + ), + invalid_protocol_factory=lambda call_info: PreRecordMessageProtocol( + hass, + "not_configured.pcm", + opus_payload_type=call_info.opus_payload_type, + ), + ) + self.hass = hass + self.devices = devices + self._closed_event = asyncio.Event() + + def is_valid_call(self, call_info: CallInfo) -> bool: + """Filter calls.""" + device = self.devices.async_get_or_create(call_info) + return device.async_allow_call(self.hass) + + def connection_lost(self, exc): + """Signal wait_closed when transport is completely closed.""" + self.hass.loop.call_soon_threadsafe(self._closed_event.set) + + async def wait_closed(self) -> None: + """Wait for connection_lost to be called.""" + await self._closed_event.wait() + + +class PipelineRtpDatagramProtocol(RtpDatagramProtocol): + """Run a voice assistant pipeline in a loop for a VoIP call.""" + + def __init__( + self, + hass: HomeAssistant, + language: str, + voip_device: VoIPDevice, + context: Context, + opus_payload_type: int, + pipeline_timeout: float = 30.0, + audio_timeout: float = 2.0, + buffered_chunks_before_speech: int = 100, + listening_tone_enabled: bool = True, + processing_tone_enabled: bool = True, + error_tone_enabled: bool = True, + tone_delay: float = 0.2, + tts_extra_timeout: float = 1.0, + ) -> None: + """Set up pipeline RTP server.""" + super().__init__( + rate=RATE, + width=WIDTH, + channels=CHANNELS, + opus_payload_type=opus_payload_type, + ) + + self.hass = hass + self.language = language + self.voip_device = voip_device + self.pipeline: Pipeline | None = None + self.pipeline_timeout = pipeline_timeout + self.audio_timeout = audio_timeout + self.buffered_chunks_before_speech = buffered_chunks_before_speech + self.listening_tone_enabled = listening_tone_enabled + self.processing_tone_enabled = processing_tone_enabled + self.error_tone_enabled = error_tone_enabled + self.tone_delay = tone_delay + self.tts_extra_timeout = tts_extra_timeout + + self._audio_queue: asyncio.Queue[bytes] = asyncio.Queue() + self._context = context + self._conversation_id: str | None = None + self._pipeline_task: asyncio.Task | None = None + self._tts_done = asyncio.Event() + self._session_id: str | None = None + self._tone_bytes: bytes | None = None + self._processing_bytes: bytes | None = None + self._error_bytes: bytes | None = None + self._pipeline_error: bool = False + + def connection_made(self, transport): + """Server is ready.""" + super().connection_made(transport) + self.voip_device.set_is_active(True) + + def connection_lost(self, exc): + """Handle connection is lost or closed.""" + super().connection_lost(exc) + self.voip_device.set_is_active(False) + + def on_chunk(self, audio_bytes: bytes) -> None: + """Handle raw audio chunk.""" + if self._pipeline_task is None: + self._clear_audio_queue() + + # Run pipeline until voice command finishes, then start over + self._pipeline_task = self.hass.async_create_background_task( + self._run_pipeline(), + "voip_pipeline_run", + ) + + self._audio_queue.put_nowait(audio_bytes) + + async def _run_pipeline( + self, + ) -> None: + """Forward audio to pipeline STT and handle TTS.""" + if self._session_id is None: + self._session_id = ulid() + + # Play listening tone at the start of each cycle + if self.listening_tone_enabled: + await self._play_listening_tone() + + try: + # Wait for speech before starting pipeline + segmenter = VoiceCommandSegmenter() + chunk_buffer: deque[bytes] = deque( + maxlen=self.buffered_chunks_before_speech, + ) + speech_detected = await self._wait_for_speech( + segmenter, + chunk_buffer, + ) + if not speech_detected: + _LOGGER.debug("No speech detected") + return + + _LOGGER.debug("Starting pipeline") + self._tts_done.clear() + + async def stt_stream(): + try: + async for chunk in self._segment_audio( + segmenter, + chunk_buffer, + ): + yield chunk + + if self.processing_tone_enabled: + await self._play_processing_tone() + except asyncio.TimeoutError: + # Expected after caller hangs up + _LOGGER.debug("Audio timeout") + self._session_id = None + self.disconnect() + finally: + self._clear_audio_queue() + + # Run pipeline with a timeout + async with async_timeout.timeout(self.pipeline_timeout): + await async_pipeline_from_audio_stream( + self.hass, + context=self._context, + event_callback=self._event_callback, + stt_metadata=stt.SpeechMetadata( + language="", # set in async_pipeline_from_audio_stream + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=stt_stream(), + pipeline_id=pipeline_select.get_chosen_pipeline( + self.hass, DOMAIN, self.voip_device.voip_id + ), + conversation_id=self._conversation_id, + tts_audio_output="raw", + ) + + if self._pipeline_error: + self._pipeline_error = False + if self.error_tone_enabled: + await self._play_error_tone() + else: + # Block until TTS is done speaking. + # + # This is set in _send_tts and has a timeout that's based on the + # length of the TTS audio. + await self._tts_done.wait() + + _LOGGER.debug("Pipeline finished") + except asyncio.TimeoutError: + # Expected after caller hangs up + _LOGGER.debug("Pipeline timeout") + self._session_id = None + self.disconnect() + finally: + # Allow pipeline to run again + self._pipeline_task = None + + async def _wait_for_speech( + self, + segmenter: VoiceCommandSegmenter, + chunk_buffer: MutableSequence[bytes], + ): + """Buffer audio chunks until speech is detected. + + Returns True if speech was detected, False otherwise. + """ + # Timeout if no audio comes in for a while. + # This means the caller hung up. + async with async_timeout.timeout(self.audio_timeout): + chunk = await self._audio_queue.get() + + while chunk: + segmenter.process(chunk) + if segmenter.in_command: + return True + + # Buffer until command starts + chunk_buffer.append(chunk) + + async with async_timeout.timeout(self.audio_timeout): + chunk = await self._audio_queue.get() + + return False + + async def _segment_audio( + self, + segmenter: VoiceCommandSegmenter, + chunk_buffer: Sequence[bytes], + ) -> AsyncIterable[bytes]: + """Yield audio chunks until voice command has finished.""" + # Buffered chunks first + for buffered_chunk in chunk_buffer: + yield buffered_chunk + + # Timeout if no audio comes in for a while. + # This means the caller hung up. + async with async_timeout.timeout(self.audio_timeout): + chunk = await self._audio_queue.get() + + while chunk: + if not segmenter.process(chunk): + # Voice command is finished + break + + yield chunk + + async with async_timeout.timeout(self.audio_timeout): + chunk = await self._audio_queue.get() + + def _clear_audio_queue(self) -> None: + while not self._audio_queue.empty(): + self._audio_queue.get_nowait() + + def _event_callback(self, event: PipelineEvent): + if not event.data: + return + + if event.type == PipelineEventType.INTENT_END: + # Capture conversation id + self._conversation_id = event.data["intent_output"]["conversation_id"] + elif event.type == PipelineEventType.TTS_END: + # Send TTS audio to caller over RTP + media_id = event.data["tts_output"]["media_id"] + self.hass.async_create_background_task( + self._send_tts(media_id), + "voip_pipeline_tts", + ) + elif event.type == PipelineEventType.ERROR: + # Play error tone instead of wait for TTS + self._pipeline_error = True + + async def _send_tts(self, media_id: str) -> None: + """Send TTS audio to caller via RTP.""" + try: + if self.transport is None: + return + + _extension, audio_bytes = await tts.async_get_media_source_audio( + self.hass, + media_id, + ) + + _LOGGER.debug("Sending %s byte(s) of audio", len(audio_bytes)) + + # Time out 1 second after TTS audio should be finished + tts_samples = len(audio_bytes) / (WIDTH * CHANNELS) + tts_seconds = tts_samples / RATE + + async with async_timeout.timeout(tts_seconds + self.tts_extra_timeout): + # Assume TTS audio is 16Khz 16-bit mono + await self._async_send_audio(audio_bytes) + except asyncio.TimeoutError as err: + _LOGGER.warning("TTS timeout") + raise err + finally: + # Signal pipeline to restart + self._tts_done.set() + + async def _async_send_audio(self, audio_bytes: bytes, **kwargs): + """Send audio in executor.""" + await self.hass.async_add_executor_job( + partial(self.send_audio, audio_bytes, **RTP_AUDIO_SETTINGS, **kwargs) + ) + + async def _play_listening_tone(self) -> None: + """Play a tone to indicate that Home Assistant is listening.""" + if self._tone_bytes is None: + # Do I/O in executor + self._tone_bytes = await self.hass.async_add_executor_job( + self._load_pcm, + "tone.pcm", + ) + + await self._async_send_audio( + self._tone_bytes, + silence_before=self.tone_delay, + ) + + async def _play_processing_tone(self) -> None: + """Play a tone to indicate that Home Assistant is processing the voice command.""" + if self._processing_bytes is None: + # Do I/O in executor + self._processing_bytes = await self.hass.async_add_executor_job( + self._load_pcm, + "processing.pcm", + ) + + await self._async_send_audio(self._processing_bytes) + + async def _play_error_tone(self) -> None: + """Play a tone to indicate a pipeline error occurred.""" + if self._error_bytes is None: + # Do I/O in executor + self._error_bytes = await self.hass.async_add_executor_job( + self._load_pcm, + "error.pcm", + ) + + await self._async_send_audio(self._error_bytes) + + def _load_pcm(self, file_name: str) -> bytes: + """Load raw audio (16Khz, 16-bit mono).""" + return (Path(__file__).parent / file_name).read_bytes() + + +class PreRecordMessageProtocol(RtpDatagramProtocol): + """Plays a pre-recorded message on a loop.""" + + def __init__( + self, + hass: HomeAssistant, + file_name: str, + opus_payload_type: int, + message_delay: float = 1.0, + loop_delay: float = 2.0, + ) -> None: + """Set up RTP server.""" + super().__init__( + rate=RATE, + width=WIDTH, + channels=CHANNELS, + opus_payload_type=opus_payload_type, + ) + self.hass = hass + self.file_name = file_name + self.message_delay = message_delay + self.loop_delay = loop_delay + self._audio_task: asyncio.Task | None = None + self._audio_bytes: bytes | None = None + + def on_chunk(self, audio_bytes: bytes) -> None: + """Handle raw audio chunk.""" + if self.transport is None: + return + + if self._audio_bytes is None: + # 16Khz, 16-bit mono audio message + file_path = Path(__file__).parent / self.file_name + self._audio_bytes = file_path.read_bytes() + + if self._audio_task is None: + self._audio_task = self.hass.async_create_background_task( + self._play_message(), + "voip_not_connected", + ) + + async def _play_message(self) -> None: + await self.hass.async_add_executor_job( + partial( + self.send_audio, + self._audio_bytes, + silence_before=self.message_delay, + **RTP_AUDIO_SETTINGS, + ) + ) + + await asyncio.sleep(self.loop_delay) + + # Allow message to play again + self._audio_task = None diff --git a/homeassistant/components/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/manifest.json b/homeassistant/components/volvooncall/manifest.json index 99553426ea..89a35ecde1 100644 --- a/homeassistant/components/volvooncall/manifest.json +++ b/homeassistant/components/volvooncall/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/volvooncall", "iot_class": "cloud_polling", "loggers": ["geopy", "hbmqtt", "volvooncall"], - "requirements": ["volvooncall==0.10.2"] + "requirements": ["volvooncall==0.10.3"] } 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/water_heater/device_action.py b/homeassistant/components/water_heater/device_action.py index 6bc7e1ca63..8ae75527ab 100644 --- a/homeassistant/components/water_heater/device_action.py +++ b/homeassistant/components/water_heater/device_action.py @@ -13,7 +13,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -33,10 +33,10 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Water Heater devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 3d9ab67eab..6344b5a847 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -5,15 +5,18 @@ "turn_off": "Turn off {entity_name}" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "eco": "Eco", - "electric": "Electric", - "gas": "Gas", - "high_demand": "High Demand", - "heat_pump": "Heat Pump", - "performance": "Performance" + "name": "Water heater", + "state": { + "off": "[%key:common::state::off%]", + "eco": "Eco", + "electric": "Electric", + "gas": "Gas", + "high_demand": "High Demand", + "heat_pump": "Heat Pump", + "performance": "Performance" + } } } } diff --git a/homeassistant/components/watson_tts/manifest.json b/homeassistant/components/watson_tts/manifest.json index 2f9404bf14..f26fc00656 100644 --- a/homeassistant/components/watson_tts/manifest.json +++ b/homeassistant/components/watson_tts/manifest.json @@ -2,6 +2,7 @@ "domain": "watson_tts", "name": "IBM Watson TTS", "codeowners": ["@rutkai"], + "disabled": "Dependencies not compatible with the new pip resolver", "documentation": "https://www.home-assistant.io/integrations/watson_tts", "iot_class": "cloud_push", "loggers": ["ibm_cloud_sdk_core", "ibm_watson"], diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index efd20e37e8..08e910feae 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -1,8 +1,10 @@ """Support for IBM Watson TTS integration.""" import logging -from ibm_cloud_sdk_core.authenticators import IAMAuthenticator -from ibm_watson import TextToSpeechV1 +from ibm_cloud_sdk_core.authenticators import ( # pylint: disable=import-error + IAMAuthenticator, +) +from ibm_watson import TextToSpeechV1 # pylint: disable=import-error import voluptuous as vol from homeassistant.components.tts import PLATFORM_SCHEMA, Provider @@ -21,9 +23,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 +30,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 +70,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 +89,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 f69f9a019f..cf709805f6 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import Any from WazeRouteCalculator import WazeRouteCalculator, WRCError @@ -59,8 +60,6 @@ async def async_setup_entry( name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) data = WazeTravelTimeData( - None, - None, region, config_entry, ) @@ -84,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 @@ -112,7 +118,7 @@ class WazeTravelTime(SensorEntity): return None @property - def extra_state_attributes(self) -> dict | None: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the last update.""" if self._waze_data.duration is None: return None @@ -125,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() @@ -141,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/weather/__init__.py b/homeassistant/components/weather/__init__.py index 52642c4f1b..0a99b6aaaf 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -1,7 +1,6 @@ """Weather component that handles meteorological data for your location.""" from __future__ import annotations -from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass from datetime import timedelta @@ -16,8 +15,6 @@ from homeassistant.const import ( PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE, - UnitOfLength, - UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, @@ -30,14 +27,27 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.unit_conversion import ( - DistanceConverter, - PressureConverter, - SpeedConverter, - TemperatureConverter, -) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM +from .const import ( + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRECIPITATION_UNIT, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_VISIBILITY_UNIT, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, + DOMAIN, + UNIT_CONVERSIONS, + VALID_UNITS, +) +from .websocket_api import async_setup as async_setup_ws_api + _LOGGER = logging.getLogger(__name__) ATTR_CONDITION_CLASS = "condition_class" @@ -71,20 +81,6 @@ ATTR_FORECAST_TIME: Final = "datetime" ATTR_FORECAST_WIND_BEARING: Final = "wind_bearing" ATTR_FORECAST_NATIVE_WIND_SPEED: Final = "native_wind_speed" ATTR_FORECAST_WIND_SPEED: Final = "wind_speed" -ATTR_WEATHER_HUMIDITY = "humidity" -ATTR_WEATHER_OZONE = "ozone" -ATTR_WEATHER_PRESSURE = "pressure" -ATTR_WEATHER_PRESSURE_UNIT = "pressure_unit" -ATTR_WEATHER_TEMPERATURE = "temperature" -ATTR_WEATHER_TEMPERATURE_UNIT = "temperature_unit" -ATTR_WEATHER_VISIBILITY = "visibility" -ATTR_WEATHER_VISIBILITY_UNIT = "visibility_unit" -ATTR_WEATHER_WIND_BEARING = "wind_bearing" -ATTR_WEATHER_WIND_SPEED = "wind_speed" -ATTR_WEATHER_WIND_SPEED_UNIT = "wind_speed_unit" -ATTR_WEATHER_PRECIPITATION_UNIT = "precipitation_unit" - -DOMAIN = "weather" ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -92,48 +88,6 @@ SCAN_INTERVAL = timedelta(seconds=30) ROUNDING_PRECISION = 2 -VALID_UNITS_PRESSURE: set[str] = { - UnitOfPressure.HPA, - UnitOfPressure.MBAR, - UnitOfPressure.INHG, - UnitOfPressure.MMHG, -} -VALID_UNITS_TEMPERATURE: set[str] = { - UnitOfTemperature.CELSIUS, - UnitOfTemperature.FAHRENHEIT, -} -VALID_UNITS_PRECIPITATION: set[str] = { - UnitOfPrecipitationDepth.MILLIMETERS, - UnitOfPrecipitationDepth.INCHES, -} -VALID_UNITS_VISIBILITY: set[str] = { - UnitOfLength.KILOMETERS, - UnitOfLength.MILES, -} -VALID_UNITS_WIND_SPEED: set[str] = { - UnitOfSpeed.FEET_PER_SECOND, - UnitOfSpeed.KILOMETERS_PER_HOUR, - UnitOfSpeed.KNOTS, - UnitOfSpeed.METERS_PER_SECOND, - UnitOfSpeed.MILES_PER_HOUR, -} - -UNIT_CONVERSIONS: dict[str, Callable[[float, str, str], float]] = { - ATTR_WEATHER_PRESSURE_UNIT: PressureConverter.convert, - ATTR_WEATHER_TEMPERATURE_UNIT: TemperatureConverter.convert, - ATTR_WEATHER_VISIBILITY_UNIT: DistanceConverter.convert, - ATTR_WEATHER_PRECIPITATION_UNIT: DistanceConverter.convert, - ATTR_WEATHER_WIND_SPEED_UNIT: SpeedConverter.convert, -} - -VALID_UNITS: dict[str, set[str]] = { - ATTR_WEATHER_PRESSURE_UNIT: VALID_UNITS_PRESSURE, - ATTR_WEATHER_TEMPERATURE_UNIT: VALID_UNITS_TEMPERATURE, - ATTR_WEATHER_VISIBILITY_UNIT: VALID_UNITS_VISIBILITY, - ATTR_WEATHER_PRECIPITATION_UNIT: VALID_UNITS_PRECIPITATION, - ATTR_WEATHER_WIND_SPEED_UNIT: VALID_UNITS_WIND_SPEED, -} - # mypy: disallow-any-generics @@ -182,6 +136,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component = hass.data[DOMAIN] = EntityComponent[WeatherEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) + async_setup_ws_api(hass) await component.async_setup(config) return True diff --git a/homeassistant/components/weather/const.py b/homeassistant/components/weather/const.py new file mode 100644 index 0000000000..2dcfd8a2dd --- /dev/null +++ b/homeassistant/components/weather/const.py @@ -0,0 +1,76 @@ +"""Constants for weather.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Final + +from homeassistant.const import ( + UnitOfLength, + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.util.unit_conversion import ( + DistanceConverter, + PressureConverter, + SpeedConverter, + TemperatureConverter, +) + +ATTR_WEATHER_HUMIDITY = "humidity" +ATTR_WEATHER_OZONE = "ozone" +ATTR_WEATHER_PRESSURE = "pressure" +ATTR_WEATHER_PRESSURE_UNIT = "pressure_unit" +ATTR_WEATHER_TEMPERATURE = "temperature" +ATTR_WEATHER_TEMPERATURE_UNIT = "temperature_unit" +ATTR_WEATHER_VISIBILITY = "visibility" +ATTR_WEATHER_VISIBILITY_UNIT = "visibility_unit" +ATTR_WEATHER_WIND_BEARING = "wind_bearing" +ATTR_WEATHER_WIND_SPEED = "wind_speed" +ATTR_WEATHER_WIND_SPEED_UNIT = "wind_speed_unit" +ATTR_WEATHER_PRECIPITATION_UNIT = "precipitation_unit" + +DOMAIN: Final = "weather" + +VALID_UNITS_PRESSURE: set[str] = { + UnitOfPressure.HPA, + UnitOfPressure.MBAR, + UnitOfPressure.INHG, + UnitOfPressure.MMHG, +} +VALID_UNITS_TEMPERATURE: set[str] = { + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, +} +VALID_UNITS_PRECIPITATION: set[str] = { + UnitOfPrecipitationDepth.MILLIMETERS, + UnitOfPrecipitationDepth.INCHES, +} +VALID_UNITS_VISIBILITY: set[str] = { + UnitOfLength.KILOMETERS, + UnitOfLength.MILES, +} +VALID_UNITS_WIND_SPEED: set[str] = { + UnitOfSpeed.FEET_PER_SECOND, + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.KNOTS, + UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.MILES_PER_HOUR, +} + +UNIT_CONVERSIONS: dict[str, Callable[[float, str, str], float]] = { + ATTR_WEATHER_PRESSURE_UNIT: PressureConverter.convert, + ATTR_WEATHER_TEMPERATURE_UNIT: TemperatureConverter.convert, + ATTR_WEATHER_VISIBILITY_UNIT: DistanceConverter.convert, + ATTR_WEATHER_PRECIPITATION_UNIT: DistanceConverter.convert, + ATTR_WEATHER_WIND_SPEED_UNIT: SpeedConverter.convert, +} + +VALID_UNITS: dict[str, set[str]] = { + ATTR_WEATHER_PRESSURE_UNIT: VALID_UNITS_PRESSURE, + ATTR_WEATHER_TEMPERATURE_UNIT: VALID_UNITS_TEMPERATURE, + ATTR_WEATHER_VISIBILITY_UNIT: VALID_UNITS_VISIBILITY, + ATTR_WEATHER_PRECIPITATION_UNIT: VALID_UNITS_PRECIPITATION, + ATTR_WEATHER_WIND_SPEED_UNIT: VALID_UNITS_WIND_SPEED, +} diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index c4764beb5b..a64f846722 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -1,21 +1,66 @@ { - "state": { + "title": "Weather", + "entity_component": { "_": { - "clear-night": "Clear, night", - "cloudy": "Cloudy", - "exceptional": "Exceptional", - "fog": "Fog", - "hail": "Hail", - "lightning": "Lightning", - "lightning-rainy": "Lightning, rainy", - "partlycloudy": "Partly cloudy", - "pouring": "Pouring", - "rainy": "Rainy", - "snowy": "Snowy", - "snowy-rainy": "Snowy, rainy", - "sunny": "Sunny", - "windy": "Windy", - "windy-variant": "Windy" + "name": "[%key:component::weather::title%]", + "state": { + "clear-night": "Clear, night", + "cloudy": "Cloudy", + "exceptional": "Exceptional", + "fog": "Fog", + "hail": "Hail", + "lightning": "Lightning", + "lightning-rainy": "Lightning, rainy", + "partlycloudy": "Partly cloudy", + "pouring": "Pouring", + "rainy": "Rainy", + "snowy": "Snowy", + "snowy-rainy": "Snowy, rainy", + "sunny": "Sunny", + "windy": "Windy", + "windy-variant": "Windy" + }, + "state_attributes": { + "forecast": { + "name": "Forecast" + }, + "humidity": { + "name": "Humidity" + }, + "ozone": { + "name": "Ozone" + }, + "precipitation_unit": { + "name": "Precipitation unit" + }, + "pressure": { + "name": "Pressure" + }, + "pressure_unit": { + "name": "Pressure unit" + }, + "temperature": { + "name": "Temperature" + }, + "temperature_unit": { + "name": "Temperature unit" + }, + "visibility": { + "name": "Visibility" + }, + "visibility_unit": { + "name": "Visibility unit" + }, + "wind_bearing": { + "name": "Wind bearing" + }, + "wind_speed": { + "name": "Wind speed" + }, + "wind_speed_unit": { + "name": "Wind speed unit" + } + } } } } diff --git a/homeassistant/components/weather/websocket_api.py b/homeassistant/components/weather/websocket_api.py new file mode 100644 index 0000000000..51f129fc4a --- /dev/null +++ b/homeassistant/components/weather/websocket_api.py @@ -0,0 +1,33 @@ +"""The weather websocket API.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from .const import VALID_UNITS + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the weather websocket API.""" + websocket_api.async_register_command(hass, ws_convertible_units) + + +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "weather/convertible_units", + } +) +def ws_convertible_units( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Return supported units for a device class.""" + sorted_units = { + key: sorted(units, key=str.casefold) for key, units in VALID_UNITS.items() + } + connection.send_result(msg["id"], {"units": sorted_units}) 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/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 1eac959c16..9152739852 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "loggers": ["aiowebostv"], "quality_scale": "platinum", - "requirements": ["aiowebostv==0.3.2"], + "requirements": ["aiowebostv==0.3.3"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/homeassistant/components/webostv/trigger.py b/homeassistant/components/webostv/trigger.py index 5441917cc3..4d237993f9 100644 --- a/homeassistant/components/webostv/trigger.py +++ b/homeassistant/components/webostv/trigger.py @@ -5,24 +5,28 @@ from typing import cast from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + TriggerProtocol, +) from homeassistant.helpers.typing import ConfigType -from .triggers import TriggersPlatformModule, turn_on +from .triggers import turn_on TRIGGERS = { "turn_on": turn_on, } -def _get_trigger_platform(config: ConfigType) -> TriggersPlatformModule: +def _get_trigger_platform(config: ConfigType) -> TriggerProtocol: """Return trigger platform.""" platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: raise ValueError( f"Unknown webOS Smart TV trigger platform {config[CONF_PLATFORM]}" ) - return cast(TriggersPlatformModule, TRIGGERS[platform_split[1]]) + return cast(TriggerProtocol, TRIGGERS[platform_split[1]]) async def async_validate_trigger_config( @@ -41,10 +45,4 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach trigger of specified platform.""" platform = _get_trigger_platform(config) - assert hasattr(platform, "async_attach_trigger") - return cast( - CALLBACK_TYPE, - await getattr(platform, "async_attach_trigger")( - hass, config, action, trigger_info - ), - ) + return await platform.async_attach_trigger(hass, config, action, trigger_info) diff --git a/homeassistant/components/webostv/triggers/__init__.py b/homeassistant/components/webostv/triggers/__init__.py index 710caffef7..d8c5a28ef3 100644 --- a/homeassistant/components/webostv/triggers/__init__.py +++ b/homeassistant/components/webostv/triggers/__init__.py @@ -1,12 +1 @@ """webOS Smart TV triggers.""" -from __future__ import annotations - -from typing import Protocol - -import voluptuous as vol - - -class TriggersPlatformModule(Protocol): - """Protocol type for the triggers platform.""" - - TRIGGER_SCHEMA: vol.Schema diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index e8008eb49b..b5dabf8b73 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -2,8 +2,8 @@ from __future__ import annotations from collections.abc import Callable -from contextlib import suppress import datetime as dt +from functools import lru_cache import json from typing import Any, cast @@ -33,6 +33,7 @@ from homeassistant.helpers.json import ( JSON_DUMP, ExtendedJSONEncoder, find_paths_unserializable_data, + json_dumps, ) from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.loader import ( @@ -48,6 +49,9 @@ from homeassistant.util.json import format_unserializable_data from . import const, decorators, messages from .connection import ActiveConnection from .const import ERR_NOT_FOUND +from .messages import construct_event_message, construct_result_message + +ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" @callback @@ -241,33 +245,36 @@ def handle_get_states( """Handle get states command.""" states = _async_get_allowed_states(hass, connection) - # JSON serialize here so we can recover if it blows up due to the - # state machine containing unserializable data. This command is required - # to succeed for the UI to show. - response = messages.result_message(msg["id"], states) try: - connection.send_message(JSON_DUMP(response)) - return + serialized_states = [state.as_dict_json() for state in states] except (ValueError, TypeError): - connection.logger.error( - "Unable to serialize to JSON. Bad data found at %s", - format_unserializable_data( - find_paths_unserializable_data(response, dump=JSON_DUMP) - ), - ) - del response + pass + else: + _send_handle_get_states_response(connection, msg["id"], serialized_states) + return # If we can't serialize, we'll filter out unserializable states - serialized = [] + serialized_states = [] for state in states: - # Error is already logged above - with suppress(ValueError, TypeError): - serialized.append(JSON_DUMP(state)) + try: + serialized_states.append(state.as_dict_json()) + except (ValueError, TypeError): + connection.logger.error( + "Unable to serialize to JSON. Bad data found at %s", + format_unserializable_data( + find_paths_unserializable_data(state, dump=JSON_DUMP) + ), + ) - # We now have partially serialized states. Craft some JSON. - response2 = JSON_DUMP(messages.result_message(msg["id"], ["TO_REPLACE"])) - response2 = response2.replace('"TO_REPLACE"', ", ".join(serialized)) - connection.send_message(response2) + _send_handle_get_states_response(connection, msg["id"], serialized_states) + + +def _send_handle_get_states_response( + connection: ActiveConnection, msg_id: int, serialized_states: list[str] +) -> None: + """Send handle get states response.""" + joined_states = ",".join(serialized_states) + connection.send_message(construct_result_message(msg_id, f"[{joined_states}]")) @callback @@ -303,42 +310,60 @@ def handle_subscribe_entities( EVENT_STATE_CHANGED, forward_entity_changes, run_immediately=True ) connection.send_result(msg["id"]) - data: dict[str, dict[str, dict]] = { - messages.ENTITY_EVENT_ADD: { - state.entity_id: state.as_compressed_state() - for state in states - if not entity_ids or state.entity_id in entity_ids - } - } # JSON serialize here so we can recover if it blows up due to the # state machine containing unserializable data. This command is required # to succeed for the UI to show. - response = messages.event_message(msg["id"], data) try: - connection.send_message(JSON_DUMP(response)) - return + serialized_states = [ + state.as_compressed_state_json() + for state in states + if not entity_ids or state.entity_id in entity_ids + ] except (ValueError, TypeError): - connection.logger.error( - "Unable to serialize to JSON. Bad data found at %s", - format_unserializable_data( - find_paths_unserializable_data(response, dump=JSON_DUMP) - ), - ) - del response + pass + else: + _send_handle_entities_init_response(connection, msg["id"], serialized_states) + return - add_entities = data[messages.ENTITY_EVENT_ADD] - cannot_serialize: list[str] = [] - for entity_id, state_dict in add_entities.items(): + serialized_states = [] + for state in states: try: - JSON_DUMP(state_dict) + serialized_states.append(state.as_compressed_state_json()) except (ValueError, TypeError): - cannot_serialize.append(entity_id) + connection.logger.error( + "Unable to serialize to JSON. Bad data found at %s", + format_unserializable_data( + find_paths_unserializable_data(state, dump=JSON_DUMP) + ), + ) - for entity_id in cannot_serialize: - del add_entities[entity_id] + _send_handle_entities_init_response(connection, msg["id"], serialized_states) - connection.send_message(JSON_DUMP(messages.event_message(msg["id"], data))) + +def _send_handle_entities_init_response( + connection: ActiveConnection, msg_id: int, serialized_states: list[str] +) -> None: + """Send handle entities init response.""" + joined_states = ",".join(serialized_states) + connection.send_message( + construct_event_message(msg_id, f'{{"a":{{{joined_states}}}}}') + ) + + +async def _async_get_all_descriptions_json(hass: HomeAssistant) -> str: + """Return JSON of descriptions (i.e. user documentation) for all service calls.""" + descriptions = await async_get_all_descriptions(hass) + if ALL_SERVICE_DESCRIPTIONS_JSON_CACHE in hass.data: + cached_descriptions, cached_json_payload = hass.data[ + ALL_SERVICE_DESCRIPTIONS_JSON_CACHE + ] + # If the descriptions are the same, return the cached JSON payload + if cached_descriptions is descriptions: + return cast(str, cached_json_payload) + json_payload = json_dumps(descriptions) + hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] = (descriptions, json_payload) + return json_payload @decorators.websocket_command({vol.Required("type"): "get_services"}) @@ -347,8 +372,8 @@ async def handle_get_services( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get services command.""" - descriptions = await async_get_all_descriptions(hass) - connection.send_result(msg["id"], descriptions) + payload = await _async_get_all_descriptions_json(hass) + connection.send_message(construct_result_message(msg["id"], payload)) @callback @@ -398,9 +423,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.""" @@ -424,6 +449,12 @@ def handle_ping( connection.send_message(pong_message(msg["id"])) +@lru_cache +def _cached_template(template_str: str, hass: HomeAssistant) -> template.Template: + """Return a cached template.""" + return template.Template(template_str, hass) + + @decorators.websocket_command( { vol.Required("type"): "render_template", @@ -440,7 +471,7 @@ async def handle_render_template( ) -> None: """Handle render_template command.""" template_str = msg["template"] - template_obj = template.Template(template_str, hass) + template_obj = _cached_template(template_str, hass) variables = msg.get("variables") timeout = msg.get("timeout") info = None @@ -641,6 +672,7 @@ async def handle_execute_script( connection.send_result(msg["id"], {"context": context}) +@callback @decorators.websocket_command( { vol.Required("type"): "fire_event", @@ -649,8 +681,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.""" @@ -708,7 +739,7 @@ def handle_supported_features( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle setting supported features.""" - connection.supported_features = msg["features"] + connection.set_supported_features(msg["features"]) connection.send_result(msg["id"]) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 3e8328c117..1f6fd30296 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.""" @@ -45,9 +48,19 @@ class ActiveConnection: self.refresh_token_id = refresh_token.id self.subscriptions: dict[Hashable, Callable[[], Any]] = {} self.last_id = 0 + self.can_coalesce = False self.supported_features: dict[str, float] = {} + self.handlers: dict[str, tuple[MessageHandler, vol.Schema]] = self.hass.data[ + const.DOMAIN + ] + self.binary_handlers: list[BinaryHandler | None] = [] current_connection.set(self) + def set_supported_features(self, features: dict[str, float]) -> None: + """Set supported features.""" + self.supported_features = features + self.can_coalesce = const.FEATURE_COALESCE_MESSAGES in features + def get_description(self, request: web.Request | None) -> str: """Return a description of the connection.""" description = self.user.name or "" @@ -59,25 +72,87 @@ 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.""" self.send_message(messages.result_message(msg_id, result)) + @callback + def send_event(self, msg_id: int, event: Any | None = None) -> None: + """Send a event message.""" + self.send_message(messages.event_message(msg_id, event)) + @callback def send_error(self, msg_id: int, code: str, message: str) -> None: """Send a error message.""" self.send_message(messages.error_message(msg_id, code, message)) @callback - def async_handle(self, msg: dict[str, Any]) -> None: - """Handle a single incoming message.""" - handlers = self.hass.data[const.DOMAIN] + 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: - msg = messages.MINIMAL_MESSAGE_SCHEMA(msg) - cur_id = msg["id"] - except vol.Invalid: + 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.""" + if ( + # Not using isinstance as we don't care about children + # as these are always coming from JSON + type(msg) is not dict # pylint: disable=unidiomatic-typecheck + or ( + not (cur_id := msg.get("id")) + or type(cur_id) is not int # pylint: disable=unidiomatic-typecheck + or not (type_ := msg.get("type")) + or type(type_) is not str # pylint: disable=unidiomatic-typecheck + ) + ): self.logger.error("Received invalid command", msg) self.send_message( messages.error_message( @@ -96,8 +171,8 @@ class ActiveConnection: ) return - if msg["type"] not in handlers: - self.logger.info("Received unknown command: {}".format(msg["type"])) + if not (handler_schema := self.handlers.get(type_)): + self.logger.info(f"Received unknown command: {type_}") self.send_message( messages.error_message( cur_id, const.ERR_UNKNOWN_COMMAND, "Unknown command." @@ -105,7 +180,7 @@ class ActiveConnection: ) return - handler, schema = handlers[msg["type"]] + handler, schema = handler_schema try: handler(self.hass, self, schema(msg)) 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 d92e52dbf8..5ca5ea6257 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections import deque from collections.abc import Callable from contextlib import suppress import datetime as dt @@ -22,7 +23,6 @@ from .auth import AuthPhase, auth_required_message from .const import ( CANCELLATION_ERRORS, DATA_CONNECTIONS, - FEATURE_COALESCE_MESSAGES, MAX_PENDING_MSG, PENDING_MSG_PEAK, PENDING_MSG_PEAK_TIME, @@ -71,7 +71,6 @@ class WebSocketHandler: self.hass = hass self.request = request self.wsock = web.WebSocketResponse(heartbeat=55) - self._to_write: asyncio.Queue = asyncio.Queue(maxsize=MAX_PENDING_MSG) self._handle_task: asyncio.Task | None = None self._writer_task: asyncio.Task | None = None self._closing: bool = False @@ -79,6 +78,13 @@ class WebSocketHandler: self._peak_checker_unsub: Callable[[], None] | None = None self.connection: ActiveConnection | None = None + # The WebSocketHandler has a single consumer and path + # to where messages are queued. This allows the implementation + # to use a deque and an asyncio.Future to avoid the overhead of + # an asyncio.Queue. + self._message_queue: deque = deque() + self._ready_future: asyncio.Future[None] | None = None + @property def description(self) -> str: """Return a description of the connection.""" @@ -88,39 +94,53 @@ class WebSocketHandler: async def _writer(self) -> None: """Write outgoing messages.""" - # Exceptions if Socket disconnected or cancelled by connection handler - to_write = self._to_write + # Variables are set locally to avoid lookups in the loop + message_queue = self._message_queue logger = self._logger - wsock = self.wsock + send_str = self.wsock.send_str + loop = self.hass.loop + debug = logger.debug + # Exceptions if Socket disconnected or cancelled by connection handler try: with suppress(RuntimeError, ConnectionResetError, *CANCELLATION_ERRORS): while not self.wsock.closed: - if (process := await to_write.get()) is None: + if (messages_remaining := len(message_queue)) == 0: + self._ready_future = loop.create_future() + await self._ready_future + messages_remaining = len(message_queue) + + # A None message is used to signal the end of the connection + if (process := message_queue.popleft()) is None: return + + messages_remaining -= 1 message = process if isinstance(process, str) else process() + if ( - to_write.empty() + not messages_remaining or not self.connection - or FEATURE_COALESCE_MESSAGES - not in self.connection.supported_features + or not self.connection.can_coalesce ): - logger.debug("Sending %s", message) - await wsock.send_str(message) + debug("Sending %s", message) + await send_str(message) continue messages: list[str] = [message] - while not to_write.empty(): - if (process := to_write.get_nowait()) is None: + while messages_remaining: + # A None message is used to signal the end of the connection + if (process := message_queue.popleft()) is None: return messages.append( process if isinstance(process, str) else process() ) + messages_remaining -= 1 - coalesced_messages = "[" + ",".join(messages) + "]" - logger.debug("Sending %s", coalesced_messages) - await wsock.send_str(coalesced_messages) + joined_messages = ",".join(messages) + coalesced_messages = f"[{joined_messages}]" + debug("Sending %s", coalesced_messages) + await send_str(coalesced_messages) finally: - # Clean up the peaker checker when we shut down the writer + # Clean up the peak checker when we shut down the writer self._cancel_peak_checker() @callback @@ -146,26 +166,29 @@ class WebSocketHandler: if isinstance(message, dict): message = message_to_json(message) - to_write = self._to_write - - try: - to_write.put_nowait(message) - except asyncio.QueueFull: + message_queue = self._message_queue + queue_size_before_add = len(message_queue) + if queue_size_before_add >= MAX_PENDING_MSG: self._logger.error( ( "%s: Client unable to keep up with pending messages. Reached %s pending" " messages. The system's load is too high or an integration is" - " misbehaving. Last message was: %s" + " misbehaving; Last message was: %s" ), self.description, MAX_PENDING_MSG, message, ) self._cancel() + return + + message_queue.append(message) + if self._ready_future and not self._ready_future.done(): + self._ready_future.set_result(None) peak_checker_active = self._peak_checker_unsub is not None - if to_write.qsize() < PENDING_MSG_PEAK: + if queue_size_before_add <= PENDING_MSG_PEAK: if peak_checker_active: self._cancel_peak_checker() return @@ -180,18 +203,19 @@ class WebSocketHandler: """Check that we are no longer above the write peak.""" self._peak_checker_unsub = None - if self._to_write.qsize() < PENDING_MSG_PEAK: + if len(self._message_queue) < PENDING_MSG_PEAK: return self._logger.error( ( "%s: Client unable to keep up with pending messages. Stayed over %s for %s" " seconds. The system's load is too high or an integration is" - " misbehaving" + " misbehaving; Last message was: %s" ), self.description, PENDING_MSG_PEAK, PENDING_MSG_PEAK_TIME, + self._message_queue[-1], ) self._cancel() @@ -199,6 +223,7 @@ class WebSocketHandler: def _cancel(self) -> None: """Cancel the connection.""" self._closing = True + self._cancel_peak_checker() if self._handle_task is not None: self._handle_task.cancel() if self._writer_task is not None: @@ -268,6 +293,43 @@ class WebSocketHandler: ) async_dispatcher_send(self.hass, SIGNAL_WEBSOCKET_CONNECTED) + # + # + # Our websocket implementation is backed by an asyncio.Queue + # + # As back-pressure builds, the queue will back up and use more memory + # until we disconnect the client when the queue size reaches + # MAX_PENDING_MSG. When we are generating a high volume of websocket messages, + # we hit a bottleneck in aiohttp where it will wait for + # the buffer to drain before sending the next message and messages + # start backing up in the queue. + # + # https://github.com/aio-libs/aiohttp/issues/1367 added drains + # to the websocket writer to handle malicious clients and network issues. + # The drain causes multiple problems for us since the buffer cannot be + # drained fast enough when we deliver a high volume or large messages: + # + # - We end up disconnecting the client. The client will then reconnect, + # and the cycle repeats itself, which results in a significant amount of + # CPU usage. + # + # - Messages latency increases because messages cannot be moved into + # the TCP buffer because it is blocked waiting for the drain to happen because + # of the low default limit of 16KiB. By increasing the limit, we instead + # rely on the underlying TCP buffer and stack to deliver the messages which + # can typically happen much faster. + # + # After the auth phase is completed, and we are not concerned about + # the user being a malicious client, we set the limit to force a drain + # to 1MiB. 1MiB is the maximum expected size of the serialized entity + # registry, which is the largest message we usually send. + # + # https://github.com/aio-libs/aiohttp/commit/b3c80ee3f7d5d8f0b8bc27afe52e4d46621eaf99 + # added a way to set the limit, but there is no way to actually + # reach the code to set the limit, so we have to set it directly. + # + wsock._writer._limit = 2**20 # type: ignore[union-attr] # pylint: disable=protected-access + # Command phase while not wsock.closed: msg = await wsock.receive() @@ -275,6 +337,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 @@ -310,14 +381,14 @@ class WebSocketHandler: self._closing = True + self._message_queue.append(None) + if self._ready_future and not self._ready_future.done(): + self._ready_future.set_result(None) + try: - self._to_write.put_nowait(None) # Make sure all error messages are written before closing await self._writer_task await wsock.close() - except asyncio.QueueFull: # can be raised by put_nowait - self._writer_task.cancel() - finally: if disconnect_warn is None: self._logger.debug("Disconnected") diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 0765c6a5b7..3d85f984e9 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -3,7 +3,7 @@ from __future__ import annotations from functools import lru_cache import logging -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final, cast import voluptuous as vol @@ -18,7 +18,6 @@ from homeassistant.core import Event, State from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import JSON_DUMP, find_paths_unserializable_data from homeassistant.util.json import format_unserializable_data -from homeassistant.util.yaml.loader import JSON_TYPE from . import const @@ -49,6 +48,12 @@ def result_message(iden: int, result: Any = None) -> dict[str, Any]: return {"id": iden, "type": const.TYPE_RESULT, "success": True, "result": result} +def construct_result_message(iden: int, payload: str) -> str: + """Construct a success result message JSON.""" + iden_str = str(iden) + return f'{{"id":{iden_str},"type":"result","success":true,"result":{payload}}}' + + def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]: """Return an error result message.""" return { @@ -59,7 +64,13 @@ def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]: } -def event_message(iden: JSON_TYPE | int, event: Any) -> dict[str, Any]: +def construct_event_message(iden: int, payload: str) -> str: + """Construct an event message JSON.""" + iden_str = str(iden) + return f'{{"id":{iden_str},"type":"event","event":{payload}}}' + + +def event_message(iden: int, event: Any) -> dict[str, Any]: """Return an event message.""" return {"id": iden, "type": "event", "event": event} @@ -83,7 +94,7 @@ def _cached_event_message(event: Event) -> str: The IDEN_TEMPLATE is used which will be replaced with the actual iden in cached_event_message """ - return message_to_json(event_message(IDEN_TEMPLATE, event)) + return message_to_json({"id": IDEN_TEMPLATE, "type": "event", "event": event}) def cached_state_diff_message(iden: int, event: Event) -> str: @@ -105,7 +116,9 @@ def _cached_state_diff_message(event: Event) -> str: The IDEN_TEMPLATE is used which will be replaced with the actual iden in cached_event_message """ - return message_to_json(event_message(IDEN_TEMPLATE, _state_diff_event(event))) + return message_to_json( + {"id": IDEN_TEMPLATE, "type": "event", "event": _state_diff_event(event)} + ) def _state_diff_event(event: Event) -> dict: @@ -121,43 +134,45 @@ def _state_diff_event(event: Event) -> dict: """ if (event_new_state := event.data["new_state"]) is None: return {ENTITY_EVENT_REMOVE: [event.data["entity_id"]]} - assert isinstance(event_new_state, State) + if TYPE_CHECKING: + event_new_state = cast(State, event_new_state) if (event_old_state := event.data["old_state"]) is None: return { ENTITY_EVENT_ADD: { event_new_state.entity_id: event_new_state.as_compressed_state() } } - assert isinstance(event_old_state, State) + if TYPE_CHECKING: + event_old_state = cast(State, event_old_state) 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]]]]]: """Create a diff dict that can be used to overlay changes.""" - diff: dict = {STATE_DIFF_ADDITIONS: {}} - additions = diff[STATE_DIFF_ADDITIONS] + additions: dict[str, Any] = {} + diff: dict[str, dict[str, Any]] = {STATE_DIFF_ADDITIONS: additions} + new_state_context = new_state.context + old_state_context = old_state.context if old_state.state != new_state.state: additions[COMPRESSED_STATE_STATE] = new_state.state if old_state.last_changed != new_state.last_changed: additions[COMPRESSED_STATE_LAST_CHANGED] = new_state.last_changed.timestamp() elif old_state.last_updated != new_state.last_updated: additions[COMPRESSED_STATE_LAST_UPDATED] = new_state.last_updated.timestamp() - if old_state.context.parent_id != new_state.context.parent_id: - additions.setdefault(COMPRESSED_STATE_CONTEXT, {})[ - "parent_id" - ] = new_state.context.parent_id - if old_state.context.user_id != new_state.context.user_id: - additions.setdefault(COMPRESSED_STATE_CONTEXT, {})[ - "user_id" - ] = new_state.context.user_id - if old_state.context.id != new_state.context.id: + if old_state_context.parent_id != new_state_context.parent_id: + additions[COMPRESSED_STATE_CONTEXT] = {"parent_id": new_state_context.parent_id} + if old_state_context.user_id != new_state_context.user_id: if COMPRESSED_STATE_CONTEXT in additions: - additions[COMPRESSED_STATE_CONTEXT]["id"] = new_state.context.id + additions[COMPRESSED_STATE_CONTEXT]["user_id"] = new_state_context.user_id else: - additions[COMPRESSED_STATE_CONTEXT] = new_state.context.id + additions[COMPRESSED_STATE_CONTEXT] = {"user_id": new_state_context.user_id} + if old_state_context.id != new_state_context.id: + if COMPRESSED_STATE_CONTEXT in additions: + additions[COMPRESSED_STATE_CONTEXT]["id"] = new_state_context.id + else: + additions[COMPRESSED_STATE_CONTEXT] = new_state_context.id if (old_attributes := old_state.attributes) != ( new_attributes := new_state.attributes ): diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index a70b5d7089..b208a30f5e 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -113,7 +113,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: wemo_dispatcher = WemoDispatcher(entry) wemo_discovery = WemoDiscovery(hass, wemo_dispatcher, static_conf) - async def async_stop_wemo(event: Event) -> None: + async def async_stop_wemo(_: Event | None = None) -> None: """Shutdown Wemo subscriptions and subscription thread on exit.""" _LOGGER.debug("Shutting down WeMo event subscriptions") await hass.async_add_executor_job(registry.stop) @@ -123,6 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_wemo) ) + entry.async_on_unload(async_stop_wemo) # Need to do this at least once in case statistics are defined and discovery is disabled await wemo_discovery.discover_statics() @@ -133,6 +134,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a wemo config entry.""" + # This makes sure that `entry.async_on_unload` routines run correctly on unload + return True + + class WemoDispatcher: """Dispatch WeMo devices to the correct platform.""" @@ -140,6 +147,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 +157,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 +195,7 @@ class WemoDispatcher: ) self._added_serial_numbers.add(wemo.serialnumber) + self._failed_serial_numbers.discard(wemo.serialnumber) class WemoDiscovery: diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 81065cf810..1d2c2c9252 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -86,6 +86,7 @@ class WemoHumidifier(WemoBinaryStateEntity, FanEntity): _attr_supported_features = FanEntityFeature.SET_SPEED wemo: Humidifier + _last_fan_on_mode: FanMode def __init__(self, coordinator: DeviceCoordinator) -> None: """Initialize the WeMo switch.""" diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 634d4a9e41..3cfc9a16f3 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -28,6 +28,10 @@ from .wemo_device import DeviceCoordinator class AttributeSensorDescription(SensorEntityDescription): """SensorEntityDescription for WeMo AttributeSensor entities.""" + # AttributeSensor does not support DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + name: str | None = None state_conversion: Callable[[StateType], StateType] | None = None unique_id_suffix: str | None = None 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/whisper/__init__.py b/homeassistant/components/whisper/__init__.py new file mode 100644 index 0000000000..572f91f68d --- /dev/null +++ b/homeassistant/components/whisper/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Whisper.""" diff --git a/homeassistant/components/whisper/manifest.json b/homeassistant/components/whisper/manifest.json new file mode 100644 index 0000000000..1bc0f12e07 --- /dev/null +++ b/homeassistant/components/whisper/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "whisper", + "name": "Whisper", + "integration_type": "virtual", + "supported_by": "wyoming" +} diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index d44c3aaefb..a802535441 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, CONF_TIMEOUT, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -144,7 +144,7 @@ class WiffiEntity(Entity): """Initialize the base elements of a wiffi entity.""" self._id = generate_unique_id(device, metric) self._device_info = DeviceInfo( - connections={(device_registry.CONNECTION_NETWORK_MAC, device.mac_address)}, + connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)}, identifiers={(DOMAIN, device.mac_address)}, manufacturer="stall.biz", model=device.moduletype, diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 94aaa9aa5a..682efde888 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -151,7 +151,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Start subscription check in the background, outside this component's setup. - async_call_later(hass, 1, async_call_later_callback) + entry.async_on_unload(async_call_later(hass, 1, async_call_later_callback)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 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..a66d965a35 --- /dev/null +++ b/homeassistant/components/workday/config_flow.py @@ -0,0 +1,304 @@ +"""Adds config flow for Workday integration.""" +from __future__ import annotations + +from typing import Any + +from holidays import country_holidays, list_supported_countries +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, + country: str, +) -> vol.Schema: + """Update schema with province from country.""" + all_countries = list_supported_countries() + if not all_countries[country]: + return schema + + province_list = [NONE_SENTINEL, *all_countries[country]] + 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 = country_holidays( + user_input[CONF_COUNTRY], user_input.get(CONF_PROVINCE), 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(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[CONF_COUNTRY] + ) + new_schema = self.add_suggested_values_to_schema(schema, user_input) + return self.async_show_form( + step_id="options", + data_schema=new_schema, + errors=errors, + ) + + +class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Handle Workday options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage Workday options.""" + errors: dict[str, str] = {} + + if user_input is not None: + combined_input: dict[str, Any] = {**self.options, **user_input} + if combined_input.get(CONF_PROVINCE, NONE_SENTINEL) == NONE_SENTINEL: + combined_input[CONF_PROVINCE] = None + + try: + await self.hass.async_add_executor_job( + validate_custom_dates, combined_input + ) + except AddDatesError: + errors["add_holidays"] = "add_holiday_error" + except RemoveDatesError: + errors["remove_holidays"] = "remove_holiday_error" + else: + try: + self._async_abort_entries_match( + { + CONF_COUNTRY: self._config_entry.options[CONF_COUNTRY], + CONF_EXCLUDES: combined_input[CONF_EXCLUDES], + CONF_OFFSET: combined_input[CONF_OFFSET], + CONF_WORKDAYS: combined_input[CONF_WORKDAYS], + CONF_ADD_HOLIDAYS: combined_input[CONF_ADD_HOLIDAYS], + CONF_REMOVE_HOLIDAYS: combined_input[CONF_REMOVE_HOLIDAYS], + CONF_PROVINCE: combined_input[CONF_PROVINCE], + } + ) + except AbortFlow as err: + errors = {"base": err.reason} + else: + return self.async_create_entry(data=combined_input) + + schema: vol.Schema = await self.hass.async_add_executor_job( + add_province_to_schema, DATA_SCHEMA_OPT, self.options[CONF_COUNTRY] + ) + + new_schema = self.add_suggested_values_to_schema( + schema, user_input or self.options + ) + + return self.async_show_form( + step_id="init", + data_schema=new_schema, + errors=errors, + ) + + +class AddDatesError(HomeAssistantError): + """Exception for error adding dates.""" + + +class RemoveDatesError(HomeAssistantError): + """Exception for error removing dates.""" + + +class CountryNotExist(HomeAssistantError): + """Exception country does not exist error.""" diff --git a/homeassistant/components/workday/const.py b/homeassistant/components/workday/const.py 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..8a516602b5 --- /dev/null +++ b/homeassistant/components/workday/strings.json @@ -0,0 +1,90 @@ +{ + "config": { + "abort": { + "incorrect_province": "Incorrect subdivision from yaml import", + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "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, Territory, 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" + } + }, + "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": "Service with this configuration already exist" + } + }, + "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..d7d5d0278e --- /dev/null +++ b/homeassistant/components/wyoming/config_flow.py @@ -0,0 +1,105 @@ +"""Config flow for Wyoming integration.""" +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN +from .data import WyomingService + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT): int, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Wyoming integration.""" + + VERSION = 1 + + _hassio_discovery: HassioServiceInfo + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + service = await WyomingService.create( + user_input[CONF_HOST], + user_input[CONF_PORT], + ) + + if service is None: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "cannot_connect"}, + ) + + # ASR = automated speech recognition (STT) + asr_installed = [asr for asr in service.info.asr if asr.installed] + tts_installed = [tts for tts in service.info.tts if tts.installed] + + if asr_installed: + name = asr_installed[0].name + elif tts_installed: + name = tts_installed[0].name + else: + return self.async_abort(reason="no_services") + + return self.async_create_entry(title=name, data=user_input) + + async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: + """Handle Supervisor add-on discovery.""" + await self.async_set_unique_id(discovery_info.uuid) + self._abort_if_unique_id_configured() + + self._hassio_discovery = discovery_info + self.context.update( + { + "title_placeholders": {"name": discovery_info.name}, + "configuration_url": f"homeassistant://hassio/addon/{discovery_info.slug}/info", + } + ) + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm Supervisor discovery.""" + errors: dict[str, str] = {} + + if user_input is not None: + uri = urlparse(self._hassio_discovery.config["uri"]) + if service := await WyomingService.create(uri.hostname, uri.port): + if not any( + asr for asr in service.info.asr if asr.installed + ) and not any(tts for tts in service.info.tts if tts.installed): + return self.async_abort(reason="no_services") + + return self.async_create_entry( + title=self._hassio_discovery.name, + data={CONF_HOST: uri.hostname, CONF_PORT: uri.port}, + ) + + errors = {"base": "cannot_connect"} + + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders={"addon": self._hassio_discovery.name}, + errors=errors, + ) diff --git a/homeassistant/components/wyoming/const.py b/homeassistant/components/wyoming/const.py new file mode 100644 index 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/media_player.py b/homeassistant/components/xbox/media_player.py index 1d56cfc71c..ab16afa928 100644 --- a/homeassistant/components/xbox/media_player.py +++ b/homeassistant/components/xbox/media_player.py @@ -205,7 +205,7 @@ class XboxMediaPlayer(CoordinatorEntity[XboxUpdateCoordinator], MediaPlayerEntit ) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Launch an app on the Xbox.""" if media_id == "Home": 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/camera.py b/homeassistant/components/xiaomi/camera.py index 8b7abcd2fe..e9d686a636 100644 --- a/homeassistant/components/xiaomi/camera.py +++ b/homeassistant/components/xiaomi/camera.py @@ -1,4 +1,4 @@ -"""This component provides support for Xiaomi Cameras.""" +"""Component providing support for Xiaomi Cameras.""" from __future__ import annotations from ftplib import FTP, error_perm diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py index b8cf5f005c..f277060304 100644 --- a/homeassistant/components/xiaomi/device_tracker.py +++ b/homeassistant/components/xiaomi/device_tracker.py @@ -36,7 +36,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> XiaomiDeviceScanner class XiaomiDeviceScanner(DeviceScanner): - """This class queries a Xiaomi Mi router. + """Class which queries a Xiaomi Mi router. Adapted from Luci scanner. """ diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py index 773e63d92b..2fae1796e4 100644 --- a/homeassistant/components/xiaomi_aqara/config_flow.py +++ b/homeassistant/components/xiaomi_aqara/config_flow.py @@ -105,7 +105,7 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_settings() - # Discover Xiaomi Aqara Gateways in the netwerk to get required SIDs. + # Discover Xiaomi Aqara Gateways in the network to get required SIDs. xiaomi = XiaomiGatewayDiscovery(self.interface) try: await self.hass.async_add_executor_job(xiaomi.discover_gateways) 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/device_tracker.py b/homeassistant/components/xiaomi_miio/device_tracker.py index e4bebdd0e6..977dc29ac4 100644 --- a/homeassistant/components/xiaomi_miio/device_tracker.py +++ b/homeassistant/components/xiaomi_miio/device_tracker.py @@ -53,7 +53,7 @@ def get_scanner( class XiaomiMiioDeviceScanner(DeviceScanner): - """This class queries a Xiaomi Mi WiFi Repeater.""" + """Class which queries a Xiaomi Mi WiFi Repeater.""" def __init__(self, device): """Initialize the scanner.""" diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 50e0cd8c72..6fde33309e 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -1,6 +1,7 @@ """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier with humidifier entity.""" import logging import math +from typing import Any from miio.integrations.humidifier.deerma.airhumidifier_mjjsq import ( OperationMode as AirhumidifierMjjsqOperationMode, @@ -136,10 +137,7 @@ class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): """Get the current mode.""" return self._mode - async def async_turn_on( - self, - **kwargs, - ) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" result = await self._try_command( "Turning the miio device on failed.", self._device.on @@ -148,7 +146,7 @@ class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): self._state = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" result = await self._try_command( "Turning the miio device off failed.", self._device.off diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index bbf2764ceb..249774519d 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -64,6 +64,7 @@ from .const import ( MODEL_AIRPURIFIER_4_LITE_RMA1, MODEL_AIRPURIFIER_4_LITE_RMB1, MODEL_AIRPURIFIER_4_PRO, + MODEL_AIRPURIFIER_MA2, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V2, @@ -467,6 +468,16 @@ PURIFIER_ZA1_SENSORS = ( ATTR_HUMIDITY, ATTR_TEMPERATURE, ) +PURIFIER_MA2_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_TEMPERATURE, + ATTR_USE_TIME, + ATTR_ILLUMINANCE, +) PURIFIER_V2_SENSORS = ( ATTR_FILTER_LIFE_REMAINING, ATTR_FILTER_USE, @@ -564,6 +575,7 @@ MODEL_TO_SENSORS_MAP: dict[str, tuple[str, ...]] = { MODEL_AIRPURIFIER_V2: PURIFIER_V2_SENSORS, MODEL_AIRPURIFIER_V3: PURIFIER_V3_SENSORS, MODEL_AIRPURIFIER_ZA1: PURIFIER_ZA1_SENSORS, + MODEL_AIRPURIFIER_MA2: PURIFIER_MA2_SENSORS, MODEL_FAN_V2: FAN_V2_V3_SENSORS, MODEL_FAN_V3: FAN_V2_V3_SENSORS, MODEL_FAN_ZA5: FAN_ZA5_SENSORS, 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/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index f3d086afed..11516015b6 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -1,6 +1,8 @@ """The Yale Access Bluetooth integration.""" from __future__ import annotations +import asyncio + from yalexs_ble import ( AuthError, ConnectionInfo, @@ -13,11 +15,18 @@ from yalexs_ble import ( from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .const import CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DEVICE_TIMEOUT, DOMAIN +from .const import ( + CONF_ALWAYS_CONNECTED, + CONF_KEY, + CONF_LOCAL_NAME, + CONF_SLOT, + DEVICE_TIMEOUT, + DOMAIN, +) from .models import YaleXSBLEData from .util import async_find_existing_service_info, bluetooth_callback_matcher @@ -31,7 +40,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: key = entry.data[CONF_KEY] slot = entry.data[CONF_SLOT] has_unique_local_name = local_name_is_unique(local_name) - push_lock = PushLock(local_name, address, None, key, slot) + always_connected = entry.options.get(CONF_ALWAYS_CONNECTED, False) + push_lock = PushLock( + local_name, address, None, key, slot, always_connected=always_connected + ) id_ = local_name if has_unique_local_name else address push_lock.set_name(f"{entry.title} ({id_})") @@ -43,7 +55,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Update from a ble callback.""" push_lock.update_advertisement(service_info.device, service_info.advertisement) - entry.async_on_unload(await push_lock.start()) + shutdown_callback: CALLBACK_TYPE | None = await push_lock.start() + + @callback + def _async_shutdown(event: Event | None = None) -> None: + nonlocal shutdown_callback + if shutdown_callback: + shutdown_callback() + shutdown_callback = None + + entry.async_on_unload(_async_shutdown) # We may already have the advertisement, so check for it. if service_info := async_find_existing_service_info(hass, local_name, address): @@ -62,13 +83,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await push_lock.wait_for_first_update(DEVICE_TIMEOUT) except AuthError as ex: raise ConfigEntryAuthFailed(str(ex)) from ex - except YaleXSBLEError as ex: + except (YaleXSBLEError, asyncio.TimeoutError) as ex: raise ConfigEntryNotReady( f"{ex}; Try moving the Bluetooth adapter closer to {local_name}" ) from ex hass.data.setdefault(DOMAIN, {})[entry.entry_id] = YaleXSBLEData( - entry.title, push_lock + entry.title, push_lock, always_connected ) @callback @@ -95,13 +116,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(push_lock.register_callback(_async_state_changed)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown) + ) return True async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" data: YaleXSBLEData = hass.data[DOMAIN][entry.entry_id] - if entry.title != data.title: + if entry.title != data.title or data.always_connected != entry.options.get( + CONF_ALWAYS_CONNECTED + ): await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py index b87066b50f..3a6d91c4f5 100644 --- a/homeassistant/components/yalexs_ble/config_flow.py +++ b/homeassistant/components/yalexs_ble/config_flow.py @@ -23,10 +23,11 @@ from homeassistant.components.bluetooth import ( async_discovered_service_info, ) from homeassistant.const import CONF_ADDRESS +from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.typing import DiscoveryInfoType -from .const import CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DOMAIN +from .const import CONF_ALWAYS_CONNECTED, CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DOMAIN from .util import async_find_existing_service_info, human_readable_name _LOGGER = logging.getLogger(__name__) @@ -297,3 +298,46 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=data_schema, errors=errors, ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> YaleXSBLEOptionsFlowHandler: + """Get the options flow for this handler.""" + return YaleXSBLEOptionsFlowHandler(config_entry) + + +class YaleXSBLEOptionsFlowHandler(config_entries.OptionsFlow): + """Handle YaleXSBLE options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize YaleXSBLE options flow.""" + self.entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the YaleXSBLE options.""" + return await self.async_step_device_options() + + async def async_step_device_options( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the YaleXSBLE devices options.""" + if user_input is not None: + return self.async_create_entry( + data={CONF_ALWAYS_CONNECTED: user_input[CONF_ALWAYS_CONNECTED]}, + ) + + return self.async_show_form( + step_id="device_options", + data_schema=vol.Schema( + { + vol.Optional( + CONF_ALWAYS_CONNECTED, + default=self.entry.options.get(CONF_ALWAYS_CONNECTED, False), + ): bool, + } + ), + ) diff --git a/homeassistant/components/yalexs_ble/const.py b/homeassistant/components/yalexs_ble/const.py index f38a376a71..18555f9107 100644 --- a/homeassistant/components/yalexs_ble/const.py +++ b/homeassistant/components/yalexs_ble/const.py @@ -5,5 +5,6 @@ DOMAIN = "yalexs_ble" CONF_LOCAL_NAME = "local_name" CONF_KEY = "key" CONF_SLOT = "slot" +CONF_ALWAYS_CONNECTED = "always_connected" DEVICE_TIMEOUT = 55 diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 2953e33f19..8aa795b970 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.0.1"] + "requirements": ["yalexs-ble==2.1.17"] } diff --git a/homeassistant/components/yalexs_ble/models.py b/homeassistant/components/yalexs_ble/models.py index d79668f1c7..3b83b52cf7 100644 --- a/homeassistant/components/yalexs_ble/models.py +++ b/homeassistant/components/yalexs_ble/models.py @@ -12,3 +12,4 @@ class YaleXSBLEData: title: str lock: PushLock + always_connected: bool diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index 0f1f138fd6..bd96e07f6b 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%]", @@ -35,5 +35,15 @@ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "options": { + "step": { + "device_options": { + "description": "If the lock does not support push updates via advertisements or you want lock operation to be more responsive, you can enable always connected mode. Always connected will cause the lock to stay connected to Home Assistant via Bluetooth, which will use more battery.", + "data": { + "always_connected": "Always connected" + } + } + } } } diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index aeb38c0faa..e2658c21f3 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -347,7 +347,9 @@ class YamahaDevice(MediaPlayerEntity): """Select input source.""" self.receiver.input = self._reverse_mapping.get(source, source) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play media from an ID. This exposes a pass through for various input sources in the diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index 9a19f61eb4..48b8de2060 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", "iot_class": "local_push", "loggers": ["aiomusiccast"], - "requirements": ["aiomusiccast==0.14.7"], + "requirements": ["aiomusiccast==0.14.8"], "ssdp": [ { "manufacturer": "Yamaha Corporation" diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index 01e5e1b898..05518a6c3c 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -263,7 +263,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): ) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play media.""" if media_source.is_media_source_id(media_id): 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..dc4283b4a7 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 @@ -106,7 +106,7 @@ class YeelightScanner: await self._async_wait_connected() self._track_interval = async_track_time_interval( - self._hass, self.async_scan, DISCOVERY_INTERVAL + self._hass, self.async_scan, DISCOVERY_INTERVAL, cancel_on_shutdown=True ) self.async_scan() @@ -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/light.py b/homeassistant/components/yolink/light.py index 7c11b54efa..a7f52e801b 100644 --- a/homeassistant/components/yolink/light.py +++ b/homeassistant/components/yolink/light.py @@ -51,9 +51,9 @@ class YoLinkDimmerEntity(YoLinkEntity, LightEntity): @callback def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" - if (dimmer_is_on := state.get("state")) is not None: + if (dimmer_state := state.get("state")) is not None: # update _attr_is_on when device report it's state - self._attr_is_on = dimmer_is_on + self._attr_is_on = dimmer_state == "open" if (brightness := state.get("brightness")) is not None: self._attr_brightness = round(255 * brightness / 100) self.async_write_ha_state() 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/manifest.json b/homeassistant/components/youless/manifest.json index 02cca76c4a..7c0ea36a06 100644 --- a/homeassistant/components/youless/manifest.json +++ b/homeassistant/components/youless/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/youless", "iot_class": "local_polling", "loggers": ["youless_api"], - "requirements": ["youless-api==0.16"] + "requirements": ["youless-api==1.0.1"] } 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/zamg/weather.py b/homeassistant/components/zamg/weather.py index 46913e9051..f94f9ca8a3 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -64,8 +64,16 @@ class ZamgWeather(CoordinatorEntity, WeatherEntity): def native_temperature(self) -> float | None: """Return the platform temperature.""" try: - return float(self.coordinator.data[self.station_id]["TL"]["data"]) - except (KeyError, ValueError): + if ( + value := self.coordinator.data[self.station_id]["TLAM"]["data"] + ) is not None: + return float(value) + if ( + value := self.coordinator.data[self.station_id]["TL"]["data"] + ) is not None: + return float(value) + return None + except (KeyError, ValueError, TypeError): return None @property @@ -73,7 +81,7 @@ class ZamgWeather(CoordinatorEntity, WeatherEntity): """Return the pressure.""" try: return float(self.coordinator.data[self.station_id]["P"]["data"]) - except (KeyError, ValueError): + except (KeyError, ValueError, TypeError): return None @property @@ -81,21 +89,37 @@ class ZamgWeather(CoordinatorEntity, WeatherEntity): """Return the humidity.""" try: return float(self.coordinator.data[self.station_id]["RFAM"]["data"]) - except (KeyError, ValueError): + except (KeyError, ValueError, TypeError): return None @property def native_wind_speed(self) -> float | None: """Return the wind speed.""" try: - return float(self.coordinator.data[self.station_id]["FFAM"]["data"]) - except (KeyError, ValueError): + if ( + value := self.coordinator.data[self.station_id]["FFAM"]["data"] + ) is not None: + return float(value) + if ( + value := self.coordinator.data[self.station_id]["FFX"]["data"] + ) is not None: + return float(value) + return None + except (KeyError, ValueError, TypeError): return None @property - def wind_bearing(self) -> float | str | None: + def wind_bearing(self) -> float | None: """Return the wind bearing.""" try: - return self.coordinator.data[self.station_id]["DD"]["data"] - except (KeyError, ValueError): + if ( + value := self.coordinator.data[self.station_id]["DD"]["data"] + ) is not None: + return float(value) + if ( + value := self.coordinator.data[self.station_id]["DDX"]["data"] + ) is not None: + return float(value) + return None + except (KeyError, ValueError, TypeError): return None diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index badc124271..9d7dd73be0 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.""" @@ -542,6 +542,12 @@ def async_get_homekit_discovery_domain( return None +@lru_cache(maxsize=256) # matches to the cache in zeroconf itself +def _stringify_ip_address(ip_addr: IPv4Address | IPv6Address) -> str: + """Stringify an IP address.""" + return str(ip_addr) + + def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: """Return prepared info from mDNS entries.""" properties: dict[str, Any] = {"_raw": {}} @@ -564,14 +570,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 = _stringify_ip_address(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=[_stringify_ip_address(ip_addr) for ip_addr in ip_addresses], port=service.port, hostname=service.server, type=service.type, @@ -580,30 +592,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 02b5982e58..63d8f97d5b 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.3"] + "requirements": ["zeroconf==0.62.0"] } diff --git a/homeassistant/components/zeroconf/usage.py b/homeassistant/components/zeroconf/usage.py index 0c452149bf..b9d51cd3c3 100644 --- a/homeassistant/components/zeroconf/usage.py +++ b/homeassistant/components/zeroconf/usage.py @@ -31,4 +31,4 @@ def install_multiple_zeroconf_catcher(hass_zc: HaZeroconf) -> None: return zeroconf.Zeroconf.__new__ = new_zeroconf_new # type: ignore[assignment] - zeroconf.Zeroconf.__init__ = new_zeroconf_init # type: ignore[assignment] + zeroconf.Zeroconf.__init__ = new_zeroconf_init # type: ignore[method-assign] 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/zeversolar/manifest.json b/homeassistant/components/zeversolar/manifest.json index eceba0ddeb..af197b3aa7 100644 --- a/homeassistant/components/zeversolar/manifest.json +++ b/homeassistant/components/zeversolar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/zeversolar", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["zeversolar==0.3.0"] + "requirements": ["zeversolar==0.3.1"] } diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index d32dcf0bda..5607cabffe 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -1,5 +1,6 @@ """Support for Zigbee Home Automation devices.""" import asyncio +import copy import logging import os @@ -16,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, @@ -90,6 +91,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b Will automatically load components to support devices found on the network. """ + # Strip whitespace around `socket://` URIs, this is no longer accepted by zigpy + # This will be removed in 2023.7.0 + path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + data = copy.deepcopy(dict(config_entry.data)) + + if path.startswith("socket://") and path != path.strip(): + data[CONF_DEVICE][CONF_DEVICE_PATH] = path.strip() + hass.config_entries.async_update_entry(config_entry, data=data) + zha_data = hass.data.setdefault(DATA_ZHA, {}) config = zha_data.get(DATA_ZHA_CONFIG, {}) @@ -97,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b zha_data.setdefault(platform, []) if config.get(CONF_ENABLE_QUIRKS, True): - setup_quirks(config) + setup_quirks(custom_quirks_path=config.get(CONF_CUSTOM_QUIRKS_PATH)) # temporary code to remove the ZHA storage file from disk. # this will be removed in 2022.10.0 @@ -121,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.""" @@ -140,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..3d44103e22 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -1,1549 +1,134 @@ -"""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, Literal -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 zigpy.types import Channels +from zigpy.util import pick_optimal_channel 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 + return RadioType[config_entry.data[CONF_RADIO_TYPE]] - 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) +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) - async_register_admin_service( - hass, DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT] - ) + return config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] - 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 async_change_channel( + hass: HomeAssistant, new_channel: int | Literal["auto"] +) -> None: + """Migrate the ZHA network to a new channel.""" - 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, + zha_gateway: ZHAGateway = _get_gateway(hass) + app = zha_gateway.application_controller + + if new_channel == "auto": + channel_energy = await app.energy_scan( + channels=Channels.ALL_CHANNELS, + duration_exp=4, + count=1, ) + new_channel = pick_optimal_channel(channel_energy) - 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) - - -@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) + await app.move_network_to_channel(new_channel) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index af7ce6b5bf..1c29f61971 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -2,24 +2,30 @@ 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_HUE_OCCUPANCY, + CLUSTER_HANDLER_OCCUPANCY, + CLUSTER_HANDLER_ON_OFF, + CLUSTER_HANDLER_ZONE, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -28,17 +34,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 +82,162 @@ 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) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY) +class HueOccupancy(Occupancy): + """ZHA Hue occupancy.""" + + +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) class Opening(BinarySensor): - """ZHA 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,20 +247,83 @@ 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.""" SENSOR_ATTR = "error_detected" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM _attr_name: str = "Error detected" + + +@MULTI_MATCH( + cluster_handler_names="opple_cluster", + models={"lumi.plug.mmeu01", "lumi.plug.maeu01"}, +) +class XiaomiPlugConsumerConnected(BinarySensor, id_suffix="consumer_connected"): + """ZHA Xiaomi plug consumer connected binary sensor.""" + + 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 554a94b845..5230d77ce4 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -7,6 +7,7 @@ import json from typing import Any import serial.tools.list_ports +from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol import zigpy.backups from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH @@ -14,9 +15,13 @@ from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant import config_entries from homeassistant.components import onboarding, usb, zeroconf from homeassistant.components.file_upload import process_uploaded_file +from homeassistant.components.hassio import AddonError, AddonState +from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon +from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowHandler, FlowResult +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import FileSelector, FileSelectorConfig from homeassistant.util import dt @@ -27,7 +32,11 @@ from .core.const import ( DOMAIN, RadioType, ) -from .radio_manager import HARDWARE_DISCOVERY_SCHEMA, ZhaRadioManager +from .radio_manager import ( + HARDWARE_DISCOVERY_SCHEMA, + RECOMMENDED_RADIOS, + ZhaRadioManager, +) CONF_MANUAL_PATH = "Enter Manually" SUPPORTED_PORT_SETTINGS = ( @@ -72,6 +81,41 @@ def _format_backup_choice( return f"{dt.as_local(backup.backup_time).strftime('%c')} ({identifier})" +async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: + """List all serial ports, including the Yellow radio and the multi-PAN addon.""" + ports = await hass.async_add_executor_job(serial.tools.list_ports.comports) + + # Add useful info to the Yellow's serial port selection screen + try: + yellow_hardware.async_info(hass) + except HomeAssistantError: + pass + else: + yellow_radio = next(p for p in ports if p.device == "/dev/ttyAMA1") + yellow_radio.description = "Yellow Zigbee module" + yellow_radio.manufacturer = "Nabu Casa" + + # Present the multi-PAN addon as a setup option, if it's available + addon_manager = silabs_multiprotocol_addon.get_addon_manager(hass) + + try: + addon_info = await addon_manager.async_get_addon_info() + except (AddonError, KeyError): + addon_info = None + + if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: + addon_port = ListPortInfo( + device=silabs_multiprotocol_addon.get_zigbee_socket(), + skip_link_detection=True, + ) + + addon_port.description = "Multiprotocol add-on" + addon_port.manufacturer = "Nabu Casa" + ports.append(addon_port) + + return ports + + class BaseZhaFlow(FlowHandler): """Mixin for common ZHA flow steps and forms.""" @@ -120,9 +164,9 @@ class BaseZhaFlow(FlowHandler): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Choose a serial port.""" - ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + ports = await list_serial_ports(self.hass) list_of_ports = [ - f"{p}, s/n: {p.serial_number or 'n/a'}" + f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}" + (f" - {p.manufacturer}" if p.manufacturer else "") for p in ports ] @@ -146,13 +190,13 @@ class BaseZhaFlow(FlowHandler): return await self.async_step_manual_pick_radio_type() self._title = ( - f"{port.description}, s/n: {port.serial_number or 'n/a'}" + f"{port.description}{', s/n: ' + port.serial_number if port.serial_number else ''}" f" - {port.manufacturer}" if port.manufacturer else "" ) - return await self.async_step_choose_formation_strategy() + return await self.async_step_verify_radio() # Pre-select the currently configured port default_port = vol.UNDEFINED @@ -212,7 +256,7 @@ class BaseZhaFlow(FlowHandler): self._radio_mgr.device_settings = user_input.copy() if await self._radio_mgr.radio_type.controller.probe(user_input): - return await self.async_step_choose_formation_strategy() + return await self.async_step_verify_radio() errors["base"] = "cannot_connect" @@ -249,6 +293,26 @@ class BaseZhaFlow(FlowHandler): errors=errors, ) + async def async_step_verify_radio( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add a warning step to dissuade the use of deprecated radios.""" + assert self._radio_mgr.radio_type is not None + + # Skip this step if we are using a recommended radio + if user_input is not None or self._radio_mgr.radio_type in RECOMMENDED_RADIOS: + return await self.async_step_choose_formation_strategy() + + return self.async_show_form( + step_id="verify_radio", + description_placeholders={ + CONF_NAME: self._radio_mgr.radio_type.description, + "docs_recommended_adapters_url": ( + "https://www.home-assistant.io/integrations/zha/#recommended-zigbee-radio-adapters-and-modules" + ), + }, + ) + async def async_step_choose_formation_strategy( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -476,7 +540,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN if self._radio_mgr.device_settings is None: return await self.async_step_manual_port_config() - return await self.async_step_choose_formation_strategy() + return await self.async_step_verify_radio() return self.async_show_form( step_id="confirm", diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py deleted file mode 100644 index 149b733be3..0000000000 --- a/homeassistant/components/zha/core/channels/__init__.py +++ /dev/null @@ -1,380 +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 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..7863b04345 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,7 +424,7 @@ class ZigbeeChannel(LogMixin): else: raise TypeError(f"Unexpected zha_send_event {command!r} argument: {arg!r}") - self._ch_pool.zha_send_event( + self._endpoint.send_event( { ATTR_UNIQUE_ID: self.unique_id, ATTR_CLUSTER_ID: self.cluster.cluster_id, @@ -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 84% rename from homeassistant/components/zha/core/channels/closures.py rename to homeassistant/components/zha/core/cluster_handlers/closures.py index de2dcaf38e..ab58405b97 100644 --- a/homeassistant/components/zha/core/channels/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -1,16 +1,16 @@ -"""Closures channels module for Zigbee Home Automation.""" +"""Closures cluster handlers module for Zigbee Home Automation.""" from zigpy.zcl.clusters import closures from homeassistant.core import callback +from . import AttrReportConfig, ClientClusterHandler, ClusterHandler from .. import registries from ..const import REPORT_CONFIG_IMMEDIATE, SIGNAL_ATTR_UPDATED -from .base import AttrReportConfig, ClientChannel, ZigbeeChannel -@registries.ZIGBEE_CHANNEL_REGISTRY.register(closures.DoorLock.cluster_id) -class DoorLockChannel(ZigbeeChannel): - """Door lock channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.DoorLock.cluster_id) +class DoorLockClusterHandler(ClusterHandler): + """Door lock cluster handler.""" _value_attribute = 0 REPORT_CONFIG = ( @@ -107,19 +107,20 @@ class DoorLockChannel(ZigbeeChannel): return result -@registries.ZIGBEE_CHANNEL_REGISTRY.register(closures.Shade.cluster_id) -class Shade(ZigbeeChannel): - """Shade channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.Shade.cluster_id) +class Shade(ClusterHandler): + """Shade cluster handler.""" -@registries.CLIENT_CHANNELS_REGISTRY.register(closures.WindowCovering.cluster_id) -class WindowCoveringClient(ClientChannel): - """Window client channel.""" +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(closures.WindowCovering.cluster_id) +class WindowCoveringClient(ClientClusterHandler): + """Window client cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(closures.WindowCovering.cluster_id) -class WindowCovering(ZigbeeChannel): - """Window channel.""" +@registries.BINDABLE_CLUSTERS.register(closures.WindowCovering.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.WindowCovering.cluster_id) +class WindowCovering(ClusterHandler): + """Window cluster handler.""" _value_attribute = 8 REPORT_CONFIG = ( diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py similarity index 66% rename from homeassistant/components/zha/core/channels/general.py rename to homeassistant/components/zha/core/cluster_handlers/general.py index 47d0cafb01..d4014bbf69 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -1,4 +1,4 @@ -"""General channels module for Zigbee Home Automation.""" +"""General cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations import asyncio @@ -14,6 +14,12 @@ from zigpy.zcl.foundation import Status from homeassistant.core import callback from homeassistant.helpers.event import async_call_later +from . import ( + AttrReportConfig, + ClientClusterHandler, + ClusterHandler, + parse_and_log_command, +) from .. import registries from ..const import ( REPORT_CONFIG_ASAP, @@ -27,21 +33,20 @@ from ..const import ( SIGNAL_SET_LEVEL, SIGNAL_UPDATE_DEVICE, ) -from .base import AttrReportConfig, ClientChannel, ZigbeeChannel, parse_and_log_command from .helpers import is_hue_motion_sensor if TYPE_CHECKING: - from . import ChannelPool + from ..endpoint import Endpoint -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Alarms.cluster_id) -class Alarms(ZigbeeChannel): - """Alarms channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Alarms.cluster_id) +class Alarms(ClusterHandler): + """Alarms cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogInput.cluster_id) -class AnalogInput(ZigbeeChannel): - """Analog Input channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogInput.cluster_id) +class AnalogInput(ClusterHandler): + """Analog Input cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), @@ -49,9 +54,9 @@ class AnalogInput(ZigbeeChannel): @registries.BINDABLE_CLUSTERS.register(general.AnalogOutput.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogOutput.cluster_id) -class AnalogOutput(ZigbeeChannel): - """Analog Output channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogOutput.cluster_id) +class AnalogOutput(ClusterHandler): + """Analog Output cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), @@ -120,24 +125,26 @@ class AnalogOutput(ZigbeeChannel): return False -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogValue.cluster_id) -class AnalogValue(ZigbeeChannel): - """Analog Value channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogValue.cluster_id) +class AnalogValue(ClusterHandler): + """Analog Value cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.ApplianceControl.cluster_id) -class ApplianceContorl(ZigbeeChannel): - """Appliance Control channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + general.ApplianceControl.cluster_id +) +class ApplianceContorl(ClusterHandler): + """Appliance Control cluster handler.""" -@registries.CHANNEL_ONLY_CLUSTERS.register(general.Basic.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Basic.cluster_id) -class BasicChannel(ZigbeeChannel): - """Channel to interact with the basic cluster.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(general.Basic.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Basic.cluster_id) +class BasicClusterHandler(ClusterHandler): + """Cluster handler to interact with the basic cluster.""" UNKNOWN = 0 BATTERY = 3 @@ -153,9 +160,9 @@ class BasicChannel(ZigbeeChannel): 6: "Emergency mains and transfer switch", } - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize Basic channel.""" - super().__init__(cluster, ch_pool) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize Basic cluster handler.""" + super().__init__(cluster, endpoint) if is_hue_motion_sensor(self) and self.cluster.endpoint.endpoint_id == 2: self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name self.ZCL_INIT_ATTRS.copy() @@ -169,41 +176,43 @@ class BasicChannel(ZigbeeChannel): self.ZCL_INIT_ATTRS["transmit_power"] = True -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryInput.cluster_id) -class BinaryInput(ZigbeeChannel): - """Binary Input channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.BinaryInput.cluster_id) +class BinaryInput(ClusterHandler): + """Binary Input cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryOutput.cluster_id) -class BinaryOutput(ZigbeeChannel): - """Binary Output channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.BinaryOutput.cluster_id) +class BinaryOutput(ClusterHandler): + """Binary Output cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryValue.cluster_id) -class BinaryValue(ZigbeeChannel): - """Binary Value channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.BinaryValue.cluster_id) +class BinaryValue(ClusterHandler): + """Binary Value cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Commissioning.cluster_id) -class Commissioning(ZigbeeChannel): - """Commissioning channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Commissioning.cluster_id) +class Commissioning(ClusterHandler): + """Commissioning cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.DeviceTemperature.cluster_id) -class DeviceTemperature(ZigbeeChannel): - """Device Temperature channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + general.DeviceTemperature.cluster_id +) +class DeviceTemperature(ClusterHandler): + """Device Temperature cluster handler.""" REPORT_CONFIG = ( { @@ -213,23 +222,23 @@ class DeviceTemperature(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.GreenPowerProxy.cluster_id) -class GreenPowerProxy(ZigbeeChannel): - """Green Power Proxy channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.GreenPowerProxy.cluster_id) +class GreenPowerProxy(ClusterHandler): + """Green Power Proxy cluster handler.""" BIND: bool = False -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Groups.cluster_id) -class Groups(ZigbeeChannel): - """Groups channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Groups.cluster_id) +class Groups(ClusterHandler): + """Groups cluster handler.""" BIND: bool = False -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Identify.cluster_id) -class Identify(ZigbeeChannel): - """Identify channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Identify.cluster_id) +class Identify(ClusterHandler): + """Identify cluster handler.""" BIND: bool = False @@ -242,15 +251,15 @@ class Identify(ZigbeeChannel): self.async_send_signal(f"{self.unique_id}_{cmd}", args[0]) -@registries.CLIENT_CHANNELS_REGISTRY.register(general.LevelControl.cluster_id) -class LevelControlClientChannel(ClientChannel): +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.LevelControl.cluster_id) +class LevelControlClientClusterHandler(ClientClusterHandler): """LevelControl client cluster.""" @registries.BINDABLE_CLUSTERS.register(general.LevelControl.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.LevelControl.cluster_id) -class LevelControlChannel(ZigbeeChannel): - """Channel for the LevelControl Zigbee cluster.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.LevelControl.cluster_id) +class LevelControlClusterHandler(ClusterHandler): + """Cluster handler for the LevelControl Zigbee cluster.""" CURRENT_LEVEL = 0 REPORT_CONFIG = (AttrReportConfig(attr="current_level", config=REPORT_CONFIG_ASAP),) @@ -299,52 +308,54 @@ class LevelControlChannel(ZigbeeChannel): self.async_send_signal(f"{self.unique_id}_{command}", level) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateInput.cluster_id) -class MultistateInput(ZigbeeChannel): - """Multistate Input channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.MultistateInput.cluster_id) +class MultistateInput(ClusterHandler): + """Multistate Input cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateOutput.cluster_id) -class MultistateOutput(ZigbeeChannel): - """Multistate Output channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + general.MultistateOutput.cluster_id +) +class MultistateOutput(ClusterHandler): + """Multistate Output cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateValue.cluster_id) -class MultistateValue(ZigbeeChannel): - """Multistate Value channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.MultistateValue.cluster_id) +class MultistateValue(ClusterHandler): + """Multistate Value cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.CLIENT_CHANNELS_REGISTRY.register(general.OnOff.cluster_id) -class OnOffClientChannel(ClientChannel): - """OnOff client channel.""" +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.OnOff.cluster_id) +class OnOffClientClusterHandler(ClientClusterHandler): + """OnOff client cluster handler.""" @registries.BINDABLE_CLUSTERS.register(general.OnOff.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.OnOff.cluster_id) -class OnOffChannel(ZigbeeChannel): - """Channel for the OnOff Zigbee cluster.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.OnOff.cluster_id) +class OnOffClusterHandler(ClusterHandler): + """Cluster handler for the OnOff Zigbee cluster.""" - ON_OFF = 0 + ON_OFF = general.OnOff.attributes_by_name["on_off"].id REPORT_CONFIG = (AttrReportConfig(attr="on_off", config=REPORT_CONFIG_IMMEDIATE),) ZCL_INIT_ATTRS = { "start_up_on_off": True, } - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize OnOffChannel.""" - super().__init__(cluster, ch_pool) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize OnOffClusterHandler.""" + super().__init__(cluster, endpoint) self._off_listener = None if self.cluster.endpoint.model in ( @@ -363,6 +374,15 @@ class OnOffChannel(ZigbeeChannel): if self.cluster.endpoint.model == "TS011F": self.ZCL_INIT_ATTRS["child_lock"] = True + @classmethod + def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: + """Filter the cluster match for specific devices.""" + return not ( + cluster.endpoint.device.manufacturer == "Konke" + and cluster.endpoint.device.model + in ("3AFE280100510001", "3AFE170100510001") + ) + @property def on_off(self) -> bool | None: """Return cached value of on/off attribute.""" @@ -404,7 +424,7 @@ class OnOffChannel(ZigbeeChannel): self.cluster.update_attribute(self.ON_OFF, t.Bool.true) if on_time > 0: self._off_listener = async_call_later( - self._ch_pool.hass, + self._endpoint.device.hass, (on_time / 10), # value is in 10ths of a second self.set_to_off, ) @@ -426,24 +446,26 @@ class OnOffChannel(ZigbeeChannel): ) async def async_update(self): - """Initialize channel.""" + """Initialize cluster handler.""" if self.cluster.is_client: return - from_cache = not self._ch_pool.is_mains_powered + from_cache = not self._endpoint.device.is_mains_powered self.debug("attempting to update onoff state - from cache: %s", from_cache) await self.get_attribute_value(self.ON_OFF, from_cache=from_cache) await super().async_update() -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.OnOffConfiguration.cluster_id) -class OnOffConfiguration(ZigbeeChannel): - """OnOff Configuration channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + general.OnOffConfiguration.cluster_id +) +class OnOffConfiguration(ClusterHandler): + """OnOff Configuration cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Ota.cluster_id) -@registries.CLIENT_CHANNELS_REGISTRY.register(general.Ota.cluster_id) -class Ota(ClientChannel): - """OTA Channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Ota.cluster_id) +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.Ota.cluster_id) +class Ota(ClientClusterHandler): + """OTA cluster handler.""" BIND: bool = False @@ -457,21 +479,21 @@ class Ota(ClientChannel): else: cmd_name = command_id - signal_id = self._ch_pool.unique_id.split("-")[0] + signal_id = self._endpoint.unique_id.split("-")[0] if cmd_name == "query_next_image": assert args self.async_send_signal(SIGNAL_UPDATE_DEVICE.format(signal_id), args[3]) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Partition.cluster_id) -class Partition(ZigbeeChannel): - """Partition channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Partition.cluster_id) +class Partition(ClusterHandler): + """Partition cluster handler.""" -@registries.CHANNEL_ONLY_CLUSTERS.register(general.PollControl.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.PollControl.cluster_id) -class PollControl(ZigbeeChannel): - """Poll Control channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(general.PollControl.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.PollControl.cluster_id) +class PollControl(ClusterHandler): + """Poll Control cluster handler.""" CHECKIN_INTERVAL = 55 * 60 * 4 # 55min CHECKIN_FAST_POLL_TIMEOUT = 2 * 4 # 2s @@ -480,8 +502,8 @@ class PollControl(ZigbeeChannel): 4476, } # IKEA - async def async_configure_channel_specific(self) -> None: - """Configure channel: set check-in interval.""" + async def async_configure_cluster_handler_specific(self) -> None: + """Configure cluster handler: set check-in interval.""" try: res = await self.cluster.write_attributes( {"checkin_interval": self.CHECKIN_INTERVAL} @@ -508,7 +530,7 @@ class PollControl(ZigbeeChannel): async def check_in_response(self, tsn: int) -> None: """Respond to checkin command.""" await self.checkin_response(True, self.CHECKIN_FAST_POLL_TIMEOUT, tsn=tsn) - if self._ch_pool.manufacturer_code not in self._IGNORED_MANUFACTURER_ID: + if self._endpoint.device.manufacturer_code not in self._IGNORED_MANUFACTURER_ID: await self.set_long_poll_interval(self.LONG_POLL) await self.fast_poll_stop() @@ -518,9 +540,11 @@ class PollControl(ZigbeeChannel): self._IGNORED_MANUFACTURER_ID.add(manufacturer_code) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.PowerConfiguration.cluster_id) -class PowerConfigurationChannel(ZigbeeChannel): - """Channel for the zigbee power configuration cluster.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + general.PowerConfiguration.cluster_id +) +class PowerConfigurationClusterHandler(ClusterHandler): + """Cluster handler for the zigbee power configuration cluster.""" REPORT_CONFIG = ( AttrReportConfig(attr="battery_voltage", config=REPORT_CONFIG_BATTERY_SAVE), @@ -529,8 +553,8 @@ class PowerConfigurationChannel(ZigbeeChannel): ), ) - def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: - """Initialize channel specific attrs.""" + def async_initialize_cluster_handler_specific(self, from_cache: bool) -> Coroutine: + """Initialize cluster handler specific attrs.""" attributes = [ "battery_size", "battery_quantity", @@ -540,26 +564,26 @@ class PowerConfigurationChannel(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.PowerProfile.cluster_id) -class PowerProfile(ZigbeeChannel): - """Power Profile channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.PowerProfile.cluster_id) +class PowerProfile(ClusterHandler): + """Power Profile cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.RSSILocation.cluster_id) -class RSSILocation(ZigbeeChannel): - """RSSI Location channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.RSSILocation.cluster_id) +class RSSILocation(ClusterHandler): + """RSSI Location cluster handler.""" -@registries.CLIENT_CHANNELS_REGISTRY.register(general.Scenes.cluster_id) -class ScenesClientChannel(ClientChannel): - """Scenes channel.""" +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.Scenes.cluster_id) +class ScenesClientClusterHandler(ClientClusterHandler): + """Scenes cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Scenes.cluster_id) -class Scenes(ZigbeeChannel): - """Scenes channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Scenes.cluster_id) +class Scenes(ClusterHandler): + """Scenes cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Time.cluster_id) -class Time(ZigbeeChannel): - """Time channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Time.cluster_id) +class Time(ClusterHandler): + """Time cluster handler.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/helpers.py b/homeassistant/components/zha/core/cluster_handlers/helpers.py new file mode 100644 index 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 65% rename from homeassistant/components/zha/core/channels/manufacturerspecific.py rename to homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index e6b88a6c9a..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, @@ -125,6 +129,7 @@ class OppleRemote(ZigbeeChannel): elif self.cluster.endpoint.model in ("lumi.plug.mmeu01", "lumi.plug.maeu01"): self.ZCL_INIT_ATTRS = { "power_outage_memory": True, + "consumer_connected": True, } elif self.cluster.endpoint.model == "aqara.feeder.acn001": self.ZCL_INIT_ATTRS = { @@ -137,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: @@ -147,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), @@ -160,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 @@ -176,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): @@ -195,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 = { @@ -222,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, @@ -272,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), @@ -323,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 8a773213a5..c90c78243d 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -64,39 +64,40 @@ ATTR_WARNING_DEVICE_STROBE_INTENSITY = "intensity" BAUD_RATES = [2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000] BINDINGS = "bindings" -CHANNEL_ACCELEROMETER = "accelerometer" -CHANNEL_BINARY_INPUT = "binary_input" -CHANNEL_ANALOG_INPUT = "analog_input" -CHANNEL_ANALOG_OUTPUT = "analog_output" -CHANNEL_ATTRIBUTE = "attribute" -CHANNEL_BASIC = "basic" -CHANNEL_COLOR = "light_color" -CHANNEL_COVER = "window_covering" -CHANNEL_DEVICE_TEMPERATURE = "device_temperature" -CHANNEL_DOORLOCK = "door_lock" -CHANNEL_ELECTRICAL_MEASUREMENT = "electrical_measurement" -CHANNEL_EVENT_RELAY = "event_relay" -CHANNEL_FAN = "fan" -CHANNEL_HUMIDITY = "humidity" -CHANNEL_SOIL_MOISTURE = "soil_moisture" -CHANNEL_LEAF_WETNESS = "leaf_wetness" -CHANNEL_IAS_ACE = "ias_ace" -CHANNEL_IAS_WD = "ias_wd" -CHANNEL_IDENTIFY = "identify" -CHANNEL_ILLUMINANCE = "illuminance" -CHANNEL_LEVEL = ATTR_LEVEL -CHANNEL_MULTISTATE_INPUT = "multistate_input" -CHANNEL_OCCUPANCY = "occupancy" -CHANNEL_ON_OFF = "on_off" -CHANNEL_POWER_CONFIGURATION = "power" -CHANNEL_PRESSURE = "pressure" -CHANNEL_SHADE = "shade" -CHANNEL_SMARTENERGY_METERING = "smartenergy_metering" -CHANNEL_TEMPERATURE = "temperature" -CHANNEL_THERMOSTAT = "thermostat" -CHANNEL_ZDO = "zdo" -CHANNEL_ZONE = ZONE = "ias_zone" -CHANNEL_INOVELLI = "inovelli_vzm31sn_cluster" +CLUSTER_HANDLER_ACCELEROMETER = "accelerometer" +CLUSTER_HANDLER_BINARY_INPUT = "binary_input" +CLUSTER_HANDLER_ANALOG_INPUT = "analog_input" +CLUSTER_HANDLER_ANALOG_OUTPUT = "analog_output" +CLUSTER_HANDLER_ATTRIBUTE = "attribute" +CLUSTER_HANDLER_BASIC = "basic" +CLUSTER_HANDLER_COLOR = "light_color" +CLUSTER_HANDLER_COVER = "window_covering" +CLUSTER_HANDLER_DEVICE_TEMPERATURE = "device_temperature" +CLUSTER_HANDLER_DOORLOCK = "door_lock" +CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT = "electrical_measurement" +CLUSTER_HANDLER_EVENT_RELAY = "event_relay" +CLUSTER_HANDLER_FAN = "fan" +CLUSTER_HANDLER_HUMIDITY = "humidity" +CLUSTER_HANDLER_HUE_OCCUPANCY = "philips_occupancy" +CLUSTER_HANDLER_SOIL_MOISTURE = "soil_moisture" +CLUSTER_HANDLER_LEAF_WETNESS = "leaf_wetness" +CLUSTER_HANDLER_IAS_ACE = "ias_ace" +CLUSTER_HANDLER_IAS_WD = "ias_wd" +CLUSTER_HANDLER_IDENTIFY = "identify" +CLUSTER_HANDLER_ILLUMINANCE = "illuminance" +CLUSTER_HANDLER_LEVEL = ATTR_LEVEL +CLUSTER_HANDLER_MULTISTATE_INPUT = "multistate_input" +CLUSTER_HANDLER_OCCUPANCY = "occupancy" +CLUSTER_HANDLER_ON_OFF = "on_off" +CLUSTER_HANDLER_POWER_CONFIGURATION = "power" +CLUSTER_HANDLER_PRESSURE = "pressure" +CLUSTER_HANDLER_SHADE = "shade" +CLUSTER_HANDLER_SMARTENERGY_METERING = "smartenergy_metering" +CLUSTER_HANDLER_TEMPERATURE = "temperature" +CLUSTER_HANDLER_THERMOSTAT = "thermostat" +CLUSTER_HANDLER_ZDO = "zdo" +CLUSTER_HANDLER_ZONE = ZONE = "ias_zone" +CLUSTER_HANDLER_INOVELLI = "inovelli_vzm31sn_cluster" CLUSTER_COMMAND_SERVER = "server" CLUSTER_COMMANDS_CLIENT = "client_commands" @@ -137,8 +138,11 @@ 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" CONF_ZIGPY = "zigpy_config" CONF_CONSIDER_UNAVAILABLE_MAINS = "consider_unavailable_mains" @@ -148,7 +152,9 @@ CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY = 60 * 60 * 6 # 6 hours CONF_ZHA_OPTIONS_SCHEMA = vol.Schema( { - vol.Optional(CONF_DEFAULT_LIGHT_TRANSITION, default=0): cv.positive_int, + vol.Optional(CONF_DEFAULT_LIGHT_TRANSITION, default=0): vol.All( + vol.Coerce(float), vol.Range(min=0, max=2**16 / 10) + ), vol.Required(CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, default=False): cv.boolean, vol.Required(CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, default=True): cv.boolean, vol.Required(CONF_ALWAYS_PREFER_XY_COLOR_MODE, default=True): cv.boolean, @@ -327,15 +333,15 @@ REPORT_CONFIG_OP = ( SENSOR_ACCELERATION = "acceleration" SENSOR_BATTERY = "battery" -SENSOR_ELECTRICAL_MEASUREMENT = CHANNEL_ELECTRICAL_MEASUREMENT +SENSOR_ELECTRICAL_MEASUREMENT = CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT SENSOR_GENERIC = "generic" -SENSOR_HUMIDITY = CHANNEL_HUMIDITY -SENSOR_ILLUMINANCE = CHANNEL_ILLUMINANCE +SENSOR_HUMIDITY = CLUSTER_HANDLER_HUMIDITY +SENSOR_ILLUMINANCE = CLUSTER_HANDLER_ILLUMINANCE SENSOR_METERING = "metering" -SENSOR_OCCUPANCY = CHANNEL_OCCUPANCY +SENSOR_OCCUPANCY = CLUSTER_HANDLER_OCCUPANCY SENSOR_OPENING = "opening" -SENSOR_PRESSURE = CHANNEL_PRESSURE -SENSOR_TEMPERATURE = CHANNEL_TEMPERATURE +SENSOR_PRESSURE = CLUSTER_HANDLER_PRESSURE +SENSOR_TEMPERATURE = CLUSTER_HANDLER_TEMPERATURE SENSOR_TYPE = "sensor_type" SIGNAL_ADD_ENTITIES = "zha_add_new_entities" @@ -378,12 +384,12 @@ WARNING_DEVICE_SQUAWK_MODE_ARMED = 0 WARNING_DEVICE_SQUAWK_MODE_DISARMED = 1 ZHA_DISCOVERY_NEW = "zha_discovery_new_{}" -ZHA_CHANNEL_MSG = "zha_channel_message" -ZHA_CHANNEL_MSG_BIND = "zha_channel_bind" -ZHA_CHANNEL_MSG_CFG_RPT = "zha_channel_configure_reporting" -ZHA_CHANNEL_MSG_DATA = "zha_channel_msg_data" -ZHA_CHANNEL_CFG_DONE = "zha_channel_cfg_done" -ZHA_CHANNEL_READS_PER_REQ = 5 +ZHA_CLUSTER_HANDLER_MSG = "zha_channel_message" +ZHA_CLUSTER_HANDLER_MSG_BIND = "zha_channel_bind" +ZHA_CLUSTER_HANDLER_MSG_CFG_RPT = "zha_channel_configure_reporting" +ZHA_CLUSTER_HANDLER_MSG_DATA = "zha_channel_msg_data" +ZHA_CLUSTER_HANDLER_CFG_DONE = "zha_channel_cfg_done" +ZHA_CLUSTER_HANDLER_READS_PER_REQ = 5 ZHA_EVENT = "zha_event" ZHA_GW_MSG = "zha_gateway_message" ZHA_GW_MSG_DEVICE_FULL_INIT = "device_fully_initialized" diff --git a/homeassistant/components/zha/core/decorators.py b/homeassistant/components/zha/core/decorators.py index 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 eb7dd81e38..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,45 +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 + 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() @@ -112,119 +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 + 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()), + 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(), + 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 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, + 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 + 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..53a3fb883e --- /dev/null +++ b/homeassistant/components/zha/core/endpoint.py @@ -0,0 +1,239 @@ +"""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 + + try: + cluster_handler = cluster_handler_class(cluster, self) + except KeyError as err: + _LOGGER.warning( + "Cluster handler %s for cluster %s on endpoint %s is invalid: %s", + cluster_handler_class, + cluster, + self, + err, + ) + continue + + if cluster_handler.name == const.CLUSTER_HANDLER_POWER_CONFIGURATION: + self._device.power_configuration_ch = cluster_handler + elif cluster_handler.name == const.CLUSTER_HANDLER_IDENTIFY: + self._device.identify_ch = cluster_handler + elif cluster_handler.name == const.CLUSTER_HANDLER_BASIC: + self._device.basic_ch = cluster_handler + self._all_cluster_handlers[cluster_handler.id] = cluster_handler + + def add_client_cluster_handlers(self) -> None: + """Create client cluster handlers for all output clusters if in the registry.""" + for ( + cluster_id, + cluster_handler_class, + ) in registries.CLIENT_CLUSTER_HANDLER_REGISTRY.items(): + cluster = self.zigpy_endpoint.out_clusters.get(cluster_id) + if cluster is not None: + cluster_handler = cluster_handler_class(cluster, self) + self.client_cluster_handlers[cluster_handler.id] = cluster_handler + + async def async_initialize(self, from_cache: bool = False) -> None: + """Initialize claimed cluster handlers.""" + await self._execute_handler_tasks("async_initialize", from_cache) + + async def async_configure(self) -> None: + """Configure claimed cluster handlers.""" + await self._execute_handler_tasks("async_configure") + + async def _execute_handler_tasks(self, func_name: str, *args: Any) -> None: + """Add a throttled cluster handler task and swallow exceptions.""" + cluster_handlers = [ + *self.claimed_cluster_handlers.values(), + *self.client_cluster_handlers.values(), + ] + tasks = [getattr(ch, func_name)(*args) for ch in cluster_handlers] + results = await asyncio.gather(*tasks, return_exceptions=True) + for cluster_handler, outcome in zip(cluster_handlers, results): + if isinstance(outcome, Exception): + cluster_handler.warning( + "'%s' stage failed: %s", func_name, str(outcome), exc_info=outcome + ) + continue + cluster_handler.debug("'%s' stage succeeded", func_name) + + def async_new_entity( + self, + platform: Platform | str, + entity_class: CALLABLE_T, + unique_id: str, + cluster_handlers: list[ClusterHandler], + ) -> None: + """Create a new entity.""" + from .device import DeviceStatus # pylint: disable=import-outside-toplevel + + if self.device.status == DeviceStatus.INITIALIZED: + return + + self.device.hass.data[const.DATA_ZHA][platform].append( + (entity_class, (unique_id, self.device, cluster_handlers)) + ) + + @callback + def async_send_signal(self, signal: str, *args: Any) -> None: + """Send a signal through hass dispatcher.""" + async_dispatcher_send(self.device.hass, signal, *args) + + def send_event(self, signal: dict[str, Any]) -> None: + """Broadcast an event from this endpoint.""" + self.device.zha_send_event( + { + const.ATTR_UNIQUE_ID: self.unique_id, + const.ATTR_ENDPOINT_ID: self.id, + **signal, + } + ) + + def claim_cluster_handlers(self, cluster_handlers: list[ClusterHandler]) -> None: + """Claim cluster handlers.""" + self.claimed_cluster_handlers.update({ch.id: ch for ch in cluster_handlers}) + + def unclaimed_cluster_handlers(self) -> list[ClusterHandler]: + """Return a list of available (unclaimed) cluster handlers.""" + claimed = set(self.claimed_cluster_handlers) + available = set(self.all_cluster_handlers) + return [ + self.all_cluster_handlers[cluster_id] + for cluster_id in (available - claimed) + ] diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 128e3b145f..02c16930d5 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -40,7 +40,11 @@ from .const import ( ATTR_SIGNATURE, ATTR_TYPE, CONF_DATABASE, + CONF_DEVICE_PATH, + CONF_NWK, + CONF_NWK_CHANNEL, CONF_RADIO_TYPE, + CONF_USE_THREAD, CONF_ZIGPY, DATA_ZHA, DATA_ZHA_BRIDGE_ID, @@ -89,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] @@ -101,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] @@ -146,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 @@ -167,7 +165,40 @@ class ZHAGateway: app_config[CONF_DATABASE] = database app_config[CONF_DEVICE] = self.config_entry.data[CONF_DEVICE] - app_config = app_controller_cls.SCHEMA(app_config) + # The bellows UART thread sometimes propagates a cancellation into the main Core + # event loop, when a connection to a TCP coordinator fails in a specific way + if ( + CONF_USE_THREAD not in app_config + and RadioType[radio_type] is RadioType.ezsp + and app_config[CONF_DEVICE][CONF_DEVICE_PATH].startswith("socket://") + ): + app_config[CONF_USE_THREAD] = False + + # 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: @@ -382,7 +413,10 @@ class ZHAGateway: device_info = zha_device.zha_device_info zha_device.async_cleanup_handles() async_dispatcher_send(self._hass, f"{SIGNAL_REMOVE}_{str(zha_device.ieee)}") - asyncio.ensure_future(self._async_remove_device(zha_device, entity_refs)) + self._hass.async_create_task( + self._async_remove_device(zha_device, entity_refs), + "ZHAGateway._async_remove_device", + ) if device_info is not None: async_dispatcher_send( self._hass, @@ -486,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], ): @@ -495,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, ) @@ -699,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 6b99d41268..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: { @@ -93,9 +90,7 @@ DEVICE_CLASS = { zigpy.profiles.zha.DeviceType.ON_OFF_PLUG_IN_UNIT: Platform.SWITCH, zigpy.profiles.zha.DeviceType.SHADE: Platform.COVER, zigpy.profiles.zha.DeviceType.SMART_PLUG: Platform.SWITCH, - zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL: ( - Platform.ALARM_CONTROL_PANEL - ), + zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL: Platform.ALARM_CONTROL_PANEL, zigpy.profiles.zha.DeviceType.IAS_WARNING_DEVICE: Platform.SIREN, }, zigpy.profiles.zll.PROFILE_ID: { @@ -110,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: @@ -131,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) @@ -143,7 +141,10 @@ 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( factory=_get_empty_frozenset, converter=set_or_callable ) @@ -151,15 +152,21 @@ class MatchRule: def weight(self) -> int: """Return the weight of the matching rule. - More specific matches should be preferred over less specific. Model matching - rules have a priority over manufacturer matching rules and rules matching a + More specific matches should be preferred over less specific. Quirk class + matching rules have priority over model matching rules + 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: + weight += 501 - ( + 1 if callable(self.quirk_classes) else len(self.quirk_classes) + ) + if self.models: weight += 401 - (1 if callable(self.models) else len(self.models)) @@ -168,45 +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) -> bool: + def strict_matched( + 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)) + return all(self._matched(manufacturer, model, cluster_handlers, quirk_class)) - def loose_matched(self, manufacturer: str, model: str, channels: list) -> bool: + def loose_matched( + 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)) + return any(self._matched(manufacturer, model, cluster_handlers, quirk_class)) - def _matched(self, manufacturer: str, model: str, channels: list) -> list: + def _matched( + 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: @@ -221,19 +240,25 @@ class MatchRule: else: matches.append(model in self.models) + if self.quirk_classes: + if callable(self.quirk_classes): + matches.append(self.quirk_classes(quirk_class)) + else: + matches.append(quirk_class in self.quirk_classes) + return matches @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.""" @@ -260,14 +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): - 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, [] @@ -276,20 +302,27 @@ class ZHAEntityRegistry: self, manufacturer: str, model: str, - channels: list[ZigbeeChannel], - ) -> 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() + cluster_handlers: list[ClusterHandler], + quirk_class: str, + ) -> 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): - 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 @@ -300,11 +333,14 @@ class ZHAEntityRegistry: self, manufacturer: str, model: str, - channels: list[ZigbeeChannel], - ) -> 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() + cluster_handlers: list[ClusterHandler], + quirk_class: str, + ) -> 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, @@ -312,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): - 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 @@ -330,16 +370,22 @@ 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, generic_ids, manufacturers, models, aux_channels + cluster_handler_names, + generic_ids, + manufacturers, + models, + aux_cluster_handlers, + quirk_classes, ) def decorator(zha_ent: _ZhaEntityT) -> _ZhaEntityT: @@ -355,21 +401,23 @@ 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, ) def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: @@ -377,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 ) @@ -388,21 +436,23 @@ 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, ) def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: @@ -410,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 8b025f6eec..966f35fe98 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -2,18 +2,13 @@ from __future__ import annotations import dataclasses +from importlib.metadata import version from typing import Any -import bellows -import pkg_resources -import zigpy from zigpy.config import CONF_NWK_EXTENDED_PAN_ID from zigpy.profiles import PROFILES +from zigpy.types import Channels from zigpy.zcl import Cluster -import zigpy_deconz -import zigpy_xbee -import zigpy_zigate -import zigpy_znp from homeassistant.components.diagnostics.util import async_redact_data from homeassistant.config_entries import ConfigEntry @@ -73,19 +68,27 @@ 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": bellows.__version__, - "zigpy": zigpy.__version__, - "zigpy_deconz": zigpy_deconz.__version__, - "zigpy_xbee": zigpy_xbee.__version__, - "zigpy_znp": zigpy_znp.__version__, - "zigpy_zigate": zigpy_zigate.__version__, - "zhaquirks": pkg_resources.get_distribution("zha-quirks").version, + "bellows": version("bellows"), + "zigpy": version("zigpy"), + "zigpy_deconz": version("zigpy-deconz"), + "zigpy_xbee": version("zigpy-xbee"), + "zigpy_znp": version("zigpy_znp"), + "zigpy_zigate": version("zigpy-zigate"), + "zhaquirks": version("zha-quirks"), }, }, KEYS_TO_REDACT, 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 13d63808b6..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 @@ -278,10 +289,8 @@ class IkeaFan(BaseFan, ZhaEntity): """Return the number of speeds the fan supports.""" return int_states_in_range(IKEA_SPEED_RANGE) - async def async_set_percentage(self, percentage: int | None) -> None: - """Set the speed percenage of the fan.""" - if percentage is None: - percentage = 0 + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" fan_mode = math.ceil(percentage_to_ranged_value(IKEA_SPEED_RANGE, percentage)) await self._async_set_fan_mode(fan_mode) @@ -298,25 +307,32 @@ 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, percentage=None, preset_mode=None, **kwargs) -> None: + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: """Turn the entity on.""" if percentage is None: - percentage = (100 / self.speed_count) * IKEA_NAME_TO_PRESET_MODE[ - PRESET_MODE_AUTO - ] + percentage = int( + (100 / self.speed_count) * IKEA_NAME_TO_PRESET_MODE[PRESET_MODE_AUTO] + ) await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs: Any) -> None: @@ -325,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..705176ceda 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -39,9 +39,9 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter from .core import discovery, helpers from .core.const import ( - CHANNEL_COLOR, - CHANNEL_LEVEL, - CHANNEL_ON_OFF, + CLUSTER_HANDLER_COLOR, + CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_ON_OFF, CONF_ALWAYS_PREFER_XY_COLOR_MODE, CONF_DEFAULT_LIGHT_TRANSITION, CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, @@ -113,7 +113,7 @@ class BaseLight(LogMixin, light.LightEntity): """Operations common to all light entities.""" _FORCE_ON = False - _DEFAULT_MIN_TRANSITION_TIME = 0 + _DEFAULT_MIN_TRANSITION_TIME: float = 0 def __init__(self, *args, **kwargs): """Initialize the light.""" @@ -130,14 +130,19 @@ class BaseLight(LogMixin, light.LightEntity): self._zha_config_enhanced_light_transition: bool = False self._zha_config_enable_light_transitioning_flag: bool = True self._zha_config_always_prefer_xy_color_mode: bool = True - self._on_off_channel = None - self._level_channel = None - self._color_channel = None - self._identify_channel = None + self._on_off_cluster_handler = None + self._level_cluster_handler = None + self._color_cluster_handler = None + self._identify_cluster_handler = None self._transitioning_individual: bool = False self._transitioning_group: bool = False self._transition_listener: Callable[[], None] | None = None + async def async_will_remove_from_hass(self) -> None: + """Disconnect entity object when removed.""" + self._async_unsub_transition_listener() + await super().async_will_remove_from_hass() + @property def extra_state_attributes(self) -> dict[str, Any]: """Return state attributes.""" @@ -176,9 +181,7 @@ class BaseLight(LogMixin, light.LightEntity): """Turn the entity on.""" transition = kwargs.get(light.ATTR_TRANSITION) duration = ( - transition * 10 - if transition is not None - else self._zha_config_transition * 10 + transition if transition is not None else self._zha_config_transition ) or ( # if 0 is passed in some devices still need the minimum default self._DEFAULT_MIN_TRANSITION_TIME @@ -193,7 +196,8 @@ class BaseLight(LogMixin, light.LightEntity): execute_if_off_supported = ( self._GROUP_SUPPORTS_EXECUTE_IF_OFF if isinstance(self, LightGroup) - else self._color_channel and self._color_channel.execute_if_off_supported + else self._color_cluster_handler + and self._color_cluster_handler.execute_if_off_supported ) set_transition_flag = ( @@ -204,7 +208,7 @@ class BaseLight(LogMixin, light.LightEntity): ) and self._zha_config_enable_light_transitioning_flag transition_time = ( ( - duration / 10 + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT + duration + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT if ( (brightness is not None or transition is not None) and brightness_supported(self._attr_supported_color_modes) @@ -289,9 +293,9 @@ class BaseLight(LogMixin, light.LightEntity): # If the light is currently off, we first need to turn it on at a low # brightness level with no transition. # After that, we set it to the desired color/temperature with no transition. - result = await self._level_channel.move_to_level_with_on_off( + result = await self._level_cluster_handler.move_to_level_with_on_off( level=DEFAULT_MIN_BRIGHTNESS, - transition_time=self._DEFAULT_MIN_TRANSITION_TIME, + transition_time=int(10 * self._DEFAULT_MIN_TRANSITION_TIME), ) t_log["move_to_level_with_on_off"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -329,9 +333,9 @@ class BaseLight(LogMixin, light.LightEntity): and not new_color_provided_while_off and brightness_supported(self._attr_supported_color_modes) ): - result = await self._level_channel.move_to_level_with_on_off( + result = await self._level_cluster_handler.move_to_level_with_on_off( level=level, - transition_time=duration, + transition_time=int(10 * duration), ) t_log["move_to_level_with_on_off"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -353,7 +357,7 @@ class BaseLight(LogMixin, light.LightEntity): # since some lights don't always turn on with move_to_level_with_on_off, # we should call the on command on the on_off cluster # if brightness is not 0. - result = await self._on_off_channel.on() + result = await self._on_off_cluster_handler.on() t_log["on_off"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: # 'On' call failed, but as brightness may still transition @@ -383,8 +387,8 @@ class BaseLight(LogMixin, light.LightEntity): if new_color_provided_while_off: # The light is has the correct color, so we can now transition # it to the correct brightness level. - result = await self._level_channel.move_to_level( - level=level, transition_time=duration + result = await self._level_cluster_handler.move_to_level( + level=level, transition_time=int(10 * duration) ) t_log["move_to_level_if_color"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -400,7 +404,7 @@ class BaseLight(LogMixin, light.LightEntity): self.async_transition_start_timer(transition_time) if effect == light.EFFECT_COLORLOOP: - result = await self._color_channel.color_loop_set( + result = await self._color_cluster_handler.color_loop_set( update_flags=( Color.ColorLoopUpdateFlags.Action | Color.ColorLoopUpdateFlags.Direction @@ -417,7 +421,7 @@ class BaseLight(LogMixin, light.LightEntity): self._attr_effect == light.EFFECT_COLORLOOP and effect != light.EFFECT_COLORLOOP ): - result = await self._color_channel.color_loop_set( + result = await self._color_cluster_handler.color_loop_set( update_flags=Color.ColorLoopUpdateFlags.Action, action=Color.ColorLoopAction.Deactivate, direction=Color.ColorLoopDirection.Decrement, @@ -428,7 +432,7 @@ class BaseLight(LogMixin, light.LightEntity): self._attr_effect = None if flash is not None: - result = await self._identify_channel.trigger_effect( + result = await self._identify_cluster_handler.trigger_effect( effect_id=FLASH_EFFECTS[flash], effect_variant=Identify.EffectVariant.Default, ) @@ -457,12 +461,14 @@ class BaseLight(LogMixin, light.LightEntity): # is not none looks odd here, but it will override built in bulb # transition times if we pass 0 in here if transition is not None and supports_level: - result = await self._level_channel.move_to_level_with_on_off( + result = await self._level_cluster_handler.move_to_level_with_on_off( level=0, - transition_time=(transition * 10 or self._DEFAULT_MIN_TRANSITION_TIME), + transition_time=int( + 10 * (transition or self._DEFAULT_MIN_TRANSITION_TIME) + ), ) else: - result = await self._on_off_channel.off() + result = await self._on_off_cluster_handler.off() # Pause parsing attribute reports until transition is complete if self._zha_config_enable_light_transitioning_flag: @@ -503,9 +509,9 @@ class BaseLight(LogMixin, light.LightEntity): ) if temperature is not None: - result = await self._color_channel.move_to_color_temp( + result = await self._color_cluster_handler.move_to_color_temp( color_temp_mireds=temperature, - transition_time=transition_time, + transition_time=int(10 * transition_time), ) t_log["move_to_color_temp"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -518,19 +524,19 @@ class BaseLight(LogMixin, light.LightEntity): if hs_color is not None: if ( not isinstance(self, LightGroup) - and self._color_channel.enhanced_hue_supported + and self._color_cluster_handler.enhanced_hue_supported ): - result = await self._color_channel.enhanced_move_to_hue_and_saturation( + result = await self._color_cluster_handler.enhanced_move_to_hue_and_saturation( enhanced_hue=int(hs_color[0] * 65535 / 360), saturation=int(hs_color[1] * 2.54), - transition_time=transition_time, + transition_time=int(10 * transition_time), ) t_log["enhanced_move_to_hue_and_saturation"] = result else: - result = await self._color_channel.move_to_hue_and_saturation( + result = await self._color_cluster_handler.move_to_hue_and_saturation( hue=int(hs_color[0] * 254 / 360), saturation=int(hs_color[1] * 2.54), - transition_time=transition_time, + transition_time=int(10 * transition_time), ) t_log["move_to_hue_and_saturation"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -542,10 +548,10 @@ class BaseLight(LogMixin, light.LightEntity): xy_color = None # don't set xy_color if it is also present if xy_color is not None: - result = await self._color_channel.move_to_color( + result = await self._color_cluster_handler.move_to_color( color_x=int(xy_color[0] * 65535), color_y=int(xy_color[1] * 65535), - transition_time=transition_time, + transition_time=int(10 * transition_time), ) t_log["move_to_color"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -574,8 +580,7 @@ class BaseLight(LogMixin, light.LightEntity): SIGNAL_LIGHT_GROUP_TRANSITION_START, {"entity_ids": self._entity_ids}, ) - if self._transition_listener is not None: - self._transition_listener() + self._async_unsub_transition_listener() @callback def async_transition_start_timer(self, transition_time) -> None: @@ -595,14 +600,19 @@ class BaseLight(LogMixin, light.LightEntity): self.async_transition_complete, ) + @callback + def _async_unsub_transition_listener(self) -> None: + """Unsubscribe transition listener.""" + if self._transition_listener: + self._transition_listener() + self._transition_listener = None + @callback def async_transition_complete(self, _=None) -> None: """Set _transitioning_individual to False and write HA state.""" self.debug("transition complete - future attribute reports will write HA state") self._transitioning_individual = False - if self._transition_listener: - self._transition_listener() - self._transition_listener = None + self._async_unsub_transition_listener() self.async_write_ha_state() if isinstance(self, LightGroup): async_dispatcher_send( @@ -620,24 +630,30 @@ class BaseLight(LogMixin, light.LightEntity): ) -@STRICT_MATCH(channel_names=CHANNEL_ON_OFF, aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}) +@STRICT_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, +) class Light(BaseLight, ZhaEntity): """Representation of a ZHA or ZLL light.""" + _attr_name: str = "Light" _attr_supported_color_modes: set[ColorMode] _REFRESH_INTERVAL = (45, 75) - def __init__(self, unique_id, zha_device: ZHADevice, channels, **kwargs) -> None: + def __init__( + self, unique_id, zha_device: ZHADevice, cluster_handlers, **kwargs + ) -> None: """Initialize the ZHA light.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._on_off_channel = self.cluster_channels[CHANNEL_ON_OFF] - self._attr_state = bool(self._on_off_channel.on_off) - self._level_channel = self.cluster_channels.get(CHANNEL_LEVEL) - self._color_channel = self.cluster_channels.get(CHANNEL_COLOR) - self._identify_channel = self.zha_device.channels.identify_ch - if self._color_channel: - self._attr_min_mireds: int = self._color_channel.min_mireds - self._attr_max_mireds: int = self._color_channel.max_mireds + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._on_off_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_ON_OFF] + self._attr_state = bool(self._on_off_cluster_handler.on_off) + self._level_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_LEVEL) + self._color_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COLOR) + self._identify_cluster_handler = zha_device.identify_ch + if self._color_cluster_handler: + self._attr_min_mireds: int = self._color_cluster_handler.min_mireds + self._attr_max_mireds: int = self._color_cluster_handler.max_mireds self._cancel_refresh_handle: CALLBACK_TYPE | None = None effect_list = [] @@ -649,44 +665,48 @@ class Light(BaseLight, ZhaEntity): ) self._attr_supported_color_modes = {ColorMode.ONOFF} - if self._level_channel: + if self._level_cluster_handler: self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) self._attr_supported_features |= light.LightEntityFeature.TRANSITION - self._attr_brightness = self._level_channel.current_level + self._attr_brightness = self._level_cluster_handler.current_level - if self._color_channel: - if self._color_channel.color_temp_supported: + if self._color_cluster_handler: + if self._color_cluster_handler.color_temp_supported: self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) - self._attr_color_temp = self._color_channel.color_temperature + self._attr_color_temp = self._color_cluster_handler.color_temperature - if self._color_channel.xy_supported and ( + if self._color_cluster_handler.xy_supported and ( self._zha_config_always_prefer_xy_color_mode - or not self._color_channel.hs_supported + or not self._color_cluster_handler.hs_supported ): self._attr_supported_color_modes.add(ColorMode.XY) - curr_x = self._color_channel.current_x - curr_y = self._color_channel.current_y + curr_x = self._color_cluster_handler.current_x + curr_y = self._color_cluster_handler.current_y if curr_x is not None and curr_y is not None: self._attr_xy_color = (curr_x / 65535, curr_y / 65535) else: self._attr_xy_color = (0, 0) if ( - self._color_channel.hs_supported + self._color_cluster_handler.hs_supported and not self._zha_config_always_prefer_xy_color_mode ): self._attr_supported_color_modes.add(ColorMode.HS) if ( - self._color_channel.enhanced_hue_supported - and self._color_channel.enhanced_current_hue is not None + self._color_cluster_handler.enhanced_hue_supported + and self._color_cluster_handler.enhanced_current_hue is not None ): - curr_hue = self._color_channel.enhanced_current_hue * 65535 / 360 - elif self._color_channel.current_hue is not None: - curr_hue = self._color_channel.current_hue * 254 / 360 + curr_hue = ( + self._color_cluster_handler.enhanced_current_hue * 65535 / 360 + ) + elif self._color_cluster_handler.current_hue is not None: + curr_hue = self._color_cluster_handler.current_hue * 254 / 360 else: curr_hue = 0 - if (curr_saturation := self._color_channel.current_saturation) is None: + if ( + curr_saturation := self._color_cluster_handler.current_saturation + ) is None: curr_saturation = 0 self._attr_hs_color = ( @@ -694,10 +714,10 @@ class Light(BaseLight, ZhaEntity): int(curr_saturation * 2.54), ) - if self._color_channel.color_loop_supported: + if self._color_cluster_handler.color_loop_supported: self._attr_supported_features |= light.LightEntityFeature.EFFECT effect_list.append(light.EFFECT_COLORLOOP) - if self._color_channel.color_loop_active == 1: + if self._color_cluster_handler.color_loop_active == 1: self._attr_effect = light.EFFECT_COLORLOOP self._attr_supported_color_modes = filter_supported_color_modes( self._attr_supported_color_modes @@ -705,13 +725,16 @@ class Light(BaseLight, ZhaEntity): if len(self._attr_supported_color_modes) == 1: self._attr_color_mode = next(iter(self._attr_supported_color_modes)) else: # Light supports color_temp + hs, determine which mode the light is in - assert self._color_channel - if self._color_channel.color_mode == Color.ColorMode.Color_temperature: + assert self._color_cluster_handler + if ( + self._color_cluster_handler.color_mode + == Color.ColorMode.Color_temperature + ): self._attr_color_mode = ColorMode.COLOR_TEMP else: self._attr_color_mode = ColorMode.XY - if self._identify_channel: + if self._identify_cluster_handler: self._attr_supported_features |= light.LightEntityFeature.FLASH if effect_list: @@ -755,11 +778,11 @@ class Light(BaseLight, ZhaEntity): """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._on_off_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) - if self._level_channel: + if self._level_cluster_handler: self.async_accept_signal( - self._level_channel, SIGNAL_SET_LEVEL, self.set_level + self._level_cluster_handler, SIGNAL_SET_LEVEL, self.set_level ) refresh_interval = random.randint(*(x * 60 for x in self._REFRESH_INTERVAL)) self._cancel_refresh_handle = async_track_time_interval( @@ -844,8 +867,8 @@ class Light(BaseLight, ZhaEntity): return self.debug("polling current state") - if self._on_off_channel: - state = await self._on_off_channel.get_attribute_value( + if self._on_off_cluster_handler: + state = await self._on_off_cluster_handler.get_attribute_value( "on_off", from_cache=False ) # check if transition started whilst waiting for polled state @@ -858,8 +881,8 @@ class Light(BaseLight, ZhaEntity): self._off_with_transition = False self._off_brightness = None - if self._level_channel: - level = await self._level_channel.get_attribute_value( + if self._level_cluster_handler: + level = await self._level_cluster_handler.get_attribute_value( "current_level", from_cache=False ) # check if transition started whilst waiting for polled state @@ -868,7 +891,7 @@ class Light(BaseLight, ZhaEntity): if level is not None: self._attr_brightness = level - if self._color_channel: + if self._color_cluster_handler: attributes = [ "color_mode", "current_x", @@ -876,23 +899,23 @@ class Light(BaseLight, ZhaEntity): ] if ( not self._zha_config_always_prefer_xy_color_mode - and self._color_channel.enhanced_hue_supported + and self._color_cluster_handler.enhanced_hue_supported ): attributes.append("enhanced_current_hue") attributes.append("current_saturation") if ( - self._color_channel.hs_supported - and not self._color_channel.enhanced_hue_supported + self._color_cluster_handler.hs_supported + and not self._color_cluster_handler.enhanced_hue_supported and not self._zha_config_always_prefer_xy_color_mode ): attributes.append("current_hue") attributes.append("current_saturation") - if self._color_channel.color_temp_supported: + if self._color_cluster_handler.color_temp_supported: attributes.append("color_temperature") - if self._color_channel.color_loop_supported: + if self._color_cluster_handler.color_loop_supported: attributes.append("color_loop_active") - results = await self._color_channel.get_attributes( + results = await self._color_cluster_handler.get_attributes( attributes, from_cache=False, only_cache=False ) @@ -915,7 +938,7 @@ class Light(BaseLight, ZhaEntity): and not self._zha_config_always_prefer_xy_color_mode ): self._attr_color_mode = ColorMode.HS - if self._color_channel.enhanced_hue_supported: + if self._color_cluster_handler.enhanced_hue_supported: current_hue = results.get("enhanced_current_hue") else: current_hue = results.get("current_hue") @@ -923,7 +946,7 @@ class Light(BaseLight, ZhaEntity): if current_hue is not None and current_saturation is not None: self._attr_hs_color = ( int(current_hue * 360 / 65535) - if self._color_channel.enhanced_hue_supported + if self._color_cluster_handler.enhanced_hue_supported else int(current_hue * 360 / 254), int(current_saturation / 2.54), ) @@ -1036,36 +1059,41 @@ class Light(BaseLight, ZhaEntity): @STRICT_MATCH( - channel_names=CHANNEL_ON_OFF, - aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, manufacturers={"Philips", "Signify Netherlands B.V."}, ) class HueLight(Light): """Representation of a HUE light which does not report attributes.""" + _attr_name: str = "Light" _REFRESH_INTERVAL = (3, 5) @STRICT_MATCH( - channel_names=CHANNEL_ON_OFF, - aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, manufacturers={"Jasco", "Quotra-Vision", "eWeLight", "eWeLink"}, ) class ForceOnLight(Light): """Representation of a light which does not respect move_to_level_with_on_off.""" + _attr_name: str = "Light" _FORCE_ON = True @STRICT_MATCH( - channel_names=CHANNEL_ON_OFF, - aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, manufacturers=DEFAULT_MIN_TRANSITION_MANUFACTURERS, ) class MinTransitionLight(Light): """Representation of a light which does not react to any "move to" calls with 0 as a transition.""" - _DEFAULT_MIN_TRANSITION_TIME = 1 + _attr_name: str = "Light" + + # Transitions are counted in 1/10th of a second increments, so this is the smallest + _DEFAULT_MIN_TRANSITION_TIME = 0.1 @GROUP_MATCH() @@ -1085,27 +1113,31 @@ class LightGroup(BaseLight, ZhaGroupEntity): group = self.zha_device.gateway.get_group(self._group_id) self._GROUP_SUPPORTS_EXECUTE_IF_OFF = True # pylint: disable=invalid-name - # Check all group members to see if they support execute_if_off. - # If at least one member has a color cluster and doesn't support it, - # it's not used. + for member in group.members: - for pool in member.device.channels.pools: - for channel in pool.all_channels.values(): + # Ensure we do not send group commands that violate the minimum transition + # time of any members. + if member.device.manufacturer in DEFAULT_MIN_TRANSITION_MANUFACTURERS: + self._DEFAULT_MIN_TRANSITION_TIME = ( # pylint: disable=invalid-name + MinTransitionLight._DEFAULT_MIN_TRANSITION_TIME + ) + + # Check all group members to see if they support execute_if_off. + # If at least one member has a color cluster and doesn't support it, + # it's not used. + for endpoint in member.device._endpoints.values(): + for cluster_handler in endpoint.all_cluster_handlers.values(): if ( - channel.name == CHANNEL_COLOR - and not channel.execute_if_off_supported + cluster_handler.name == CLUSTER_HANDLER_COLOR + and not cluster_handler.execute_if_off_supported ): self._GROUP_SUPPORTS_EXECUTE_IF_OFF = False break - self._DEFAULT_MIN_TRANSITION_TIME = any( # pylint: disable=invalid-name - member.device.manufacturer in DEFAULT_MIN_TRANSITION_MANUFACTURERS - for member in group.members - ) - self._on_off_channel = group.endpoint[OnOff.cluster_id] - self._level_channel = group.endpoint[LevelControl.cluster_id] - self._color_channel = group.endpoint[Color.cluster_id] - self._identify_channel = group.endpoint[Identify.cluster_id] + self._on_off_cluster_handler = group.endpoint[OnOff.cluster_id] + self._level_cluster_handler = group.endpoint[LevelControl.cluster_id] + self._color_cluster_handler = group.endpoint[Color.cluster_id] + self._identify_cluster_handler = group.endpoint[Identify.cluster_id] self._debounced_member_refresh: Debouncer | None = None self._zha_config_transition = async_get_zha_config_value( zha_device.gateway.config_entry, @@ -1154,6 +1186,7 @@ class LightGroup(BaseLight, ZhaGroupEntity): function=self._force_member_updates, ) self._debounced_member_refresh = force_refresh_debouncer + self.async_on_remove(force_refresh_debouncer.async_cancel) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index a2ec5e068c..2f6bce0b20 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -9,13 +9,17 @@ from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED, LockEnti from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) 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, @@ -38,7 +42,7 @@ SERVICE_CLEAR_LOCK_USER_CODE = "clear_lock_user_code" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation Door Lock from config entry.""" entities_to_create = hass.data[DATA_ZHA][Platform.LOCK] @@ -52,7 +56,7 @@ async def async_setup_entry( ) config_entry.async_on_unload(unsub) - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( SERVICE_SET_LOCK_USER_CODE, @@ -88,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 @@ -123,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 @@ -131,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 @@ -144,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: @@ -163,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 f0049a79fa..46fe2ce472 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -1,7 +1,7 @@ { "domain": "zha", "name": "Zigbee Home Automation", - "after_dependencies": ["onboarding", "usb", "zeroconf"], + "after_dependencies": ["onboarding", "usb"], "codeowners": ["@dmulcahey", "@adminiuga", "@puddly"], "config_flow": true, "dependencies": ["file_upload"], @@ -20,15 +20,15 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.34.7", + "bellows==0.35.5", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.92", - "zigpy-deconz==0.19.2", - "zigpy==0.53.0", - "zigpy-xbee==0.16.2", - "zigpy-zigate==0.10.3", - "zigpy-znp==0.9.2" + "zha-quirks==0.0.99", + "zigpy-deconz==0.21.0", + "zigpy==0.55.0", + "zigpy-xbee==0.18.0", + "zigpy-zigate==0.11.0", + "zigpy-znp==0.11.1" ], "usb": [ { diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 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/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 1f02c94e61..9fbfa03b92 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -40,6 +40,12 @@ AUTOPROBE_RADIOS = ( RadioType.zigate, ) +RECOMMENDED_RADIOS = ( + RadioType.ezsp, + RadioType.znp, + RadioType.deconz, +) + CONNECT_DELAY_S = 1.0 MIGRATION_RETRIES = 100 diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index d9074acecf..2453f40af4 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -20,19 +20,20 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( - CHANNEL_IAS_WD, - CHANNEL_INOVELLI, - CHANNEL_OCCUPANCY, - CHANNEL_ON_OFF, + CLUSTER_HANDLER_HUE_OCCUPANCY, + CLUSTER_HANDLER_IAS_WD, + CLUSTER_HANDLER_INOVELLI, + CLUSTER_HANDLER_ON_OFF, DATA_ZHA, SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, Strobe, ) 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 @@ -73,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(" ", "_") ] @@ -113,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__ ): @@ -123,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__ ): @@ -133,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__ ): @@ -143,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.""" @@ -163,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", @@ -182,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) @@ -207,13 +210,25 @@ 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() + 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._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state + ) -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_ON_OFF) + @callback + def async_set_state(self, attr_id: int, attr_name: str, value: Any): + """Handle state update from cluster handler.""" + self.async_write_ha_state() + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) class ZHAStartupOnOffSelectEntity( ZCLEnumSelectEntity, id_suffix=OnOff.StartUpOnOff.__name__ ): @@ -233,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", @@ -274,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"): @@ -295,7 +310,7 @@ class MoesBacklightMode(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_7tdtqgwv", "_TZE200_amp6tsvy", @@ -332,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"): @@ -352,7 +367,7 @@ class HueV1MotionSensitivities(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_OCCUPANCY, + cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY, manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML001"}, ) @@ -375,7 +390,7 @@ class HueV2MotionSensitivities(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_OCCUPANCY, + cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY, manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML002", "SML003", "SML004"}, ) @@ -394,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.""" @@ -411,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.""" @@ -428,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.""" @@ -446,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.""" @@ -459,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.""" @@ -475,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.""" @@ -482,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.""" @@ -490,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..52c1f6a5b1 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -46,19 +46,19 @@ from homeassistant.helpers.typing import StateType from .core import discovery from .core.const import ( - CHANNEL_ANALOG_INPUT, - CHANNEL_BASIC, - CHANNEL_DEVICE_TEMPERATURE, - CHANNEL_ELECTRICAL_MEASUREMENT, - CHANNEL_HUMIDITY, - CHANNEL_ILLUMINANCE, - CHANNEL_LEAF_WETNESS, - CHANNEL_POWER_CONFIGURATION, - CHANNEL_PRESSURE, - CHANNEL_SMARTENERGY_METERING, - CHANNEL_SOIL_MOISTURE, - CHANNEL_TEMPERATURE, - CHANNEL_THERMOSTAT, + CLUSTER_HANDLER_ANALOG_INPUT, + CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_DEVICE_TEMPERATURE, + CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + CLUSTER_HANDLER_HUMIDITY, + CLUSTER_HANDLER_ILLUMINANCE, + CLUSTER_HANDLER_LEAF_WETNESS, + CLUSTER_HANDLER_POWER_CONFIGURATION, + CLUSTER_HANDLER_PRESSURE, + CLUSTER_HANDLER_SMARTENERGY_METERING, + CLUSTER_HANDLER_SOIL_MOISTURE, + CLUSTER_HANDLER_TEMPERATURE, + CLUSTER_HANDLER_THERMOSTAT, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -67,7 +67,7 @@ from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES from .entity import ZhaEntity if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice PARALLEL_UPDATES = 5 @@ -88,7 +88,9 @@ BATTERY_SIZES = { 255: "Unknown", } -CHANNEL_ST_HUMIDITY_CLUSTER = f"channel_0x{SMARTTHINGS_HUMIDITY_CLUSTER:04x}" +CLUSTER_HANDLER_ST_HUMIDITY_CLUSTER = ( + f"cluster_handler_0x{SMARTTHINGS_HUMIDITY_CLUSTER:04x}" +) STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SENSOR) MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SENSOR) @@ -125,50 +127,50 @@ class Sensor(ZhaEntity, SensorEntity): self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this sensor.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: ZigbeeChannel = channels[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cluster_handler: ClusterHandler = cluster_handlers[0] @classmethod def create_entity( cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. Return entity if it is a supported configuration, otherwise return None """ - channel = channels[0] - if cls.SENSOR_ATTR in channel.cluster.unsupported_attributes: + cluster_handler = cluster_handlers[0] + if cls.SENSOR_ATTR in cluster_handler.cluster.unsupported_attributes: return None - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) @property def native_value(self) -> StateType: """Return the state of the entity.""" assert self.SENSOR_ATTR is not None - raw_state = self._channel.cluster.get(self.SENSOR_ATTR) + raw_state = self._cluster_handler.cluster.get(self.SENSOR_ATTR) if raw_state is None: return None return self.formatter(raw_state) @callback def async_set_state(self, attr_id: int, attr_name: str, value: Any) -> None: - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() def formatter(self, value: int | enum.IntEnum) -> int | float | str | None: @@ -181,17 +183,18 @@ class Sensor(ZhaEntity, SensorEntity): @MULTI_MATCH( - channel_names=CHANNEL_ANALOG_INPUT, + cluster_handler_names=CLUSTER_HANDLER_ANALOG_INPUT, manufacturers="Digi", - stop_on_match_group=CHANNEL_ANALOG_INPUT, + stop_on_match_group=CLUSTER_HANDLER_ANALOG_INPUT, ) class AnalogInput(Sensor): """Sensor that displays analog input values.""" SENSOR_ATTR = "present_value" + _attr_name: str = "Analog input" -@MULTI_MATCH(channel_names=CHANNEL_POWER_CONFIGURATION) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_POWER_CONFIGURATION) class Battery(Sensor): """Battery sensor of power configuration cluster.""" @@ -207,7 +210,7 @@ class Battery(Sensor): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -216,7 +219,9 @@ class Battery(Sensor): battery_percent_remaining attribute, but zha-device-handlers takes care of it so create the entity regardless """ - return cls(unique_id, zha_device, channels, **kwargs) + if zha_device.is_mains_powered: + return None + return cls(unique_id, zha_device, cluster_handlers, **kwargs) @staticmethod def formatter(value: int) -> int | None: # pylint: disable=arguments-differ @@ -231,25 +236,28 @@ class Battery(Sensor): def extra_state_attributes(self) -> dict[str, Any]: """Return device state attrs for battery sensors.""" state_attrs = {} - battery_size = self._channel.cluster.get("battery_size") + battery_size = self._cluster_handler.cluster.get("battery_size") if battery_size is not None: state_attrs["battery_size"] = BATTERY_SIZES.get(battery_size, "Unknown") - battery_quantity = self._channel.cluster.get("battery_quantity") + battery_quantity = self._cluster_handler.cluster.get("battery_quantity") if battery_quantity is not None: state_attrs["battery_quantity"] = battery_quantity - battery_voltage = self._channel.cluster.get("battery_voltage") + battery_voltage = self._cluster_handler.cluster.get("battery_voltage") if battery_voltage is not None: state_attrs["battery_voltage"] = round(battery_voltage / 10, 2) return state_attrs -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + models={"VZM31-SN", "SP 234", "outletv4"}, +) class ElectricalMeasurement(Sensor): """Active power measurement.""" SENSOR_ATTR = "active_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER - _attr_should_poll = True # BaseZhaEntity defaults to False _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_name: str = "Active power" _attr_native_unit_of_measurement: str = UnitOfPower.WATT @@ -259,24 +267,36 @@ class ElectricalMeasurement(Sensor): def extra_state_attributes(self) -> dict[str, Any]: """Return device state attrs for sensor.""" attrs = {} - if self._channel.measurement_type is not None: - attrs["measurement_type"] = self._channel.measurement_type + if self._cluster_handler.measurement_type is not None: + attrs["measurement_type"] = self._cluster_handler.measurement_type max_attr_name = f"{self.SENSOR_ATTR}_max" - if (max_v := self._channel.cluster.get(max_attr_name)) is not None: + if (max_v := self._cluster_handler.cluster.get(max_attr_name)) is not None: attrs[max_attr_name] = str(self.formatter(max_v)) return attrs def formatter(self, value: int) -> int | float: """Return 'normalized' value.""" - multiplier = getattr(self._channel, f"{self._div_mul_prefix}_multiplier") - divisor = getattr(self._channel, f"{self._div_mul_prefix}_divisor") + multiplier = getattr( + self._cluster_handler, f"{self._div_mul_prefix}_multiplier" + ) + divisor = getattr(self._cluster_handler, f"{self._div_mul_prefix}_divisor") value = float(value * multiplier) / divisor if value < 100 and divisor > 1: return round(value, self._decimals) return round(value) + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, +) +class PolledElectricalMeasurement(ElectricalMeasurement): + """Polled active power measurement.""" + + _attr_should_poll = True # BaseZhaEntity defaults to False + async def async_update(self) -> None: """Retrieve latest state.""" if not self.available: @@ -284,7 +304,7 @@ class ElectricalMeasurement(Sensor): await super().async_update() -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementApparentPower( ElectricalMeasurement, id_suffix="apparent_power" ): @@ -292,63 +312,62 @@ class ElectricalMeasurementApparentPower( SENSOR_ATTR = "apparent_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER - _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor _attr_name: str = "Apparent power" _attr_native_unit_of_measurement = UnitOfApparentPower.VOLT_AMPERE _div_mul_prefix = "ac_power" -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_current"): """RMS current measurement.""" SENSOR_ATTR = "rms_current" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT - _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor _attr_name: str = "RMS current" _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE _div_mul_prefix = "ac_current" -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_voltage"): """RMS Voltage measurement.""" SENSOR_ATTR = "rms_voltage" _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLTAGE - _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor _attr_name: str = "RMS voltage" _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT _div_mul_prefix = "ac_voltage" -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_frequency"): """Frequency measurement.""" SENSOR_ATTR = "ac_frequency" _attr_device_class: SensorDeviceClass = SensorDeviceClass.FREQUENCY - _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor _attr_name: str = "AC frequency" _attr_native_unit_of_measurement = UnitOfFrequency.HERTZ _div_mul_prefix = "ac_frequency" -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_factor"): """Frequency measurement.""" SENSOR_ATTR = "power_factor" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER_FACTOR - _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor _attr_name: str = "Power factor" _attr_native_unit_of_measurement = PERCENTAGE @MULTI_MATCH( - generic_ids=CHANNEL_ST_HUMIDITY_CLUSTER, stop_on_match_group=CHANNEL_HUMIDITY + generic_ids=CLUSTER_HANDLER_ST_HUMIDITY_CLUSTER, + stop_on_match_group=CLUSTER_HANDLER_HUMIDITY, +) +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_HUMIDITY, + stop_on_match_group=CLUSTER_HANDLER_HUMIDITY, ) -@MULTI_MATCH(channel_names=CHANNEL_HUMIDITY, stop_on_match_group=CHANNEL_HUMIDITY) class Humidity(Sensor): """Humidity sensor.""" @@ -360,7 +379,7 @@ class Humidity(Sensor): _attr_native_unit_of_measurement = PERCENTAGE -@MULTI_MATCH(channel_names=CHANNEL_SOIL_MOISTURE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_SOIL_MOISTURE) class SoilMoisture(Sensor): """Soil Moisture sensor.""" @@ -372,7 +391,7 @@ class SoilMoisture(Sensor): _attr_native_unit_of_measurement = PERCENTAGE -@MULTI_MATCH(channel_names=CHANNEL_LEAF_WETNESS) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEAF_WETNESS) class LeafWetness(Sensor): """Leaf Wetness sensor.""" @@ -384,7 +403,7 @@ class LeafWetness(Sensor): _attr_native_unit_of_measurement = PERCENTAGE -@MULTI_MATCH(channel_names=CHANNEL_ILLUMINANCE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ILLUMINANCE) class Illuminance(Sensor): """Illuminance Sensor.""" @@ -400,8 +419,8 @@ class Illuminance(Sensor): @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, - stop_on_match_group=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, + stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) class SmartEnergyMetering(Sensor): """Metering sensor.""" @@ -428,21 +447,21 @@ class SmartEnergyMetering(Sensor): } def formatter(self, value: int) -> int | float: - """Pass through channel formatter.""" - return self._channel.demand_formatter(value) + """Pass through cluster handler formatter.""" + return self._cluster_handler.demand_formatter(value) @property def native_unit_of_measurement(self) -> str | None: """Return Unit of measurement.""" - return self.unit_of_measure_map.get(self._channel.unit_of_measurement) + return self.unit_of_measure_map.get(self._cluster_handler.unit_of_measurement) @property def extra_state_attributes(self) -> dict[str, Any]: """Return device state attrs for battery sensors.""" attrs = {} - if self._channel.device_type is not None: - attrs["device_type"] = self._channel.device_type - if (status := self._channel.status) is not None: + if self._cluster_handler.device_type is not None: + attrs["device_type"] = self._cluster_handler.device_type + if (status := self._cluster_handler.status) is not None: if isinstance(status, enum.IntFlag) and sys.version_info >= (3, 11): attrs["status"] = str( status.name if status.name is not None else status.value @@ -453,8 +472,8 @@ class SmartEnergyMetering(Sensor): @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, - stop_on_match_group=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, + stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered"): """Smart Energy Metering summation sensor.""" @@ -482,17 +501,20 @@ class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered") def formatter(self, value: int) -> int | float: """Numeric pass-through formatter.""" - if self._channel.unit_of_measurement != 0: - return self._channel.summa_formatter(value) + if self._cluster_handler.unit_of_measurement != 0: + return self._cluster_handler.summa_formatter(value) - cooked = float(self._channel.multiplier * value) / self._channel.divisor + cooked = ( + float(self._cluster_handler.multiplier * value) + / self._cluster_handler.divisor + ) return round(cooked, 3) @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"TS011F", "ZLinky_TIC"}, - stop_on_match_group=CHANNEL_SMARTENERGY_METERING, + stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) class PolledSmartEnergySummation(SmartEnergySummation): """Polled Smart Energy Metering summation sensor.""" @@ -503,11 +525,11 @@ class PolledSmartEnergySummation(SmartEnergySummation): """Retrieve latest state.""" if not self.available: return - await self._channel.async_force_update() + await self._cluster_handler.async_force_update() @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier1SmartEnergySummation( @@ -520,7 +542,7 @@ class Tier1SmartEnergySummation( @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier2SmartEnergySummation( @@ -533,7 +555,7 @@ class Tier2SmartEnergySummation( @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier3SmartEnergySummation( @@ -546,7 +568,7 @@ class Tier3SmartEnergySummation( @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier4SmartEnergySummation( @@ -559,7 +581,7 @@ class Tier4SmartEnergySummation( @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier5SmartEnergySummation( @@ -572,7 +594,7 @@ class Tier5SmartEnergySummation( @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier6SmartEnergySummation( @@ -584,7 +606,7 @@ class Tier6SmartEnergySummation( _attr_name: str = "Tier 6 summation delivered" -@MULTI_MATCH(channel_names=CHANNEL_PRESSURE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_PRESSURE) class Pressure(Sensor): """Pressure sensor.""" @@ -596,7 +618,7 @@ class Pressure(Sensor): _attr_native_unit_of_measurement = UnitOfPressure.HPA -@MULTI_MATCH(channel_names=CHANNEL_TEMPERATURE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_TEMPERATURE) class Temperature(Sensor): """Temperature Sensor.""" @@ -608,7 +630,7 @@ class Temperature(Sensor): _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS -@MULTI_MATCH(channel_names=CHANNEL_DEVICE_TEMPERATURE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_DEVICE_TEMPERATURE) class DeviceTemperature(Sensor): """Device Temperature Sensor.""" @@ -621,7 +643,7 @@ class DeviceTemperature(Sensor): _attr_entity_category = EntityCategory.DIAGNOSTIC -@MULTI_MATCH(channel_names="carbon_dioxide_concentration") +@MULTI_MATCH(cluster_handler_names="carbon_dioxide_concentration") class CarbonDioxideConcentration(Sensor): """Carbon Dioxide Concentration sensor.""" @@ -634,7 +656,7 @@ class CarbonDioxideConcentration(Sensor): _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION -@MULTI_MATCH(channel_names="carbon_monoxide_concentration") +@MULTI_MATCH(cluster_handler_names="carbon_monoxide_concentration") class CarbonMonoxideConcentration(Sensor): """Carbon Monoxide Concentration sensor.""" @@ -647,8 +669,8 @@ class CarbonMonoxideConcentration(Sensor): _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION -@MULTI_MATCH(generic_ids="channel_0x042e", stop_on_match_group="voc_level") -@MULTI_MATCH(channel_names="voc_level", stop_on_match_group="voc_level") +@MULTI_MATCH(generic_ids="cluster_handler_0x042e", stop_on_match_group="voc_level") +@MULTI_MATCH(cluster_handler_names="voc_level", stop_on_match_group="voc_level") class VOCLevel(Sensor): """VOC Level sensor.""" @@ -662,7 +684,7 @@ class VOCLevel(Sensor): @MULTI_MATCH( - channel_names="voc_level", + cluster_handler_names="voc_level", models="lumi.airmonitor.acn01", stop_on_match_group="voc_level", ) @@ -678,7 +700,7 @@ class PPBVOCLevel(Sensor): _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_BILLION -@MULTI_MATCH(channel_names="pm25") +@MULTI_MATCH(cluster_handler_names="pm25") class PM25(Sensor): """Particulate Matter 2.5 microns or less sensor.""" @@ -690,7 +712,7 @@ class PM25(Sensor): _attr_native_unit_of_measurement = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER -@MULTI_MATCH(channel_names="formaldehyde_concentration") +@MULTI_MATCH(cluster_handler_names="formaldehyde_concentration") class FormaldehydeConcentration(Sensor): """Formaldehyde Concentration sensor.""" @@ -702,7 +724,10 @@ class FormaldehydeConcentration(Sensor): _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION -@MULTI_MATCH(channel_names=CHANNEL_THERMOSTAT, stop_on_match_group=CHANNEL_THERMOSTAT) +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): """Thermostat HVAC action sensor.""" @@ -713,7 +738,7 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -721,14 +746,14 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): Return entity if it is a supported configuration, otherwise return None """ - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) @property def native_value(self) -> str | None: """Return the current HVAC action.""" if ( - self._channel.pi_heating_demand is None - and self._channel.pi_cooling_demand is None + self._cluster_handler.pi_heating_demand is None + and self._cluster_handler.pi_cooling_demand is None ): return self._rm_rs_action return self._pi_demand_action @@ -737,36 +762,36 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): def _rm_rs_action(self) -> HVACAction | None: """Return the current HVAC action based on running mode and running state.""" - if (running_state := self._channel.running_state) is None: + if (running_state := self._cluster_handler.running_state) is None: return None rs_heat = ( - self._channel.RunningState.Heat_State_On - | self._channel.RunningState.Heat_2nd_Stage_On + self._cluster_handler.RunningState.Heat_State_On + | self._cluster_handler.RunningState.Heat_2nd_Stage_On ) if running_state & rs_heat: return HVACAction.HEATING rs_cool = ( - self._channel.RunningState.Cool_State_On - | self._channel.RunningState.Cool_2nd_Stage_On + self._cluster_handler.RunningState.Cool_State_On + | self._cluster_handler.RunningState.Cool_2nd_Stage_On ) if running_state & rs_cool: return HVACAction.COOLING - running_state = self._channel.running_state + running_state = self._cluster_handler.running_state if running_state and running_state & ( - self._channel.RunningState.Fan_State_On - | self._channel.RunningState.Fan_2nd_Stage_On - | self._channel.RunningState.Fan_3rd_Stage_On + self._cluster_handler.RunningState.Fan_State_On + | self._cluster_handler.RunningState.Fan_2nd_Stage_On + | self._cluster_handler.RunningState.Fan_3rd_Stage_On ): return HVACAction.FAN - running_state = self._channel.running_state - if running_state and running_state & self._channel.RunningState.Idle: + running_state = self._cluster_handler.running_state + if running_state and running_state & self._cluster_handler.RunningState.Idle: return HVACAction.IDLE - if self._channel.system_mode != self._channel.SystemMode.Off: + if self._cluster_handler.system_mode != self._cluster_handler.SystemMode.Off: return HVACAction.IDLE return HVACAction.OFF @@ -774,27 +799,27 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): def _pi_demand_action(self) -> HVACAction: """Return the current HVAC action based on pi_demands.""" - heating_demand = self._channel.pi_heating_demand + heating_demand = self._cluster_handler.pi_heating_demand if heating_demand is not None and heating_demand > 0: return HVACAction.HEATING - cooling_demand = self._channel.pi_cooling_demand + cooling_demand = self._cluster_handler.pi_cooling_demand if cooling_demand is not None and cooling_demand > 0: return HVACAction.COOLING - if self._channel.system_mode != self._channel.SystemMode.Off: + if self._cluster_handler.system_mode != self._cluster_handler.SystemMode.Off: return HVACAction.IDLE return HVACAction.OFF @callback def async_set_state(self, *args, **kwargs) -> None: - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() @MULTI_MATCH( - channel_names={CHANNEL_THERMOSTAT}, + cluster_handler_names={CLUSTER_HANDLER_THERMOSTAT}, manufacturers="Sinope Technologies", - stop_on_match_group=CHANNEL_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) class SinopeHVACAction(ThermostatHVACAction): """Sinope Thermostat HVAC action sensor.""" @@ -803,28 +828,28 @@ class SinopeHVACAction(ThermostatHVACAction): def _rm_rs_action(self) -> HVACAction: """Return the current HVAC action based on running mode and running state.""" - running_mode = self._channel.running_mode - if running_mode == self._channel.RunningMode.Heat: + running_mode = self._cluster_handler.running_mode + if running_mode == self._cluster_handler.RunningMode.Heat: return HVACAction.HEATING - if running_mode == self._channel.RunningMode.Cool: + if running_mode == self._cluster_handler.RunningMode.Cool: return HVACAction.COOLING - running_state = self._channel.running_state + running_state = self._cluster_handler.running_state if running_state and running_state & ( - self._channel.RunningState.Fan_State_On - | self._channel.RunningState.Fan_2nd_Stage_On - | self._channel.RunningState.Fan_3rd_Stage_On + self._cluster_handler.RunningState.Fan_State_On + | self._cluster_handler.RunningState.Fan_2nd_Stage_On + | self._cluster_handler.RunningState.Fan_3rd_Stage_On ): return HVACAction.FAN if ( - self._channel.system_mode != self._channel.SystemMode.Off - and running_mode == self._channel.SystemMode.Off + self._cluster_handler.system_mode != self._cluster_handler.SystemMode.Off + and running_mode == self._cluster_handler.SystemMode.Off ): return HVACAction.IDLE return HVACAction.OFF -@MULTI_MATCH(channel_names=CHANNEL_BASIC) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) class RSSISensor(Sensor, id_suffix="rssi"): """RSSI sensor for a device.""" @@ -842,17 +867,17 @@ class RSSISensor(Sensor, id_suffix="rssi"): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. Return entity if it is a supported configuration, otherwise return None """ - key = f"{CHANNEL_BASIC}_{cls.unique_id_suffix}" + key = f"{CLUSTER_HANDLER_BASIC}_{cls.unique_id_suffix}" if ZHA_ENTITIES.prevent_entity_creation(Platform.SENSOR, zha_device.ieee, key): return None - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) @property def native_value(self) -> StateType: @@ -860,7 +885,7 @@ class RSSISensor(Sensor, id_suffix="rssi"): return getattr(self._zha_device.device, self.unique_id_suffix) -@MULTI_MATCH(channel_names=CHANNEL_BASIC) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) class LQISensor(RSSISensor, id_suffix="lqi"): """LQI sensor for a device.""" @@ -870,7 +895,7 @@ class LQISensor(RSSISensor, id_suffix="lqi"): @MULTI_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_htnnfasr", }, @@ -885,7 +910,7 @@ class TimeLeft(Sensor, id_suffix="time_left"): _attr_native_unit_of_measurement = UnitOfTime.MINUTES -@MULTI_MATCH(channel_names="ikea_airpurifier") +@MULTI_MATCH(cluster_handler_names="ikea_airpurifier") class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): """Sensor that displays device run time (in minutes).""" @@ -896,7 +921,7 @@ class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): _attr_native_unit_of_measurement = UnitOfTime.MINUTES -@MULTI_MATCH(channel_names="ikea_airpurifier") +@MULTI_MATCH(cluster_handler_names="ikea_airpurifier") class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"): """Sensor that displays run time of the current filter (in minutes).""" @@ -914,7 +939,7 @@ class AqaraFeedingSource(types.enum8): HomeAssistant = 0x02 -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"): """Sensor that displays the last feeding source of pet feeder.""" @@ -927,7 +952,7 @@ class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"): return AqaraFeedingSource(value).name -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederLastFeedingSize(Sensor, id_suffix="last_feeding_size"): """Sensor that displays the last feeding size of the pet feeder.""" @@ -936,7 +961,7 @@ class AqaraPetFeederLastFeedingSize(Sensor, id_suffix="last_feeding_size"): _attr_icon: str = "mdi:counter" -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederPortionsDispensed(Sensor, id_suffix="portions_dispensed"): """Sensor that displays the number of portions dispensed by the pet feeder.""" @@ -946,7 +971,7 @@ class AqaraPetFeederPortionsDispensed(Sensor, id_suffix="portions_dispensed"): _attr_icon: str = "mdi:counter" -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): """Sensor that displays the weight dispensed by the pet feeder.""" @@ -955,3 +980,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/strings.json b/homeassistant/components/zha/strings.json index 132f6ed9d9..94b3951f01 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -27,6 +27,10 @@ "flow_control": "data flow control" } }, + "verify_radio": { + "title": "Radio is not recommended", + "description": "The radio you are using ({name}) is not recommended and support for it may be removed in the future. Please see the Zigbee Home Automation integration's documentation for [a list of recommended adapters]({docs_recommended_adapters_url})." + }, "choose_formation_strategy": { "title": "Network Formation", "description": "Choose the network settings for your radio.", @@ -116,6 +120,10 @@ "flow_control": "[%key:component::zha::config::step::manual_port_config::data::flow_control%]" } }, + "verify_radio": { + "title": "[%key:component::zha::config::step::verify_radio::title%]", + "description": "[%key:component::zha::config::step::verify_radio::description%]" + }, "choose_formation_strategy": { "title": "[%key:component::zha::config::step::choose_formation_strategy::title%]", "description": "[%key:component::zha::config::step::choose_formation_strategy::description%]", diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 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..2d4126861b --- /dev/null +++ b/homeassistant/components/zha/websocket_api.py @@ -0,0 +1,1564 @@ +"""Web socket API for Zigbee Home Automation devices.""" +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any, Literal, 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_change_channel, + 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_NEW_CHANNEL = "new_channel" +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]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/network/change_channel", + vol.Required(ATTR_NEW_CHANNEL): vol.Any("auto", vol.Range(11, 26)), + } +) +@websocket_api.async_response +async def websocket_change_channel( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Migrate the Zigbee network to a new channel.""" + new_channel = cast(Literal["auto"] | int, msg[ATTR_NEW_CHANNEL]) + await async_change_channel(hass, new_channel=new_channel) + connection.send_result(msg[ID]) + + +@callback +def async_load_api(hass: HomeAssistant) -> None: + """Set up the web socket API.""" + 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) + websocket_api.async_register_command(hass, websocket_change_channel) + + +@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/README.md b/homeassistant/components/zwave_js/README.md index 920fc4a6a0..f82f421f75 100644 --- a/homeassistant/components/zwave_js/README.md +++ b/homeassistant/components/zwave_js/README.md @@ -1,9 +1,29 @@ -# Z-Wave JS Architecture +# Z-Wave Integration -This document describes the architecture of Z-Wave JS in Home Assistant and how the integration is connected all the way to the Z-Wave USB stick controller. +This document covers details that new contributors may find helpful when getting started. + +## Improving device support + +This section can help new contributors learn how to improve Z-Wave device support within Home Assistant. + +The Z-Wave integration uses a discovery mechanism to create the necessary entities for each of your Z-Wave nodes. To perform this discovery, the integration iterates through each node's [Values](https://zwave-js.github.io/node-zwave-js/#/api/valueid) and compares them to a list of [discovery rules](./discovery.py). If there is a match between a particular discovery rule and the given Value, the integration creates an entity for that value using information sent from the discovery logic to indicate entity platform and instance type. + +In cases where an entity's functionality requires interaction with multiple Values, the discovery rule for that particular entity type is based on the primary Value, or the Value that must be there to indicate that this entity needs to be created, and then the rest of the Values required are discovered by the class instance for that entity. A good example of this is the discovery logic for the `climate` entity. Currently, the discovery logic is tied to the discovery of a Value with a property of `mode` and a command class of `Thermostat Mode`, but the actual entity uses many more Values than that to be fully functional as evident in the [code](./climate.py). + +There are several ways that device support can be improved within Home Assistant, but regardless of the reason, it is important to add device specific tests in these use cases. To do so, add the device's data (from device diagnostics) to the [fixtures folder](../../../tests/components/zwave_js/fixtures) and then define the new fixtures in [conftest.py](../../../tests/components/zwave_js/conftest.py). Use existing tests as the model but the tests can go in the [test_discovery.py module](../../../tests/components/zwave_js/test_discovery.py). + +### Switching HA support for a device from one entity type to another. + +Sometimes manufacturers don't follow the spec properly and implement functionality using the wrong command class, resulting in HA discovering the feature as the wrong entity type. There is a section in the [discovery rules](./discovery.py) for device specific discovery. This can be used to override the type of entity that HA discovers for that particular device's primary Value. + +### Adding feature support to complex entity types + +Sometimes the generic Z-Wave entity logic does not provide all of the features a device is capable of. A great example of this is a climate entity where the current temperature is determined by one of multiple sensors that is configurable by a configuration parameter. In these cases, there is a section in the [discovery rules](./discovery.py) for device specific discovery. By leveraging [discovery_data_template.py](./discovery_data_template.py), it is possible to create the same entity type but with different logic. Generally, we don't like to create entity classes that are device specific, so this mechanism allows us to generalize the implementation. ## Architecture +This section describes the architecture of Z-Wave JS in Home Assistant and how the integration is connected all the way to the Z-Wave USB stick controller. + ### Connection diagram ![alt text][connection_diagram] @@ -24,7 +44,7 @@ Forward the state of Z-Wave JS over a WebSocket connection. Consumes the WebSocket connection and makes the Z-Wave JS state available in Python. -#### Z-Wave JS integration +#### Z-Wave integration Represents Z-Wave devices in Home Assistant and allows control. @@ -38,7 +58,7 @@ Best home automation platform in the world. Z-Wave JS Server can be run as a standalone Node app. -It can also run as part of Z-Wave JS 2 MQTT, which is also a standalone Node app. +It can also run as part of Z-Wave JS UI, which is also a standalone Node app. Both apps are available as Home Assistant add-ons. There are also Docker containers etc. diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 8d3b93ad9d..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 @@ -32,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.issue_registry import ( @@ -161,8 +162,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_delete_issue(hass, DOMAIN, "invalid_server_version") LOGGER.info("Connected to Zwave JS Server") - dev_reg = device_registry.async_get(hass) - ent_reg = entity_registry.async_get(hass) + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) services = ZWaveServices(hass, ent_reg, dev_reg) services.async_register() @@ -220,7 +221,7 @@ class DriverEvents: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Set up the driver events instance.""" self.config_entry = entry - self.dev_reg = device_registry.async_get(hass) + self.dev_reg = dr.async_get(hass) self.hass = hass self.platform_setup_tasks: dict[str, asyncio.Task] = {} self.ready = asyncio.Event() @@ -240,7 +241,7 @@ class DriverEvents: await driver.async_disable_statistics() # Check for nodes that no longer exist and remove them - stored_devices = device_registry.async_entries_for_config_entry( + stored_devices = dr.async_entries_for_config_entry( self.dev_reg, self.config_entry.entry_id ) known_devices = [ @@ -311,7 +312,7 @@ class ControllerEvents: self.node_events = NodeEvents(hass, self) @callback - def remove_device(self, device: device_registry.DeviceEntry) -> None: + def remove_device(self, device: dr.DeviceEntry) -> None: """Remove device from registry.""" # note: removal of entity registry entry is handled by core self.dev_reg.async_remove_device(device.id) @@ -385,7 +386,7 @@ class ControllerEvents: self.remove_device(device) @callback - def register_node_in_dev_reg(self, node: ZwaveNode) -> device_registry.DeviceEntry: + def register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry: """Register node in dev reg.""" driver = self.driver_events.driver device_id = get_device_id(driver, node) @@ -448,7 +449,7 @@ class NodeEvents: self.config_entry = controller_events.config_entry self.controller_events = controller_events self.dev_reg = controller_events.dev_reg - self.ent_reg = entity_registry.async_get(hass) + self.ent_reg = er.async_get(hass) self.hass = hass async def async_on_node_ready(self, node: ZwaveNode) -> None: @@ -532,7 +533,7 @@ class NodeEvents: async def async_handle_discovery_info( self, - device: device_registry.DeviceEntry, + device: dr.DeviceEntry, disc_info: ZwaveDiscoveryInfo, value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], ) -> None: @@ -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 ba61de3bce..29e0dcf9e0 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable import dataclasses from functools import partial, wraps -from typing import Any, Literal +from typing import Any, Literal, cast from aiohttp import web, web_exceptions, web_request import voluptuous as vol @@ -27,23 +27,31 @@ from zwave_js_server.exceptions import ( NotFoundError, SetValueFailed, ) -from zwave_js_server.firmware import update_firmware +from zwave_js_server.firmware import controller_firmware_update_otw, update_firmware from zwave_js_server.model.controller import ( ControllerStatistics, InclusionGrant, ProvisioningEntry, QRProvisioningInformation, ) +from zwave_js_server.model.controller.firmware import ( + ControllerFirmwareUpdateData, + ControllerFirmwareUpdateProgress, + ControllerFirmwareUpdateResult, +) from zwave_js_server.model.driver import Driver -from zwave_js_server.model.firmware import FirmwareUpdateData from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.log_message import LogMessage from zwave_js_server.model.node import Node, NodeStatistics from zwave_js_server.model.node.firmware import ( + NodeFirmwareUpdateData, NodeFirmwareUpdateProgress, NodeFirmwareUpdateResult, ) -from zwave_js_server.model.utils import async_parse_qr_code_string +from zwave_js_server.model.utils import ( + async_parse_qr_code_string, + async_try_parse_dsk_from_qr_code_string, +) from zwave_js_server.util.node import async_set_config_parameter from homeassistant.components import websocket_api @@ -74,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" @@ -112,9 +120,6 @@ OPTED_IN = "opted_in" SECURITY_CLASSES = "security_classes" CLIENT_SIDE_AUTH = "client_side_auth" -# constants for migration -DRY_RUN = "dry_run" - # constants for inclusion INCLUSION_STRATEGY = "inclusion_strategy" @@ -396,6 +401,9 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_unprovision_smart_start_node) websocket_api.async_register_command(hass, websocket_get_provisioning_entries) websocket_api.async_register_command(hass, websocket_parse_qr_code_string) + websocket_api.async_register_command( + hass, websocket_try_parse_dsk_from_qr_code_string + ) websocket_api.async_register_command(hass, websocket_supports_feature) websocket_api.async_register_command(hass, websocket_stop_inclusion) websocket_api.async_register_command(hass, websocket_stop_exclusion) @@ -439,7 +447,7 @@ def async_register_api(hass: HomeAssistant) -> None: hass, websocket_subscribe_controller_statistics ) websocket_api.async_register_command(hass, websocket_subscribe_node_statistics) - hass.http.register_view(FirmwareUploadView()) + hass.http.register_view(FirmwareUploadView(dr.async_get(hass))) @websocket_api.require_admin @@ -647,6 +655,7 @@ async def websocket_node_comments( QR_PROVISIONING_INFORMATION, "options" ): QR_PROVISIONING_INFORMATION_SCHEMA, vol.Exclusive(QR_CODE_STRING, "options"): QR_CODE_STRING_SCHEMA, + vol.Exclusive(DSK, "options"): str, } ) @websocket_api.async_response @@ -669,6 +678,7 @@ async def websocket_add_node( or msg.get(QR_PROVISIONING_INFORMATION) or msg.get(QR_CODE_STRING) ) + dsk = msg.get(DSK) @callback def async_cleanup() -> None: @@ -765,6 +775,7 @@ async def websocket_add_node( INCLUSION_STRATEGY_NOT_SMART_START[inclusion_strategy.value], force_security=force_security, provisioning=provisioning, + dsk=dsk, ) except ValueError as err: connection.send_error( @@ -980,6 +991,32 @@ async def websocket_parse_qr_code_string( connection.send_result(msg[ID], dataclasses.asdict(qr_provisioning_information)) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/try_parse_dsk_from_qr_code_string", + vol.Required(ENTRY_ID): str, + vol.Required(QR_CODE_STRING): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_try_parse_dsk_from_qr_code_string( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Try to parse a DSK string from a QR code.""" + connection.send_result( + msg[ID], + await async_try_parse_dsk_from_qr_code_string(client, msg[QR_CODE_STRING]), + ) + + @websocket_api.require_admin @websocket_api.websocket_command( { @@ -1823,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) @@ -1903,10 +1940,10 @@ async def websocket_is_node_firmware_update_in_progress( connection.send_result(msg[ID], await node.async_is_firmware_update_in_progress()) -def _get_firmware_update_progress_dict( +def _get_node_firmware_update_progress_dict( progress: NodeFirmwareUpdateProgress, ) -> dict[str, int | float]: - """Get a dictionary of firmware update progress.""" + """Get a dictionary of a node's firmware update progress.""" return { "current_file": progress.current_file, "total_files": progress.total_files, @@ -1916,6 +1953,19 @@ def _get_firmware_update_progress_dict( } +def _get_controller_firmware_update_progress_dict( + progress: ControllerFirmwareUpdateProgress, +) -> dict[str, int | float]: + """Get a dictionary of a controller's firmware update progress.""" + return { + "current_file": 1, + "total_files": 1, + "sent_fragments": progress.sent_fragments, + "total_fragments": progress.total_fragments, + "progress": progress.progress, + } + + @websocket_api.require_admin @websocket_api.websocket_command( { @@ -1932,6 +1982,8 @@ async def websocket_subscribe_firmware_update_status( node: Node, ) -> None: """Subscribe to the status of a firmware update.""" + assert node.client.driver + controller = node.client.driver.controller @callback def async_cleanup() -> None: @@ -1940,20 +1992,20 @@ async def websocket_subscribe_firmware_update_status( unsub() @callback - def forward_progress(event: dict) -> None: + def forward_node_progress(event: dict) -> None: progress: NodeFirmwareUpdateProgress = event["firmware_update_progress"] connection.send_message( websocket_api.event_message( msg[ID], { "event": event["event"], - **_get_firmware_update_progress_dict(progress), + **_get_node_firmware_update_progress_dict(progress), }, ) ) @callback - def forward_finished(event: dict) -> None: + def forward_node_finished(event: dict) -> None: finished: NodeFirmwareUpdateResult = event["firmware_update_finished"] connection.send_message( websocket_api.event_message( @@ -1968,21 +2020,69 @@ async def websocket_subscribe_firmware_update_status( ) ) - msg[DATA_UNSUBSCRIBE] = unsubs = [ - node.on("firmware update progress", forward_progress), - node.on("firmware update finished", forward_finished), - ] + @callback + def forward_controller_progress(event: dict) -> None: + progress: ControllerFirmwareUpdateProgress = event["firmware_update_progress"] + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + **_get_controller_firmware_update_progress_dict(progress), + }, + ) + ) + + @callback + def forward_controller_finished(event: dict) -> None: + finished: ControllerFirmwareUpdateResult = event["firmware_update_finished"] + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "status": finished.status, + "success": finished.success, + }, + ) + ) + + if controller.own_node == node: + msg[DATA_UNSUBSCRIBE] = unsubs = [ + controller.on("firmware update progress", forward_controller_progress), + controller.on("firmware update finished", forward_controller_finished), + ] + else: + msg[DATA_UNSUBSCRIBE] = unsubs = [ + node.on("firmware update progress", forward_node_progress), + node.on("firmware update finished", forward_node_finished), + ] connection.subscriptions[msg["id"]] = async_cleanup - progress = node.firmware_update_progress connection.send_result(msg[ID]) - if progress: + if node.is_controller_node and ( + controller_progress := controller.firmware_update_progress + ): connection.send_message( websocket_api.event_message( msg[ID], { "event": "firmware update progress", - **_get_firmware_update_progress_dict(progress), + **_get_controller_firmware_update_progress_dict( + controller_progress + ), + }, + ) + ) + elif controller.own_node != node and ( + node_progress := node.firmware_update_progress + ): + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": "firmware update progress", + **_get_node_firmware_update_progress_dict(node_progress), }, ) ) @@ -2039,10 +2139,10 @@ class FirmwareUploadView(HomeAssistantView): url = r"/api/zwave_js/firmware/upload/{device_id}" name = "api:zwave_js:firmware:upload" - def __init__(self) -> None: + def __init__(self, dev_reg: dr.DeviceRegistry) -> None: """Initialize view.""" super().__init__() - self._dev_reg: dr.DeviceRegistry | None = None + self._dev_reg = dev_reg async def post(self, request: web.Request, device_id: str) -> web.Response: """Handle upload.""" @@ -2051,12 +2151,16 @@ class FirmwareUploadView(HomeAssistantView): hass = request.app["hass"] try: - node = async_get_node_from_device_id(hass, device_id) + node = async_get_node_from_device_id(hass, device_id, self._dev_reg) except ValueError as err: if "not loaded" in err.args[0]: raise web_exceptions.HTTPBadRequest raise web_exceptions.HTTPNotFound + # If this was not true, we wouldn't have been able to get the node from the + # device ID above + assert node.client.driver + # Increase max payload request._client_max_size = 1024 * 1024 * 10 # pylint: disable=protected-access @@ -2068,18 +2172,33 @@ class FirmwareUploadView(HomeAssistantView): uploaded_file: web_request.FileField = data["file"] try: - await update_firmware( - node.client.ws_server_url, - node, - [ - FirmwareUpdateData( + if node.client.driver.controller.own_node == node: + await controller_firmware_update_otw( + node.client.ws_server_url, + ControllerFirmwareUpdateData( uploaded_file.filename, await hass.async_add_executor_job(uploaded_file.file.read), - ) - ], - async_get_clientsession(hass), - additional_user_agent_components=USER_AGENT, - ) + ), + async_get_clientsession(hass), + additional_user_agent_components=USER_AGENT, + ) + else: + firmware_target: int | None = None + if "target" in data: + firmware_target = int(cast(str, data["target"])) + await update_firmware( + node.client.ws_server_url, + node, + [ + NodeFirmwareUpdateData( + uploaded_file.filename, + await hass.async_add_executor_job(uploaded_file.file.read), + firmware_target=firmware_target, + ) + ], + async_get_clientsession(hass), + additional_user_agent_components=USER_AGENT, + ) except BaseZwaveJSServerError as err: raise web_exceptions.HTTPBadRequest(reason=str(err)) from err 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/cover.py b/homeassistant/components/zwave_js/cover.py index 4704718c80..686a186a7c 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -51,7 +51,7 @@ async def async_setup_entry( entities: list[ZWaveBaseEntity] = [] if info.platform_hint == "motorized_barrier": entities.append(ZwaveMotorizedBarrier(config_entry, driver, info)) - elif info.platform_hint == "window_shutter_tilt": + elif info.platform_hint and info.platform_hint.endswith("tilt"): entities.append(ZWaveTiltCover(config_entry, driver, info)) else: entities.append(ZWaveCover(config_entry, driver, info)) @@ -99,6 +99,12 @@ def zwave_tilt_to_percent(value: int) -> int: class ZWaveCover(ZWaveBaseEntity, CoverEntity): """Representation of a Z-Wave Cover device.""" + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) + def __init__( self, config_entry: ConfigEntry, @@ -108,11 +114,20 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): """Initialize a ZWaveCover entity.""" super().__init__(config_entry, driver, info) + self._stop_cover_value = ( + self.get_zwave_value(COVER_OPEN_PROPERTY) + or self.get_zwave_value(COVER_UP_PROPERTY) + or self.get_zwave_value(COVER_ON_PROPERTY) + ) + + if self._stop_cover_value: + self._attr_supported_features |= CoverEntityFeature.STOP + # Entity class attributes self._attr_device_class = CoverDeviceClass.WINDOW - if self.info.platform_hint in ("window_shutter", "window_shutter_tilt"): + if self.info.platform_hint and self.info.platform_hint.startswith("shutter"): self._attr_device_class = CoverDeviceClass.SHUTTER - if self.info.platform_hint == "window_blind": + if self.info.platform_hint and self.info.platform_hint.startswith("blind"): self._attr_device_class = CoverDeviceClass.BLIND @property @@ -153,28 +168,13 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop cover.""" - cover_property = ( - self.get_zwave_value(COVER_OPEN_PROPERTY) - or self.get_zwave_value(COVER_UP_PROPERTY) - or self.get_zwave_value(COVER_ON_PROPERTY) - ) - if cover_property: - # Stop the cover, will stop regardless of the actual direction of travel. - await self.info.node.async_set_value(cover_property, False) + assert self._stop_cover_value + # Stop the cover, will stop regardless of the actual direction of travel. + await self.info.node.async_set_value(self._stop_cover_value, False) class ZWaveTiltCover(ZWaveCover): - """Representation of a Z-Wave Cover device with tilt.""" - - _attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.STOP - | CoverEntityFeature.SET_POSITION - | CoverEntityFeature.OPEN_TILT - | CoverEntityFeature.CLOSE_TILT - | CoverEntityFeature.SET_TILT_POSITION - ) + """Representation of a Z-Wave cover device with tilt.""" def __init__( self, @@ -184,8 +184,15 @@ class ZWaveTiltCover(ZWaveCover): ) -> None: """Initialize a ZWaveCover entity.""" super().__init__(config_entry, driver, info) - self.data_template = cast( + + self._current_tilt_value = cast( CoverTiltDataTemplate, self.info.platform_data_template + ).current_tilt_value(self.info.platform_data) + + self._attr_supported_features |= ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION ) @property @@ -194,19 +201,18 @@ class ZWaveTiltCover(ZWaveCover): None is unknown, 0 is closed, 100 is fully open. """ - value = self.data_template.current_tilt_value(self.info.platform_data) + value = self._current_tilt_value if value is None or value.value is None: return None return zwave_tilt_to_percent(int(value.value)) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" - tilt_value = self.data_template.current_tilt_value(self.info.platform_data) - if tilt_value: - await self.info.node.async_set_value( - tilt_value, - percent_to_zwave_tilt(kwargs[ATTR_TILT_POSITION]), - ) + assert self._current_tilt_value + await self.info.node.async_set_value( + self._current_tilt_value, + percent_to_zwave_tilt(kwargs[ATTR_TILT_POSITION]), + ) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py index 0172176d75..3a585b44f5 100644 --- a/homeassistant/components/zwave_js/device_action.py +++ b/homeassistant/components/zwave_js/device_action.py @@ -25,8 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .config_validation import VALUE_SCHEMA @@ -145,7 +144,7 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, Any]]: """List device actions for Z-Wave JS devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions: list[dict] = [] node = async_get_node_from_device_id(hass, device_id) @@ -179,7 +178,7 @@ async def async_get_actions( meter_endpoints: dict[int, dict[str, Any]] = defaultdict(dict) - for entry in entity_registry.async_entries_for_device( + for entry in er.async_entries_for_device( registry, device_id, include_disabled_entities=False ): # If an entry is unavailable, it is possible that the underlying value diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 067551109e..a0ac70ccb3 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -22,8 +22,8 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( config_validation as cv, - device_registry, - entity_registry, + device_registry as dr, + entity_registry as er, ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -255,14 +255,14 @@ async def async_get_triggers( CONF_DOMAIN: DOMAIN, } - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) node = async_get_node_from_device_id(hass, device_id, dev_reg) if node.client.driver and node.client.driver.controller.own_node == node: return triggers # We can add a node status trigger if the node status sensor is enabled - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_id = async_get_node_status_sensor_entity_id( hass, device_id, ent_reg, dev_reg ) 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 fa0c3dc13d..a43482e3e9 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import asdict, dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any from awesomeversion import AwesomeVersion from zwave_js_server.const import ( @@ -60,6 +60,9 @@ from .discovery_data_template import ( ) from .helpers import ZwaveValueID +if TYPE_CHECKING: + from _typeshed import DataclassInstance + class ValueType(StrEnum): """Enum with all value types.""" @@ -73,7 +76,7 @@ class ValueType(StrEnum): class DataclassMustHaveAtLeastOne: """A dataclass that must have at least one input parameter that is not None.""" - def __post_init__(self) -> 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") @@ -144,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 @@ -342,7 +347,7 @@ DISCOVERY_SCHEMAS = [ # Fibaro Shutter Fibaro FGR222 ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_shutter_tilt", + hint="shutter_tilt", manufacturer_id={0x010F}, product_id={0x1000, 0x1001}, product_type={0x0301, 0x0302}, @@ -366,7 +371,7 @@ DISCOVERY_SCHEMAS = [ # Qubino flush shutter ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_shutter", + hint="shutter", manufacturer_id={0x0159}, product_id={0x0052, 0x0053}, product_type={0x0003}, @@ -375,7 +380,7 @@ DISCOVERY_SCHEMAS = [ # Graber/Bali/Spring Fashion Covers ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_blind", + hint="blind", manufacturer_id={0x026E}, product_id={0x5A31}, product_type={0x4353}, @@ -384,12 +389,59 @@ DISCOVERY_SCHEMAS = [ # iBlinds v2 window blind motor ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_blind", + hint="blind", manufacturer_id={0x0287}, product_id={0x000D}, product_type={0x0003}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, ), + # Merten 507801 Connect Roller Shutter + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="shutter", + manufacturer_id={0x007A}, + product_id={0x0001}, + product_type={0x8003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={0, 1}, + type={ValueType.NUMBER}, + ), + assumed_state=True, + ), + # Merten 507801 Connect Roller Shutter. + # Disable endpoint 2, as it has no practical function. CC: Switch_Multilevel + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="shutter", + manufacturer_id={0x007A}, + product_id={0x0001}, + product_type={0x8003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={2}, + type={ValueType.NUMBER}, + ), + assumed_state=True, + entity_registry_enabled_default=False, + ), + # Merten 507801 Connect Roller Shutter. + # Disable endpoint 2, as it has no practical function. CC: Protection + ZWaveDiscoverySchema( + platform=Platform.SELECT, + manufacturer_id={0x007A}, + product_id={0x0001}, + product_type={0x8003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.PROTECTION}, + property={LOCAL_PROPERTY, RF_PROPERTY}, + endpoint={2}, + type={ValueType.NUMBER}, + ), + entity_registry_enabled_default=False, + ), # Vision Security ZL7432 In Wall Dual Relay Switch ZWaveDiscoverySchema( platform=Platform.SWITCH, @@ -755,7 +807,7 @@ DISCOVERY_SCHEMAS = [ # window coverings ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_cover", + hint="cover", device_class_generic={"Multilevel Switch"}, device_class_specific={ "Motor Control Class A", @@ -847,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")}, + ), + ), ] @@ -1022,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/entity.py b/homeassistant/components/zwave_js/entity.py index 2a57257ffb..1f43f9f5bd 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -8,7 +8,7 @@ from zwave_js_server.model.value import Value as ZwaveValue, get_value_id_str from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceInfo, Entity from .const import DOMAIN, LOGGER from .discovery import ZwaveDiscoveryInfo @@ -136,6 +136,10 @@ class ZWaveBaseEntity(Entity): and self.entity_description and self.entity_description.name ): + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + assert self.entity_description.name is not DEVICE_CLASS_NAME name = self.entity_description.name if name_prefix: 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 a1367e445e..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.45.2"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.48.0"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 7a0532f00e..f7fe5f52d7 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -491,7 +491,7 @@ class ZWaveMeterSensor(ZWaveNumericSensor): class ZWaveListSensor(ZwaveSensorBase): - """Representation of a Z-Wave Numeric sensor with multiple states.""" + """Representation of a Z-Wave List sensor with multiple states.""" def __init__( self, @@ -509,19 +509,31 @@ class ZWaveListSensor(ZwaveSensorBase): # Entity class attributes self._attr_name = self.generate_name(include_value_name=True) + @property + def device_class(self) -> SensorDeviceClass | None: + """Return sensor device class.""" + if super().device_class is not None: + return super().device_class + if self.info.primary_value.metadata.states: + return SensorDeviceClass.ENUM + return None + + @property + def options(self) -> list[str] | None: + """Return options for enum sensor.""" + if self.device_class == SensorDeviceClass.ENUM: + return list(self.info.primary_value.metadata.states.values()) + return None + @property def native_value(self) -> str | None: """Return state of the sensor.""" if self.info.primary_value.value is None: return None - if ( - str(self.info.primary_value.value) - not in self.info.primary_value.metadata.states - ): - return str(self.info.primary_value.value) - return str( - self.info.primary_value.metadata.states[str(self.info.primary_value.value)] - ) + key = str(self.info.primary_value.value) + if key not in self.info.primary_value.metadata.states: + return key + return str(self.info.primary_value.metadata.states[key]) @property def extra_state_attributes(self) -> dict[str, str] | None: @@ -557,22 +569,37 @@ class ZWaveConfigParameterSensor(ZwaveSensorBase): name_prefix="Config parameter", ) + @property + def device_class(self) -> SensorDeviceClass | None: + """Return sensor device class.""" + if super().device_class is not None: + return super().device_class + if ( + self._primary_value.configuration_value_type + == ConfigurationValueType.ENUMERATED + ): + return SensorDeviceClass.ENUM + return None + + @property + def options(self) -> list[str] | None: + """Return options for enum sensor.""" + if self.device_class == SensorDeviceClass.ENUM: + return list(self.info.primary_value.metadata.states.values()) + return None + @property def native_value(self) -> str | None: """Return state of the sensor.""" if self.info.primary_value.value is None: return None + key = str(self.info.primary_value.value) if ( self._primary_value.configuration_value_type == ConfigurationValueType.RANGE - or ( - str(self.info.primary_value.value) - not in self.info.primary_value.metadata.states - ) + or (key not in self.info.primary_value.metadata.states) ): - return str(self.info.primary_value.value) - return str( - self.info.primary_value.metadata.states[str(self.info.primary_value.value)] - ) + return key + return str(self.info.primary_value.metadata.states[key]) @property def extra_state_attributes(self) -> dict[str, str] | None: 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_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index de9d4842ff..b9209c6904 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -84,7 +84,7 @@ bulk_set_partial_config_parameters: value: name: Value description: The new value(s) to set for this configuration parameter. Can either be a raw integer value to represent the bulk change or a mapping where the key is the bitmask (either in hex or integer form) and the value is the new value you want to set for that partial parameter. - example: + example: | "0x1": 1 "0x10": 1 "0x20": 1 @@ -287,7 +287,7 @@ invoke_cc_api: parameters: name: Parameters description: A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters. - example: [1, 1] + example: "[1, 1]" required: true selector: object: diff --git a/homeassistant/components/zwave_js/trigger.py b/homeassistant/components/zwave_js/trigger.py index d1751dc4f4..f747c25c71 100644 --- a/homeassistant/components/zwave_js/trigger.py +++ b/homeassistant/components/zwave_js/trigger.py @@ -1,12 +1,15 @@ """Z-Wave JS trigger dispatcher.""" from __future__ import annotations -from types import ModuleType from typing import cast from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + TriggerProtocol, +) from homeassistant.helpers.typing import ConfigType from .triggers import event, value_updated @@ -17,7 +20,7 @@ TRIGGERS = { } -def _get_trigger_platform(config: ConfigType) -> ModuleType: +def _get_trigger_platform(config: ConfigType) -> TriggerProtocol: """Return trigger platform.""" platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: @@ -31,12 +34,9 @@ async def async_validate_trigger_config( """Validate config.""" platform = _get_trigger_platform(config) if hasattr(platform, "async_validate_trigger_config"): - return cast( - ConfigType, - await getattr(platform, "async_validate_trigger_config")(hass, config), - ) - assert hasattr(platform, "TRIGGER_SCHEMA") - return cast(ConfigType, getattr(platform, "TRIGGER_SCHEMA")(config)) + return await platform.async_validate_trigger_config(hass, config) + + return cast(ConfigType, platform.TRIGGER_SCHEMA(config)) async def async_attach_trigger( @@ -47,10 +47,4 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach trigger of specified platform.""" platform = _get_trigger_platform(config) - assert hasattr(platform, "async_attach_trigger") - return cast( - CALLBACK_TYPE, - await getattr(platform, "async_attach_trigger")( - hass, config, action, trigger_info - ), - ) + return await platform.async_attach_trigger(hass, config, action, trigger_info) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 5485870dc5..70d12b22de 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -2,9 +2,11 @@ from __future__ import annotations import asyncio +from collections import Counter from collections.abc import Callable +from dataclasses import asdict, dataclass from datetime import datetime, timedelta -from typing import Any +from typing import Any, Final from awesomeversion import AwesomeVersion from zwave_js_server.client import Client as ZwaveClient @@ -19,41 +21,72 @@ from zwave_js_server.model.node.firmware import ( ) from homeassistant.components.update import ( + ATTR_LATEST_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.start import async_at_start +from homeassistant.helpers.restore_state import ExtraStoredData from .const import API_KEY_FIRMWARE_UPDATE_SERVICE, DATA_CLIENT, DOMAIN, LOGGER from .helpers import get_device_info, get_valueless_base_unique_id PARALLEL_UPDATES = 1 +UPDATE_DELAY_STRING = "delay" +UPDATE_DELAY_INTERVAL = 5 # In minutes + + +@dataclass +class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): + """Extra stored data for Z-Wave node firmware update entity.""" + + latest_version_firmware: NodeFirmwareUpdateInfo | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the extra data.""" + return { + "latest_version_firmware": asdict(self.latest_version_firmware) + if self.latest_version_firmware + else None + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ZWaveNodeFirmwareUpdateExtraStoredData: + """Initialize the extra data from a dict.""" + if not (firmware_dict := data["latest_version_firmware"]): + return cls(None) + + return cls(NodeFirmwareUpdateInfo.from_dict(firmware_dict)) + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Z-Wave button from config entry.""" + """Set up Z-Wave update entity from config entry.""" client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] - - semaphore = asyncio.Semaphore(3) + cnt: Counter = Counter() @callback def async_add_firmware_update_entity(node: ZwaveNode) -> None: """Add firmware update entity.""" + # We need to delay the first update of each entity to avoid flooding the network + # so we maintain a counter to schedule first update in UPDATE_DELAY_INTERVAL + # minute increments. + cnt[UPDATE_DELAY_STRING] += 1 + delay = timedelta(minutes=(cnt[UPDATE_DELAY_STRING] * UPDATE_DELAY_INTERVAL)) driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. - async_add_entities([ZWaveNodeFirmwareUpdate(driver, node, semaphore)]) + async_add_entities([ZWaveNodeFirmwareUpdate(driver, node, delay)]) config_entry.async_on_unload( async_dispatcher_connect( @@ -77,13 +110,10 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): _attr_has_entity_name = True _attr_should_poll = False - def __init__( - self, driver: Driver, node: ZwaveNode, semaphore: asyncio.Semaphore - ) -> None: + def __init__(self, driver: Driver, node: ZwaveNode, delay: timedelta) -> None: """Initialize a Z-Wave device firmware update entity.""" self.driver = driver self.node = node - self.semaphore = semaphore self._latest_version_firmware: NodeFirmwareUpdateInfo | None = None self._status_unsub: Callable[[], None] | None = None self._poll_unsub: Callable[[], None] | None = None @@ -91,6 +121,7 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self._finished_unsub: Callable[[], None] | None = None self._finished_event = asyncio.Event() self._result: NodeFirmwareUpdateResult | None = None + self._delay: Final[timedelta] = delay # Entity class attributes self._attr_name = "Firmware" @@ -100,6 +131,11 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): # device may not be precreated in main handler yet self._attr_device_info = get_device_info(driver, node) + @property + def extra_restore_state_data(self) -> ZWaveNodeFirmwareUpdateExtraStoredData: + """Return ZWave Node Firmware Update specific state data to be restored.""" + return ZWaveNodeFirmwareUpdateExtraStoredData(self._latest_version_firmware) + @callback def _update_on_status_change(self, _: dict[str, Any]) -> None: """Update the entity when node is awake.""" @@ -143,7 +179,17 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): async def _async_update(self, _: HomeAssistant | datetime | None = None) -> None: """Update the entity.""" - self._poll_unsub = None + if self._poll_unsub: + self._poll_unsub() + self._poll_unsub = None + + # If hass hasn't started yet, push the next update to the next day so that we + # can preserve the offsets we've created between each node + if self.hass.state != CoreState.running: + self._poll_unsub = async_call_later( + self.hass, timedelta(days=1), self._async_update + ) + return # If device is asleep/dead, wait for it to wake up/become alive before # attempting an update @@ -159,12 +205,11 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): return try: - async with self.semaphore: - available_firmware_updates = ( - await self.driver.controller.async_get_available_firmware_updates( - self.node, API_KEY_FIRMWARE_UPDATE_SERVICE - ) + available_firmware_updates = ( + await self.driver.controller.async_get_available_firmware_updates( + self.node, API_KEY_FIRMWARE_UPDATE_SERVICE ) + ) except FailedZWaveCommand as err: LOGGER.debug( "Failed to get firmware updates for node %s: %s", @@ -277,7 +322,27 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): ) ) - self.async_on_remove(async_at_start(self.hass, self._async_update)) + # If we have a complete previous state, use that to set the latest version + if (state := await self.async_get_last_state()) and ( + extra_data := await self.async_get_last_extra_data() + ): + self._attr_latest_version = state.attributes[ATTR_LATEST_VERSION] + self._latest_version_firmware = ( + ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( + extra_data.as_dict() + ).latest_version_firmware + ) + # If we have no state to restore, we can set the latest version to installed + # so that the entity starts as off. If we have partial restore data due to an + # upgrade to an HA version where this feature is released from one that is not + # the entity will start in an unknown state until we can correct on next update + elif not state: + self._attr_latest_version = self._attr_installed_version + + # Spread updates out in 5 minute increments to avoid flooding the network + self.async_on_remove( + async_call_later(self.hass, self._delay, self._async_update) + ) async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed.""" diff --git a/homeassistant/components/zwave_me/__init__.py b/homeassistant/components/zwave_me/__init__.py index f47b77b29d..f49d203cb2 100644 --- a/homeassistant/components/zwave_me/__init__.py +++ b/homeassistant/components/zwave_me/__init__.py @@ -7,8 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry -from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import DeviceInfo, Entity @@ -24,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: controller = hass.data[DOMAIN][entry.entry_id] = ZWaveMeController(hass, entry) if await controller.async_establish_connection(): await async_setup_platforms(hass, entry, controller) - registry = device_registry.async_get(hass) + registry = dr.async_get(hass) controller.remove_stale_devices(registry) return True raise ConfigEntryNotReady() @@ -51,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], @@ -83,7 +84,15 @@ class ZWaveMeController: """Send signal to update device.""" dispatcher_send(self._hass, f"ZWAVE_ME_INFO_{new_info.id}", new_info) - def remove_stale_devices(self, registry: DeviceRegistry): + 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: device = registry.async_get_device( @@ -106,7 +115,7 @@ async def async_setup_platforms( class ZWaveMeEntity(Entity): """Representation of a ZWaveMe device.""" - def __init__(self, controller, device): + def __init__(self, controller: ZWaveMeController, device: ZWaveMeData) -> None: """Initialize the device.""" self.controller = controller self.device = device @@ -115,13 +124,9 @@ class ZWaveMeEntity(Entity): f"{self.controller.config.unique_id}-{self.device.id}" ) self._attr_should_poll = False - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.device.deviceIdentifier)}, - name=self._attr_name, + name=device.title, manufacturer=self.device.manufacturer, sw_version=self.device.firmware, suggested_area=self.device.locationName, @@ -134,10 +139,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 df4cb65152..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: @@ -445,6 +449,10 @@ class ConfigEntry: async def setup_again(*_: Any) -> None: """Run setup again.""" + # Check again when we fire in case shutdown + # has started so we do not block shutdown + if hass.is_stopping: + return self._async_cancel_retry_setup = None await self.async_setup(hass, integration=integration, tries=tries) @@ -457,9 +465,10 @@ class ConfigEntry: EVENT_HOMEASSISTANT_STARTED, setup_again ) - await self._async_process_on_unload() + await self._async_process_on_unload(hass) return - except Exception: # pylint: disable=broad-except + # pylint: disable-next=broad-except + except (asyncio.CancelledError, SystemExit, Exception): _LOGGER.exception( "Error setting up entry %s for %s", self.title, integration.domain ) @@ -539,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 @@ -623,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 @@ -671,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 @@ -724,7 +734,8 @@ class ConfigEntry: } | (context or {}), data=self.data | (data or {}), - ) + ), + f"config entry reauth {self.title} {self.domain} {self.entry_id}", ) @callback @@ -741,15 +752,20 @@ class ConfigEntry: @callback def async_create_task( - self, hass: HomeAssistant, target: Coroutine[Any, Any, _R] + self, + hass: HomeAssistant, + target: Coroutine[Any, Any, _R], + name: str | None = None, ) -> asyncio.Task[_R]: - """Create a task from within the eventloop. + """Create a task from within the event loop. This method must be run in the event loop. target: target to call. """ - task = hass.async_create_task(target) + task = hass.async_create_task( + target, f"{name} {self.title} {self.domain} {self.entry_id}" + ) self._tasks.add(task) task.add_done_callback(self._tasks.remove) @@ -819,7 +835,10 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): init_done: asyncio.Future[None] = asyncio.Future() self._pending_import_flows.setdefault(handler, {})[flow_id] = init_done - task = asyncio.create_task(self._async_init(flow_id, handler, context, data)) + task = asyncio.create_task( + self._async_init(flow_id, handler, context, data), + name=f"config entry flow {handler} {flow_id}", + ) self._initialize_tasks.setdefault(handler, []).append(task) try: @@ -940,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 @@ -1107,7 +1108,8 @@ class ConfigEntries: entry.domain, context={"source": SOURCE_UNIGNORE}, data={"unique_id": entry.unique_id}, - ) + ), + f"config entry unignore {entry.title} {entry.domain} {entry.unique_id}", ) self._async_dispatch(ConfigEntryChange.REMOVED, entry) @@ -1116,7 +1118,13 @@ class ConfigEntries: async def _async_shutdown(self, event: Event) -> None: """Call when Home Assistant is stopping.""" await asyncio.gather( - *(entry.async_shutdown() for entry in self._entries.values()) + *( + asyncio.create_task( + entry.async_shutdown(), + name=f"config entry shutdown {entry.title} {entry.domain} {entry.entry_id}", + ) + for entry in self._entries.values() + ) ) await self.flow.async_shutdown() @@ -1332,7 +1340,10 @@ class ConfigEntries: for listener_ref in entry.update_listeners: if (listener := listener_ref()) is not None: - self.hass.async_create_task(listener(self.hass, entry)) + self.hass.async_create_task( + listener(self.hass, entry), + f"config entry update listener {entry.title} {entry.domain} {entry.domain}", + ) self._async_schedule_save() self._async_dispatch(ConfigEntryChange.UPDATED, entry) @@ -1347,29 +1358,18 @@ 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)) - async def async_forward_entry_setups( self, entry: ConfigEntry, platforms: Iterable[Platform | str] ) -> None: """Forward the setup of an entry to platforms.""" await asyncio.gather( - *(self.async_forward_entry_setup(entry, platform) for platform in platforms) + *( + asyncio.create_task( + self.async_forward_entry_setup(entry, platform), + name=f"config entry forward setup {entry.title} {entry.domain} {entry.entry_id} {platform}", + ) + for platform in platforms + ) ) async def async_forward_entry_setup( @@ -1400,7 +1400,10 @@ class ConfigEntries: return all( await asyncio.gather( *( - self.async_forward_entry_unload(entry, platform) + asyncio.create_task( + self.async_forward_entry_unload(entry, platform), + name=f"config entry forward unload {entry.title} {entry.domain} {entry.entry_id} {platform}", + ) for platform in platforms ) ) @@ -1450,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.""" @@ -1487,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( @@ -1544,7 +1560,8 @@ class ConfigFlow(data_entry_flow.FlowHandler): continue if should_reload: self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) + self.hass.config_entries.async_reload(entry.entry_id), + f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", ) raise data_entry_flow.AbortFlow(error) @@ -1805,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 @@ -1837,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.""" @@ -1912,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: @@ -1930,7 +1972,13 @@ class EntityRegistryDisabledHandler: ) await asyncio.gather( - *(self.hass.config_entries.async_reload(entry_id) for entry_id in to_reload) + *( + asyncio.create_task( + self.hass.config_entries.async_reload(entry_id), + name="config entry reload {entry.title} {entry.domain} {entry.entry_id}", + ) + for entry_id in to_reload + ) ) @@ -1963,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 52cb0b5fa0..46fc8b5756 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 = 3 +MINOR_VERSION: Final = 6 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" @@ -31,6 +31,7 @@ class Platform(StrEnum): CAMERA = "camera" CLIMATE = "climate" COVER = "cover" + DATE = "date" DEVICE_TRACKER = "device_tracker" FAN = "fan" GEO_LOCATION = "geo_location" @@ -50,6 +51,7 @@ class Platform(StrEnum): STT = "stt" SWITCH = "switch" TEXT = "text" + TIME = "time" TTS = "tts" VACUUM = "vacuum" UPDATE = "update" diff --git a/homeassistant/core.py b/homeassistant/core.py index 097e1ed716..8e88820bf7 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -14,6 +14,7 @@ from collections.abc import ( Iterable, Mapping, ) +import concurrent.futures from contextlib import suppress from contextvars import ContextVar import datetime @@ -37,6 +38,7 @@ from typing import ( ) from urllib.parse import urlparse +import async_timeout from typing_extensions import Self import voluptuous as vol import yarl @@ -78,12 +80,9 @@ from .exceptions import ( Unauthorized, ) from .helpers.aiohttp_compat import restore_original_aiohttp_cancel_behavior +from .helpers.json import json_dumps from .util import dt as dt_util, location, ulid as ulid_util -from .util.async_ import ( - fire_coroutine_threadsafe, - run_callback_threadsafe, - shutdown_run_callback_threadsafe, -) +from .util.async_ import run_callback_threadsafe, shutdown_run_callback_threadsafe from .util.read_only_dict import ReadOnlyDict from .util.timeout import TimeoutManager from .util.unit_system import ( @@ -151,7 +150,7 @@ MAX_EXPECTED_ENTITY_IDS = 16384 _LOGGER = logging.getLogger(__name__) -_cv_hass: ContextVar[HomeAssistant] = ContextVar("current_entry") +_cv_hass: ContextVar[HomeAssistant] = ContextVar("hass") @functools.lru_cache(MAX_EXPECTED_ENTITY_IDS) @@ -163,9 +162,19 @@ def split_entity_id(entity_id: str) -> tuple[str, str]: return domain, object_id -VALID_ENTITY_ID = re.compile(r"^(?!.+__)(?!_)[\da-z_]+(? bool: + """Test if a domain a valid format.""" + return VALID_DOMAIN.match(domain) is not None + + +@functools.lru_cache(512) def valid_entity_id(entity_id: str) -> bool: """Test if an entity ID is a valid format. @@ -219,16 +228,29 @@ class HassJob(Generic[_P, _R_co]): we run the job. """ - __slots__ = ("job_type", "target") + __slots__ = ("job_type", "target", "name", "_cancel_on_shutdown") - def __init__(self, target: Callable[_P, _R_co]) -> 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.""" - return f"" + return f"" def _get_hassjob_callable_job_type(target: Callable[..., Any]) -> HassJobType: @@ -294,6 +316,7 @@ class HomeAssistant: self._stopped: asyncio.Event | None = None # Timeout handler for Core/Helper namespace self.timeout: TimeoutManager = TimeoutManager() + self._stop_future: concurrent.futures.Future[None] | None = None @property def is_running(self) -> bool: @@ -312,12 +335,14 @@ class HomeAssistant: For regular use, use "await hass.run()". """ # Register the async start - fire_coroutine_threadsafe(self.async_start(), self.loop) - + _future = asyncio.run_coroutine_threadsafe(self.async_start(), self.loop) # Run forever # Block until stopped _LOGGER.info("Starting Home Assistant core loop") self.loop.run_forever() + # The future is never retrieved but we still hold a reference to it + # to prevent the task from being garbage collected prematurely. + del _future return self.exit_code async def async_run(self, *, attach_signals: bool = True) -> int: @@ -487,7 +512,7 @@ class HomeAssistant: hassjob.target = cast( Callable[..., Coroutine[Any, Any, _R]], hassjob.target ) - task = self.loop.create_task(hassjob.target(*args)) + task = self.loop.create_task(hassjob.target(*args), name=hassjob.name) elif hassjob.job_type == HassJobType.Callback: if TYPE_CHECKING: hassjob.target = cast(Callable[..., _R], hassjob.target) @@ -503,23 +528,27 @@ 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(self, target: Coroutine[Any, Any, _R]) -> asyncio.Task[_R]: - """Create a task from within the eventloop. + def async_create_task( + self, target: Coroutine[Any, Any, _R], name: str | None = None + ) -> asyncio.Task[_R]: + """Create a task from within the event loop. This method must be run in the event loop. If you are using this in your integration, use the create task methods on the config entry instead. target: target to call. """ - task = self.loop.create_task(target) + task = self.loop.create_task(target, name=name) self._tasks.add(task) task.add_done_callback(self._tasks.remove) return task @@ -530,7 +559,7 @@ class HomeAssistant: target: Coroutine[Any, Any, _R], name: str, ) -> asyncio.Task[_R]: - """Create a task from within the eventloop. + """Create a task from within the event loop. This is a background task which will not block startup and will be automatically cancelled on shutdown. If you are using this in your @@ -682,7 +711,11 @@ class HomeAssistant: """Stop Home Assistant and shuts down all threads.""" if self.state == CoreState.not_running: # just ignore return - fire_coroutine_threadsafe(self.async_stop(), self.loop) + # The future is never retrieved, and we only hold a reference + # to it to prevent it from being garbage collected. + self._stop_future = asyncio.run_coroutine_threadsafe( + self.async_stop(), self.loop + ) async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: """Stop Home Assistant and shuts down all threads. @@ -707,11 +740,20 @@ class HomeAssistant: "Stopping Home Assistant before startup has completed may fail" ) + # Keep holding the reference to the tasks but do not allow them + # to block shutdown. Only tasks created after this point will + # be waited for. + running_tasks = self._tasks + # Avoid clearing here since we want the remove callbacks to fire + # and remove the tasks from the original set which is now running_tasks + self._tasks = set() + # Cancel all background tasks for task in self._background_tasks: self._tasks.add(task) task.add_done_callback(self._tasks.remove) task.cancel() + self._cancel_cancellable_timers() self.exit_code = exit_code @@ -726,6 +768,7 @@ class HomeAssistant: "Timed out waiting for shutdown stage 1 to complete, the shutdown will" " continue" ) + self._async_log_running_tasks(1) # stage 2 self.state = CoreState.final_write @@ -738,11 +781,41 @@ class HomeAssistant: "Timed out waiting for shutdown stage 2 to complete, the shutdown will" " continue" ) + self._async_log_running_tasks(2) # stage 3 self.state = CoreState.not_running self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) + # Make a copy of running_tasks since a task can finish + # while we are awaiting canceled tasks to get their result + # which will result in the set size changing during iteration + for task in list(running_tasks): + if task.done(): + # Since we made a copy we need to check + # to see if the task finished while we + # were awaiting another task + continue + _LOGGER.warning( + "Task %s was still running after stage 2 shutdown; " + "Integrations should cancel non-critical tasks when receiving " + "the stop event to prevent delaying shutdown", + task, + ) + task.cancel() + try: + async with async_timeout.timeout(0.1): + await task + except asyncio.CancelledError: + pass + except asyncio.TimeoutError: + # Task may be shielded from cancellation. + _LOGGER.exception( + "Task %s could not be canceled during stage 3 shutdown", task + ) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception("Task %s error during stage 3 shutdown: %s", task, ex) + # Prevent run_callback_threadsafe from scheduling any additional # callbacks in the event loop as callbacks created on the futures # it returns will never run after the final `self.async_block_till_done` @@ -758,16 +831,37 @@ class HomeAssistant: "Timed out waiting for shutdown stage 3 to complete, the shutdown will" " continue" ) + self._async_log_running_tasks(3) + self.state = CoreState.stopped 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: + _LOGGER.warning("Shutdown stage %s: still running: %s", stage, task) + class Context: """The context that triggered something.""" - __slots__ = ("user_id", "parent_id", "id", "origin_event") + __slots__ = ("user_id", "parent_id", "id", "origin_event", "_as_dict") def __init__( self, @@ -780,14 +874,23 @@ class Context: self.user_id = user_id self.parent_id = parent_id self.origin_event: Event | None = None + self._as_dict: ReadOnlyDict[str, str | None] | None = None def __eq__(self, other: Any) -> bool: """Compare contexts.""" return bool(self.__class__ == other.__class__ and self.id == other.id) - def as_dict(self) -> dict[str, str | None]: + def as_dict(self) -> ReadOnlyDict[str, str | None]: """Return a dictionary representation of the context.""" - return {"id": self.id, "parent_id": self.parent_id, "user_id": self.user_id} + if not self._as_dict: + self._as_dict = ReadOnlyDict( + { + "id": self.id, + "parent_id": self.parent_id, + "user_id": self.user_id, + } + ) + return self._as_dict class EventOrigin(enum.Enum): @@ -804,7 +907,7 @@ class EventOrigin(enum.Enum): class Event: """Representation of an event within the bus.""" - __slots__ = ["event_type", "data", "origin", "time_fired", "context"] + __slots__ = ("event_type", "data", "origin", "time_fired", "context", "_as_dict") def __init__( self, @@ -820,21 +923,26 @@ class Event: self.origin = origin self.time_fired = time_fired or dt_util.utcnow() self.context: Context = context or Context( - id=ulid_util.ulid(dt_util.utc_to_timestamp(self.time_fired)) + id=ulid_util.ulid_at_time(dt_util.utc_to_timestamp(self.time_fired)) ) + self._as_dict: ReadOnlyDict[str, Any] | None = None - def as_dict(self) -> dict[str, Any]: + def as_dict(self) -> ReadOnlyDict[str, Any]: """Create a dict representation of this Event. Async friendly. """ - return { - "event_type": self.event_type, - "data": dict(self.data), - "origin": str(self.origin.value), - "time_fired": self.time_fired.isoformat(), - "context": self.context.as_dict(), - } + if not self._as_dict: + self._as_dict = ReadOnlyDict( + { + "event_type": self.event_type, + "data": ReadOnlyDict(self.data), + "origin": str(self.origin.value), + "time_fired": self.time_fired.isoformat(), + "context": self.context.as_dict(), + } + ) + return self._as_dict def __repr__(self) -> str: """Return the representation.""" @@ -986,7 +1094,10 @@ class EventBus: if run_immediately and not is_callback(listener): raise HomeAssistantError(f"Event listener {listener} is not a callback") return self._async_listen_filterable_job( - event_type, _FilterableJob(HassJob(listener), event_filter, run_immediately) + event_type, + _FilterableJob( + HassJob(listener, f"listen {event_type}"), event_filter, run_immediately + ), ) @callback @@ -1060,7 +1171,11 @@ class EventBus: _onetime_listener, listener, ("__name__", "__qualname__", "__module__"), [] ) - filterable_job = _FilterableJob(HassJob(_onetime_listener), None, False) + filterable_job = _FilterableJob( + HassJob(_onetime_listener, f"onetime listen {event_type} {listener}"), + None, + False, + ) return self._async_listen_filterable_job(event_type, filterable_job) @@ -1099,7 +1214,7 @@ class State: object_id: Object id of this state. """ - __slots__ = [ + __slots__ = ( "entity_id", "state", "attributes", @@ -1109,8 +1224,9 @@ class State: "domain", "object_id", "_as_dict", - "_as_compressed_state", - ] + "_as_dict_json", + "_as_compressed_state_json", + ) def __init__( self, @@ -1145,7 +1261,8 @@ class State: self.context = context or Context() self.domain, self.object_id = split_entity_id(self.entity_id) self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None - self._as_compressed_state: dict[str, Any] | None = None + self._as_dict_json: str | None = None + self._as_compressed_state_json: str | None = None @property def name(self) -> str: @@ -1175,11 +1292,17 @@ class State: "attributes": self.attributes, "last_changed": last_changed_isoformat, "last_updated": last_updated_isoformat, - "context": ReadOnlyDict(self.context.as_dict()), + "context": self.context.as_dict(), } ) return self._as_dict + def as_dict_json(self) -> str: + """Return a JSON string of the State.""" + if not self._as_dict_json: + self._as_dict_json = json_dumps(self.as_dict()) + return self._as_dict_json + def as_compressed_state(self) -> dict[str, Any]: """Build a compressed dict of a state for adds. @@ -1187,8 +1310,6 @@ class State: Sends c (context) as a string if it only contains an id. """ - if self._as_compressed_state: - return self._as_compressed_state state_context = self.context if state_context.parent_id is None and state_context.user_id is None: context: dict[str, Any] | str = state_context.id @@ -1204,9 +1325,21 @@ class State: compressed_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp( self.last_updated ) - self._as_compressed_state = compressed_state return compressed_state + def as_compressed_state_json(self) -> str: + """Build a compressed JSON key value pair of a state for adds. + + The JSON string is a key value pair of the entity_id and the compressed state. + + It is used for sending multiple states in a single message. + """ + if not self._as_compressed_state_json: + self._as_compressed_state_json = json_dumps( + {self.entity_id: self.as_compressed_state()} + )[1:-1] + return self._as_compressed_state_json + @classmethod def from_dict(cls, json_dict: dict[str, Any]) -> Self | None: """Initialize a state from a dict. @@ -1482,7 +1615,7 @@ class StateMachine: now = dt_util.utcnow() if context is None: - context = Context(id=ulid_util.ulid(dt_util.utc_to_timestamp(now))) + context = Context(id=ulid_util.ulid_at_time(dt_util.utc_to_timestamp(now))) state = State( entity_id, new_state, @@ -1507,16 +1640,18 @@ class StateMachine: class Service: """Representation of a callable service.""" - __slots__ = ["job", "schema"] + __slots__ = ["job", "schema", "domain", "service"] def __init__( self, func: Callable[[ServiceCall], Coroutine[Any, Any, None] | None], schema: vol.Schema | None, + domain: str, + service: str, context: Context | None = None, ) -> None: """Initialize a service.""" - self.job = HassJob(func) + self.job = HassJob(func, f"service {domain}.{service}") self.schema = schema @@ -1608,7 +1743,7 @@ class ServiceRegistry: """ domain = domain.lower() service = service.lower() - service_obj = Service(service_func, schema) + service_obj = Service(service_func, schema, domain, service) if domain in self._services: self._services[domain][service] = service_obj @@ -1784,7 +1919,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 @@ -1813,7 +1951,10 @@ class Config: self.latitude: float = 0 self.longitude: float = 0 + self.elevation: int = 0 + """Elevation (always in meters regardless of the unit system).""" + self.location_name: str = "Home" self.time_zone: str = "UTC" self.units: UnitSystem = METRIC_SYSTEM @@ -1884,7 +2025,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/application_credentials.py b/homeassistant/generated/application_credentials.py index b15642d46e..59e76a9c8a 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 86da242be8..24215a8a0c 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ @@ -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 28ceb59384..48c4051bb8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ @@ -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", @@ -101,12 +103,14 @@ FLOWS = { "dsmr", "dsmr_reader", "dunehd", + "dwd_weather_warnings", "dynalite", "eafm", "easyenergy", "ecobee", "econet", "ecowitt", + "edl21", "efergy", "eight_sleep", "elgato", @@ -144,10 +148,12 @@ FLOWS = { "fritzbox", "fritzbox_callmonitor", "fronius", + "frontier_silicon", "fully_kiosk", "garages_amsterdam", "gdacs", "generic", + "geo_json_events", "geocaching", "geofency", "geonetnz_quakes", @@ -160,6 +166,7 @@ FLOWS = { "goodwe", "google", "google_assistant_sdk", + "google_generative_ai_conversation", "google_mail", "google_sheets", "google_travel_time", @@ -210,6 +217,7 @@ FLOWS = { "jellyfin", "juicenet", "justnimbus", + "jvc_projector", "kaleidescape", "keenetic_ndms2", "kegtron", @@ -280,6 +288,7 @@ FLOWS = { "netatmo", "netgear", "nexia", + "nextcloud", "nextdns", "nfandroidtv", "nibe_heatpump", @@ -293,6 +302,7 @@ FLOWS = { "nut", "nws", "nzbget", + "obihai", "octoprint", "omnilogic", "oncue", @@ -335,6 +345,7 @@ FLOWS = { "pushover", "pvoutput", "pvpc_hourly_pricing", + "qbittorrent", "qingping", "qnap_qsw", "rachio", @@ -344,6 +355,7 @@ FLOWS = { "rainbird", "rainforest_eagle", "rainmachine", + "rapt_ble", "rdw", "recollect_waste", "renault", @@ -354,6 +366,7 @@ FLOWS = { "ring", "risco", "rituals_perfume_genie", + "roborock", "roku", "roomba", "roon", @@ -394,6 +407,7 @@ FLOWS = { "smarttub", "smhi", "sms", + "snapcast", "snooz", "solaredge", "solarlog", @@ -475,6 +489,7 @@ FLOWS = { "vilfo", "vizio", "vlc_telnet", + "voip", "volumio", "volvooncall", "vulcan", @@ -491,7 +506,9 @@ FLOWS = { "wiz", "wled", "wolflink", + "workday", "ws66i", + "wyoming", "xbox", "xiaomi_aqara", "xiaomi_ble", diff --git a/homeassistant/generated/countries.py b/homeassistant/generated/countries.py index 76482a524d..452e65afb0 100644 --- a/homeassistant/generated/countries.py +++ b/homeassistant/generated/countries.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.countries diff --git a/homeassistant/generated/currencies.py b/homeassistant/generated/currencies.py index 546bc125a0..3cf2b9a1ab 100644 --- a/homeassistant/generated/currencies.py +++ b/homeassistant/generated/currencies.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.currencies """ diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 8956085a5a..adcc32fe8d 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ @@ -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*", @@ -387,8 +395,15 @@ DHCP: list[dict[str, str | bool]] = [ { "domain": "reolink", "hostname": "reolink*", + }, + { + "domain": "reolink", "macaddress": "EC71DB*", }, + { + "domain": "reolink", + "registered_devices": True, + }, { "domain": "ring", "hostname": "ring*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cee5b2167a..b3dc06926c 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", @@ -1194,7 +1200,7 @@ "dwd_weather_warnings": { "name": "Deutscher Wetterdienst (DWD) Weather Warnings", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "dweet": { @@ -1272,7 +1278,7 @@ "edl21": { "name": "EDL21", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "efergy": { @@ -1473,6 +1479,11 @@ "config_flow": true, "iot_class": "local_push" }, + "esera_onewire": { + "name": "ESERA 1-Wire", + "integration_type": "virtual", + "supported_by": "onewire" + }, "esphome": { "name": "ESPHome", "integration_type": "device", @@ -1818,7 +1829,7 @@ "frontier_silicon": { "name": "Frontier Silicon", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "fully_kiosk": { @@ -1882,7 +1893,7 @@ "geo_json_events": { "name": "GeoJSON", "integration_type": "service", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "geo_rss_events": { @@ -2018,6 +2029,12 @@ "iot_class": "cloud_polling", "name": "Google Domains" }, + "google_generative_ai_conversation": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Generative AI Conversation" + }, "google_mail": { "integration_type": "service", "config_flow": true, @@ -2202,6 +2219,12 @@ "integration_type": "virtual", "supported_by": "gree" }, + "heltun": { + "name": "HELTUN", + "iot_standards": [ + "zwave" + ] + }, "here_travel_time": { "name": "HERE Travel Time", "integration_type": "hub", @@ -2294,6 +2317,12 @@ } } }, + "homeseer": { + "name": "HomeSeer", + "iot_standards": [ + "zwave" + ] + }, "homewizard": { "name": "HomeWizard Energy", "integration_type": "hub", @@ -2635,6 +2664,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "jvc_projector": { + "name": "JVC Projector", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "kaiterra": { "name": "Kaiterra", "integration_type": "hub", @@ -3067,12 +3102,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", @@ -3282,12 +3311,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" } } }, @@ -3384,6 +3407,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", @@ -3606,12 +3634,12 @@ "name": "NextBus", "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling" + "iot_class": "cloud_polling" }, "nextcloud": { "name": "Nextcloud", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "nextdns": { @@ -3777,7 +3805,7 @@ "obihai": { "name": "Obihai", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "octoprint": { @@ -4128,6 +4156,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "piper": { + "name": "Piper", + "integration_type": "virtual", + "supported_by": "wyoming" + }, "pjlink": { "name": "PJLink", "integration_type": "hub", @@ -4304,8 +4337,8 @@ }, "qbittorrent": { "name": "qBittorrent", - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "local_polling" }, "qingping": { @@ -4343,6 +4376,11 @@ "config_flow": false, "iot_class": "calculated" }, + "quadrafire": { + "name": "Quadra-Fire", + "integration_type": "virtual", + "supported_by": "intellifire" + }, "quantum_gateway": { "name": "Quantum Gateway", "integration_type": "hub", @@ -4409,6 +4447,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": { @@ -4569,8 +4613,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", @@ -5045,7 +5090,7 @@ "snapcast": { "name": "Snapcast", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "snips": { @@ -5982,6 +6027,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "vermont_castings": { + "name": "Vermont Castings", + "integration_type": "virtual", + "supported_by": "intellifire" + }, "versasense": { "name": "VersaSense", "integration_type": "hub", @@ -6026,7 +6076,7 @@ }, "vizio": { "name": "VIZIO SmartCast", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -6053,6 +6103,12 @@ "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", @@ -6141,6 +6197,11 @@ "config_flow": true, "iot_class": "cloud_push" }, + "whisper": { + "name": "Whisper", + "integration_type": "virtual", + "supported_by": "wyoming" + }, "whois": { "name": "Whois", "integration_type": "service", @@ -6192,7 +6253,7 @@ "workday": { "name": "Workday", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "worldclock": { @@ -6225,6 +6286,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", @@ -6463,7 +6530,6 @@ }, "helper": { "counter": { - "name": "Counter", "integration_type": "helper", "config_flow": false }, @@ -6482,7 +6548,6 @@ "config_flow": false }, "input_button": { - "name": "Input Button", "integration_type": "helper", "config_flow": false }, @@ -6546,6 +6611,7 @@ "alert", "aurora", "cert_expiry", + "counter", "cpuspeed", "demo", "derivative", @@ -6557,6 +6623,7 @@ "growatt_server", "homekit_controller", "input_boolean", + "input_button", "input_datetime", "input_number", "input_select", diff --git a/homeassistant/generated/languages.py b/homeassistant/generated/languages.py index 879d4a4cd4..b4aebb0f1a 100644 --- a/homeassistant/generated/languages.py +++ b/homeassistant/generated/languages.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.languages [frontend_tag] """ diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py index 5d64546b91..69abf7c64f 100644 --- a/homeassistant/generated/mqtt.py +++ b/homeassistant/generated/mqtt.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index ca6a22e85d..3a2097a1d3 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ @@ -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/usb.py b/homeassistant/generated/usb.py index 2d0dced896..f58936caf8 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index ae9668f372..1771d9d63b 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ @@ -279,6 +279,11 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_androidtvremote2._tcp.local.": [ + { + "domain": "androidtv_remote", + }, + ], "_api._tcp.local.": [ { "domain": "baf", @@ -522,6 +527,11 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_meshcop._udp.local.": [ + { + "domain": "thread", + }, + ], "_miio._udp.local.": [ { "domain": "xiaomi_aqara", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index d623de5e81..8208c77488 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -37,8 +37,28 @@ SERVER_SOFTWARE = "{0}/{1} aiohttp/{2} Python/{3[0]}.{3[1]}".format( APPLICATION_NAME, __version__, aiohttp.__version__, sys.version_info ) +ENABLE_CLEANUP_CLOSED = not (3, 11, 1) <= sys.version_info < (3, 11, 4) +# Enabling cleanup closed on python 3.11.1+ leaks memory relatively quickly +# see https://github.com/aio-libs/aiohttp/issues/7252 +# aiohttp interacts poorly with https://github.com/python/cpython/pull/98540 +# The issue was fixed in 3.11.4 via https://github.com/python/cpython/pull/104485 + WARN_CLOSE_MSG = "closes the Home Assistant aiohttp session" +# +# The default connection limit of 100 meant that you could only have +# 100 concurrent connections. +# +# This was effectively a limit of 100 devices and than +# the supervisor API would fail as soon as it was hit. +# +# We now apply the 100 limit per host, so that we can have 100 connections +# to a single host, but can have more than 4096 connections in total to +# prevent a single host from using all available connections. +# +MAXIMUM_CONNECTIONS = 4096 +MAXIMUM_CONNECTIONS_PER_HOST = 100 + class HassClientResponse(aiohttp.ClientResponse): """aiohttp.ClientResponse with a json method that uses json_loads by default.""" @@ -129,7 +149,7 @@ def _async_create_clientsession( {USER_AGENT: SERVER_SOFTWARE}, ) - clientsession.close = warn_use( # type: ignore[assignment] + clientsession.close = warn_use( # type: ignore[method-assign] clientsession.close, WARN_CLOSE_MSG, ) @@ -257,11 +277,16 @@ def _async_get_connector( return cast(aiohttp.BaseConnector, hass.data[key]) if verify_ssl: - ssl_context: bool | SSLContext = ssl_util.client_context() + 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, ssl=ssl_context) + connector = aiohttp.TCPConnector( + enable_cleanup_closed=ENABLE_CLEANUP_CLOSED, + ssl=ssl_context, + limit=MAXIMUM_CONNECTIONS, + limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST, + ) hass.data[key] = connector async def _async_close_connector(event: Event) -> None: 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/condition.py b/homeassistant/helpers/condition.py index 30181751f8..0029a9c906 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -7,13 +7,13 @@ from collections.abc import Callable, Container, Generator from contextlib import contextmanager from datetime import datetime, time as dt_time, timedelta import functools as ft -import logging import re import sys -from typing import Any, cast +from typing import Any, Protocol, cast + +import voluptuous as vol from homeassistant.components import zone as zone_cmp -from homeassistant.components.device_automation import condition as device_condition from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -29,6 +29,7 @@ from homeassistant.const import ( CONF_DEVICE_ID, CONF_ENABLED, CONF_ENTITY_ID, + CONF_FOR, CONF_ID, CONF_MATCH, CONF_STATE, @@ -52,12 +53,13 @@ from homeassistant.exceptions import ( HomeAssistantError, TemplateError, ) +from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.util.async_ import run_callback_threadsafe import homeassistant.util.dt as dt_util from . import config_validation as cv, entity_registry as er from .sun import get_astral_event_date -from .template import Template +from .template import Template, attach as template_attach, render_complex from .trace import ( TraceElement, trace_append_element, @@ -74,12 +76,44 @@ ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config" FROM_CONFIG_FORMAT = "{}_from_config" VALIDATE_CONFIG_FORMAT = "{}_validate_config" -_LOGGER = logging.getLogger(__name__) +_PLATFORM_ALIASES = { + "and": None, + "device": "device_automation", + "not": None, + "numeric_state": None, + "or": None, + "state": None, + "sun": None, + "template": None, + "time": None, + "trigger": None, + "zone": None, +} INPUT_ENTITY_ID = re.compile( r"^input_(?:select|text|number|boolean|datetime)\.(?!.+__)(?!_)[\da-z_]+(? ConfigType: + """Validate config.""" + + def async_condition_from_config( + self, hass: HomeAssistant, config: ConfigType + ) -> ConditionCheckerType: + """Evaluate state based on configuration.""" + + ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool | None] @@ -149,6 +183,27 @@ def trace_condition_function(condition: ConditionCheckerType) -> ConditionChecke return wrapper +async def _async_get_condition_platform( + hass: HomeAssistant, config: ConfigType +) -> ConditionProtocol | None: + platform = config[CONF_CONDITION] + platform = _PLATFORM_ALIASES.get(platform, platform) + if platform is None: + return None + try: + integration = await async_get_integration(hass, platform) + except IntegrationNotFound: + raise HomeAssistantError( + f'Invalid condition "{platform}" specified {config}' + ) from None + try: + return integration.get_platform("condition") + except ImportError: + raise HomeAssistantError( + f"Integration '{platform}' does not provide condition support" + ) from None + + async def async_from_config( hass: HomeAssistant, config: ConfigType, @@ -157,15 +212,18 @@ async def async_from_config( Should be run on the event loop. """ - condition = config.get(CONF_CONDITION) - for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): - factory = getattr(sys.modules[__name__], fmt.format(condition), None) + factory: Any = None + platform = await _async_get_condition_platform(hass, config) - if factory: - break + if platform is None: + condition = config.get(CONF_CONDITION) + for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): + factory = getattr(sys.modules[__name__], fmt.format(condition), None) - if factory is None: - raise HomeAssistantError(f'Invalid condition "{condition}" specified {config}') + if factory: + break + else: + factory = platform.async_condition_from_config # Check if condition is not enabled if not config.get(CONF_ENABLED, True): @@ -481,6 +539,7 @@ def state( req_state: Any, for_period: timedelta | None = None, attribute: str | None = None, + variables: TemplateVarsType = None, ) -> bool: """Test if state matches requirements. @@ -534,7 +593,14 @@ def state( condition_trace_set_result(is_state, state=value, wanted_state=state_value) return is_state - duration = dt_util.utcnow() - for_period + try: + for_period = cv.positive_time_period(render_complex(for_period, variables)) + except TemplateError as ex: + raise ConditionErrorMessage("state", f"template error: {ex}") from ex + except vol.Invalid as ex: + raise ConditionErrorMessage("state", f"schema error: {ex}") from ex + + duration = dt_util.utcnow() - cast(timedelta, for_period) duration_ok = duration > entity.last_changed condition_trace_set_result(duration_ok, state=value, duration=duration) return duration_ok @@ -544,7 +610,7 @@ def state_from_config(config: ConfigType) -> ConditionCheckerType: """Wrap action method with state based condition.""" entity_ids = config.get(CONF_ENTITY_ID, []) req_states: str | list[str] = config.get(CONF_STATE, []) - for_period = config.get("for") + for_period = config.get(CONF_FOR) attribute = config.get(CONF_ATTRIBUTE) match = config.get(CONF_MATCH, ENTITY_MATCH_ALL) @@ -554,12 +620,15 @@ def state_from_config(config: ConfigType) -> ConditionCheckerType: @trace_condition_function def if_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Test if condition.""" + template_attach(hass, for_period) errors = [] result: bool = match != ENTITY_MATCH_ANY for index, entity_id in enumerate(entity_ids): try: with trace_path(["entity_id", str(index)]), trace_condition(variables): - if state(hass, entity_id, req_states, for_period, attribute): + if state( + hass, entity_id, req_states, for_period, attribute, variables + ): result = True elif match == ENTITY_MATCH_ALL: return False @@ -914,14 +983,6 @@ def zone_from_config(config: ConfigType) -> ConditionCheckerType: return if_in_zone -async def async_device_from_config( - hass: HomeAssistant, config: ConfigType -) -> ConditionCheckerType: - """Test a device condition.""" - checker = await device_condition.async_condition_from_config(hass, config) - return trace_condition_function(checker) - - async def async_trigger_from_config( hass: HomeAssistant, config: ConfigType ) -> ConditionCheckerType: @@ -977,10 +1038,10 @@ async def async_validate_condition_config( config["conditions"] = conditions return config - if condition == "device": - return await device_condition.async_validate_condition_config(hass, config) - - if condition in ("numeric_state", "state"): + platform = await _async_get_condition_platform(hass, config) + if platform is not None and hasattr(platform, "async_validate_condition_config"): + return await platform.async_validate_condition_config(hass, config) + if platform is None and condition in ("numeric_state", "state"): validator = cast( Callable[[HomeAssistant, ConfigType], ConfigType], getattr(sys.modules[__name__], VALIDATE_CONFIG_FORMAT.format(condition)), diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 072fed4220..fe4e547309 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -542,7 +542,7 @@ def _encode_jwt(hass: HomeAssistant, data: dict) -> str: @callback -def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict | None: +def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict[str, Any] | None: """JWT encode data.""" secret: str | None = hass.data.get(DATA_JWT_SECRET) @@ -550,6 +550,6 @@ def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict | None: return None try: - return jwt.decode(encoded, secret, algorithms=["HS256"]) + return jwt.decode(encoded, secret, algorithms=["HS256"]) # type: ignore[no-any-return] except jwt.InvalidTokenError: return None diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index c13b703cae..e0924b928c 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -85,7 +85,12 @@ from homeassistant.const import ( WEEKDAYS, UnitOfTemperature, ) -from homeassistant.core import split_entity_id, valid_entity_id +from homeassistant.core import ( + HomeAssistant, + async_get_hass, + split_entity_id, + valid_entity_id, +) from homeassistant.exceptions import TemplateError from homeassistant.generated import currencies from homeassistant.generated.countries import COUNTRIES @@ -386,6 +391,8 @@ def icon(value: Any) -> str: raise vol.Invalid('Icons should be specified in the form "prefix:name"') +_TIME_PERIOD_DICT_KEYS = ("days", "hours", "minutes", "seconds", "milliseconds") + time_period_dict = vol.All( dict, vol.Schema( @@ -397,7 +404,7 @@ time_period_dict = vol.All( "milliseconds": vol.Coerce(float), } ), - has_at_least_one_key("days", "hours", "minutes", "seconds", "milliseconds"), + has_at_least_one_key(*_TIME_PERIOD_DICT_KEYS), lambda value: timedelta(**value), ) @@ -560,6 +567,10 @@ def string(value: Any) -> str: if value is None: raise vol.Invalid("string value is None") + # This is expected to be the most common case, so check it first. + if type(value) is str: # pylint: disable=unidiomatic-typecheck + return value + if isinstance(value, template_helper.ResultWrapper): value = value.render_result @@ -595,7 +606,11 @@ def template(value: Any | None) -> template_helper.Template: if isinstance(value, (list, dict, template_helper.Template)): raise vol.Invalid("template value should be a string") - template_value = template_helper.Template(str(value)) + hass: HomeAssistant | None = None + with contextlib.suppress(LookupError): + hass = async_get_hass() + + template_value = template_helper.Template(str(value), hass) try: template_value.ensure_valid() @@ -613,7 +628,12 @@ def dynamic_template(value: Any | None) -> template_helper.Template: if not template_helper.is_template_string(str(value)): raise vol.Invalid("template value does not contain a dynamic template") - template_value = template_helper.Template(str(value)) + hass: HomeAssistant | None = None + with contextlib.suppress(LookupError): + hass = async_get_hass() + + template_value = template_helper.Template(str(value), hass) + try: template_value.ensure_valid() return template_value @@ -639,8 +659,24 @@ def template_complex(value: Any) -> Any: return value +def _positive_time_period_template_complex(value: Any) -> Any: + """Do basic validation of a positive time period expressed as a templated dict.""" + if not isinstance(value, dict) or not value: + raise vol.Invalid("template should be a dict") + for key, element in value.items(): + if not isinstance(key, str): + raise vol.Invalid("key should be a string") + if not template_helper.is_template_string(key): + vol.In(_TIME_PERIOD_DICT_KEYS)(key) + if not isinstance(element, str) or ( + isinstance(element, str) and not template_helper.is_template_string(element) + ): + vol.All(vol.Coerce(float), vol.Range(min=0))(element) + return template_complex(value) + + positive_time_period_template = vol.Any( - positive_time_period, template, template_complex + positive_time_period, dynamic_template, _positive_time_period_template_complex ) @@ -1166,7 +1202,7 @@ STATE_CONDITION_BASE_SCHEMA = { vol.Lower, vol.Any(ENTITY_MATCH_ALL, ENTITY_MATCH_ANY) ), vol.Optional(CONF_ATTRIBUTE): str, - vol.Optional(CONF_FOR): positive_time_period, + vol.Optional(CONF_FOR): positive_time_period_template, # To support use_trigger_value in automation # Deprecated 2016/04/25 vol.Optional("from"): str, diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 2fbdefd7ec..2df8965de3 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -38,8 +38,13 @@ class Debouncer(Generic[_R_co]): self._execute_at_end_of_timer: bool = False self._execute_lock = asyncio.Lock() self._job: HassJob[[], _R_co] | None = ( - None if function is None else HassJob(function) + None + if function is None + else HassJob( + function, f"debouncer cooldown={cooldown}, immediate={immediate}" + ) ) + self._shutdown_requested = False @property def function(self) -> Callable[[], _R_co] | None: @@ -51,10 +56,18 @@ class Debouncer(Generic[_R_co]): """Update the function being wrapped by the Debouncer.""" self._function = function if self._job is None or function != self._job.target: - self._job = HassJob(function) + self._job = HassJob( + function, + f"debouncer cooldown={self.cooldown}, immediate={self.immediate}", + ) 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: @@ -87,11 +100,6 @@ class Debouncer(Generic[_R_co]): """Handle a finished timer.""" assert self._job is not None - self._timer_task = None - - if not self._execute_at_end_of_timer: - return - self._execute_at_end_of_timer = False # Locked means a call is in progress. Any call is good, so abort. @@ -101,7 +109,7 @@ class Debouncer(Generic[_R_co]): async with self._execute_lock: # Abort if timer got set while we're waiting for the lock. if self._timer_task: - return # type: ignore[unreachable] + return try: task = self.hass.async_run_hass_job(self._job) @@ -110,8 +118,14 @@ class Debouncer(Generic[_R_co]): except Exception: # pylint: disable=broad-except self.logger.exception("Unexpected exception from %s", self.function) + # 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.""" @@ -121,10 +135,20 @@ class Debouncer(Generic[_R_co]): self._execute_at_end_of_timer = False + @callback + def _on_debounce(self) -> None: + """Create job task, but only if pending.""" + self._timer_task = None + if self._execute_at_end_of_timer: + self.hass.async_create_task( + self._handle_timer_finish(), + f"debouncer {self._job} finish cooldown={self.cooldown}, immediate={self.immediate}", + ) + @callback def _schedule_timer(self) -> None: """Schedule a timer.""" - self._timer_task = self.hass.loop.call_later( - self.cooldown, - lambda: self.hass.async_create_task(self._handle_timer_finish()), - ) + 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 9ea44db16d..29e6463972 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -10,10 +10,9 @@ from typing import TYPE_CHECKING, Any, TypeVar, cast import attr from homeassistant.backports.enum import StrEnum -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +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.""" @@ -907,6 +893,13 @@ def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, startup_clean) + @callback + def _on_homeassistant_stop(event: Event) -> None: + """Cancel debounced cleanup.""" + debounced_cleanup.async_cancel() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_homeassistant_stop) + def _normalize_connections(connections: set[tuple[str, str]]) -> set[tuple[str, str]]: """Normalize connections to ensure we can match mac addresses.""" diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 375c3b09c2..7045966c52 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -44,18 +44,17 @@ def async_listen( Service can be a string or a list/tuple. """ - job = core.HassJob(callback) + 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 @@ -103,19 +105,19 @@ def async_listen_platform( This method must be run in the event loop. """ service = EVENT_LOAD_PLATFORM.format(component) - job = core.HassJob(callback) + 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 2bfccf4696..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 @@ -29,7 +29,7 @@ def async_create_flow( if not dispatcher or dispatcher.started: if init_coro := _async_init_flow(hass, domain, context, data): - hass.async_create_task(init_coro) + hass.async_create_task(init_coro, f"discovery flow {domain} {context}") return return dispatcher.async_create(domain, context, data) @@ -44,7 +44,9 @@ def _async_init_flow( # as ones in progress as it may cause additional device probing # which can overload devices since zeroconf/ssdp updates can happen # multiple times in the same minute - if hass.config_entries.flow.async_has_matching_flow(domain, context, data): + if hass.is_stopping or hass.config_entries.flow.async_has_matching_flow( + domain, context, data + ): return None return hass.config_entries.flow.async_init(domain, context=context, data=data) diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index c7ad4fb1ad..60aab15614 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -75,7 +75,8 @@ def _generate_job( signal, args, ), - ) + ), + f"dispatcher {signal}", ) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index c4dfd7e9c5..3cc655a7fd 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -52,6 +52,16 @@ DATA_ENTITY_SOURCE = "entity_info" SOURCE_CONFIG_ENTRY = "config_entry" SOURCE_PLATFORM_CONFIG = "platform_config" + +class DeviceClassName(Enum): + """Singleton to use device class name.""" + + _singleton = 0 + + +DEVICE_CLASS_NAME = DeviceClassName._singleton # pylint: disable=protected-access + + # Used when converting float states to string: limit precision according to machine # epsilon to make the string representation readable FLOAT_PRECISION = abs(int(math.floor(math.log10(abs(sys.float_info.epsilon))))) - 1 @@ -205,7 +215,7 @@ class EntityPlatformState(Enum): REMOVED = auto() -@dataclass +@dataclass(slots=True) class EntityDescription: """A class that describes Home Assistant entities.""" @@ -219,7 +229,7 @@ class EntityDescription: force_update: bool = False icon: str | None = None has_entity_name: bool = False - name: str | None = None + name: str | DeviceClassName | None = None translation_key: str | None = None unit_of_measurement: str | None = None @@ -249,6 +259,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 @@ -284,7 +298,7 @@ class Entity(ABC): _attr_extra_state_attributes: MutableMapping[str, Any] _attr_force_update: bool _attr_icon: str | None - _attr_name: str | None + _attr_name: str | DeviceClassName | None _attr_should_poll: bool = True _attr_state: StateType = STATE_UNKNOWN _attr_supported_features: int | None = None @@ -314,12 +328,37 @@ class Entity(ABC): return self.entity_description.has_entity_name return False + def _device_class_name(self) -> str | None: + """Return a translated name of the entity based on its device class.""" + assert self.platform + if not self.has_entity_name: + return None + device_class_key = self.device_class or "_" + name_translation_key = ( + f"component.{self.platform.domain}.entity_component." + f"{device_class_key}.name" + ) + return self.platform.component_translations.get(name_translation_key) + @property def name(self) -> str | None: """Return the name of the entity.""" if hasattr(self, "_attr_name"): + if self._attr_name is DEVICE_CLASS_NAME: + return self._device_class_name() return self._attr_name + if self.translation_key is not None and self.has_entity_name: + assert self.platform + name_translation_key = ( + f"component.{self.platform.platform_name}.entity.{self.platform.domain}" + f".{self.translation_key}.name" + ) + if name_translation_key in self.platform.platform_translations: + name: str = self.platform.platform_translations[name_translation_key] + return name if hasattr(self, "entity_description"): + if self.entity_description.name is DEVICE_CLASS_NAME: + return self._device_class_name() return self.entity_description.name return None @@ -542,6 +581,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() @@ -570,6 +622,25 @@ class Entity(ABC): return f"{state:.{FLOAT_PRECISION}}" return str(state) + def _friendly_name_internal(self) -> str | None: + """Return the friendly name. + + If has_entity_name is False, this returns self.name + If has_entity_name is True, this returns device.name + self.name + """ + if not self.has_entity_name or not self.registry_entry: + return self.name + + device_registry = dr.async_get(self.hass) + if not (device_id := self.registry_entry.device_id) or not ( + device_entry := device_registry.async_get(device_id) + ): + return self.name + + if not (name := self.name): + return device_entry.name_by_user or device_entry.name + return f"{device_entry.name_by_user or device_entry.name} {name}" + @callback def _async_write_ha_state(self) -> None: """Write the state to the state machine.""" @@ -577,7 +648,11 @@ class Entity(ABC): # Polling returned after the entity has already been removed return - if self.registry_entry and self.registry_entry.disabled_by: + hass = self.hass + entity_id = self.entity_id + entry = self.registry_entry + + if entry and entry.disabled_by: if not self._disabled_reported: self._disabled_reported = True assert self.platform is not None @@ -586,7 +661,7 @@ class Entity(ABC): "Entity %s is incorrectly being triggered for updates while it" " is disabled. This is a bug in the %s integration" ), - self.entity_id, + entity_id, self.platform.platform_name, ) return @@ -605,8 +680,6 @@ class Entity(ABC): if (unit_of_measurement := self.unit_of_measurement) is not None: attr[ATTR_UNIT_OF_MEASUREMENT] = unit_of_measurement - entry = self.registry_entry - if assumed_state := self.assumed_state: attr[ATTR_ASSUMED_STATE] = assumed_state @@ -624,26 +697,9 @@ class Entity(ABC): if (icon := (entry and entry.icon) or self.icon) is not None: attr[ATTR_ICON] = icon - def friendly_name() -> str | None: - """Return the friendly name. - - If has_entity_name is False, this returns self.name - If has_entity_name is True, this returns device.name + self.name - """ - if not self.has_entity_name or not self.registry_entry: - return self.name - - device_registry = dr.async_get(self.hass) - if not (device_id := self.registry_entry.device_id) or not ( - device_entry := device_registry.async_get(device_id) - ): - return self.name - - if not self.name: - return device_entry.name_by_user or device_entry.name - return f"{device_entry.name_by_user or device_entry.name} {self.name}" - - if (name := (entry and entry.name) or friendly_name()) is not None: + if ( + name := (entry and entry.name) or self._friendly_name_internal() + ) is not None: attr[ATTR_FRIENDLY_NAME] = name if (supported_features := self.supported_features) is not None: @@ -656,15 +712,15 @@ class Entity(ABC): report_issue = self._suggest_report_issue() _LOGGER.warning( "Updating state for %s (%s) took %.3f seconds. Please %s", - self.entity_id, + entity_id, type(self), end - start, report_issue, ) # Overwrite properties that have been set in the config file. - if DATA_CUSTOMIZE in self.hass.data: - attr.update(self.hass.data[DATA_CUSTOMIZE].get(self.entity_id)) + if customize := hass.data.get(DATA_CUSTOMIZE): + attr.update(customize.get(entity_id)) if ( self._context_set is not None @@ -673,9 +729,7 @@ class Entity(ABC): self._context = None self._context_set = None - self.hass.states.async_set( - self.entity_id, state, attr, self.force_update, self._context - ) + hass.states.async_set(entity_id, state, attr, self.force_update, self._context) def schedule_update_ha_state(self, force_refresh: bool = False) -> None: """Schedule an update ha state change task. @@ -687,7 +741,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: @@ -702,10 +762,22 @@ class Entity(ABC): been executed, the intermediate state transitions will be missed. """ if force_refresh: - self.hass.async_create_task(self.async_update_ha_state(force_refresh)) + self.hass.async_create_task( + self.async_update_ha_state(force_refresh), + f"Entity schedule update ha state {self.entity_id}", + ) 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. @@ -713,40 +785,32 @@ class Entity(ABC): """ if self._update_staged: return + + hass = self.hass + assert hass is not None + self._update_staged = True # Process update sequential if self.parallel_updates: await self.parallel_updates.acquire() + if warning: + update_warn = hass.loop.call_later( + SLOW_UPDATE_WARNING, self._async_slow_update_warning + ) + try: - task: asyncio.Future[None] if hasattr(self, "async_update"): - task = self.hass.async_create_task(self.async_update()) + await self.async_update() elif hasattr(self, "update"): - task = self.hass.async_add_executor_job(self.update) + await 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, - ) - await task finally: self._update_staged = False + if warning: + update_warn.cancel() if self.parallel_updates: self.parallel_updates.release() @@ -929,25 +993,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"" @@ -981,7 +1026,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 874c37ffd9..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,14 +134,17 @@ 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 # Look in config for Domain, Domain 2, Domain 3 etc and load them for p_type, p_config in config_per_platform(config, self.domain): if p_type is not None: - self.hass.async_create_task(self.async_setup_platform(p_type, p_config)) + self.hass.async_create_task( + self.async_setup_platform(p_type, p_config), + f"EntityComponent setup platform {p_type} {self.domain}", + ) # Generic discovery listener for loading platform dynamically # Refer to: homeassistant.helpers.discovery.async_load_platform() diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index c002915a4d..4a2ff020fe 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -39,6 +39,7 @@ from . import ( device_registry as dev_reg, entity_registry as ent_reg, service, + translation, ) from .device_registry import DeviceRegistry from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider @@ -124,6 +125,8 @@ class EntityPlatform: self.entity_namespace = entity_namespace self.config_entry: config_entries.ConfigEntry | None = None self.entities: dict[str, Entity] = {} + self.component_translations: dict[str, Any] = {} + self.platform_translations: dict[str, Any] = {} self._tasks: list[asyncio.Task[None]] = [] # Stop tracking tasks after setup is completed self._setup_complete = False @@ -276,6 +279,23 @@ class EntityPlatform: hass = self.hass full_name = f"{self.domain}.{self.platform_name}" + try: + self.component_translations = await translation.async_get_translations( + hass, hass.config.language, "entity_component", {self.domain} + ) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.debug( + "Could not load translations for %s", self.domain, exc_info=err + ) + try: + self.platform_translations = await translation.async_get_translations( + hass, hass.config.language, "entity", {self.platform_name} + ) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.debug( + "Could not load translations for %s", self.platform_name, exc_info=err + ) + logger.info("Setting up %s", full_name) warn_task = hass.loop.call_later( SLOW_SETUP_WARNING, @@ -375,6 +395,7 @@ class EntityPlatform: """Schedule adding entities for a single platform async.""" task = self.hass.async_create_task( self.async_add_entities(new_entities, update_before_add=update_before_add), + f"EntityPlatform async_add_entities {self.domain}.{self.platform_name}", ) if not self._setup_complete: @@ -389,6 +410,7 @@ class EntityPlatform: task = self.config_entry.async_create_task( self.hass, self.async_add_entities(new_entities, update_before_add=update_before_add), + f"EntityPlatform async_add_entities_for_entry {self.domain}.{self.platform_name}", ) if not self._setup_complete: @@ -466,6 +488,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]: @@ -599,10 +622,12 @@ class EntityPlatform: except RequiredParameterMissing: pass - if entity.entity_id is not None: + # An entity may suggest the entity_id by setting entity_id itself + suggested_entity_id: str | None = entity.entity_id + if suggested_entity_id is not None: suggested_object_id = split_entity_id(entity.entity_id)[1] else: - if device and entity.has_entity_name: # type: ignore[unreachable] + if device and entity.has_entity_name: device_name = device.name_by_user or device.name if not entity.name: suggested_object_id = device_name diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index f779fe00ed..d8c5a6c1cf 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 @@ -65,6 +64,13 @@ STORAGE_VERSION_MAJOR = 1 STORAGE_VERSION_MINOR = 10 STORAGE_KEY = "core.entity_registry" +ENTITY_CATEGORY_VALUE_TO_INDEX: dict[EntityCategory | None, int] = { + # mypy does not understand strenum + val: idx # type: ignore[misc] + for idx, val in enumerate(EntityCategory) +} +ENTITY_CATEGORY_INDEX_TO_VALUE = dict(enumerate(EntityCategory)) + # Attributes relevant to describing entity # to external services. ENTITY_DESCRIBING_ATTRIBUTES = { @@ -97,6 +103,35 @@ class RegistryEntryHider(StrEnum): EntityOptionsType = Mapping[str, Mapping[str, Any]] +DISLAY_DICT_OPTIONAL = ( + ("ai", "area_id"), + ("di", "device_id"), + ("tk", "translation_key"), +) + + +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: @@ -119,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) @@ -131,7 +163,12 @@ class RegistryEntry: translation_key: str | None = attr.ib(default=None) unit_of_measurement: str | None = attr.ib(default=None) - _json_repr: str | None = attr.ib(cmp=False, default=None, init=False, repr=False) + _partial_repr: str | None | UndefinedType = attr.ib( + cmp=False, default=UNDEFINED, init=False, repr=False + ) + _display_repr: str | None | UndefinedType = attr.ib( + cmp=False, default=UNDEFINED, init=False, repr=False + ) @domain.default def _domain_default(self) -> str: @@ -148,6 +185,57 @@ class RegistryEntry: """Return if entry is hidden.""" return self.hidden_by is not None + @property + def _as_display_dict(self) -> dict[str, Any] | None: + """Return a partial dict representation of the entry. + + This version only includes what's needed for display. + Returns None if there's no data needed for display. + """ + display_dict: dict[str, Any] = {"ei": self.entity_id, "pl": self.platform} + for key, attr_name in DISLAY_DICT_OPTIONAL: + if (attr_val := getattr(self, attr_name)) is not None: + display_dict[key] = attr_val + if (category := self.entity_category) is not None: + display_dict["ec"] = ENTITY_CATEGORY_VALUE_TO_INDEX[category] + if self.hidden_by is not None: + display_dict["hb"] = True + if not self.name and self.has_entity_name: + display_dict["en"] = self.original_name + if self.domain == "sensor" and (sensor_options := self.options.get("sensor")): + if (precision := sensor_options.get("display_precision")) is not None: + display_dict["dp"] = precision + elif ( + precision := sensor_options.get("suggested_display_precision") + ) is not None: + display_dict["dp"] = precision + return display_dict + + @property + def display_json_repr(self) -> str | None: + """Return a cached partial JSON representation of the entry. + + This version only includes what's needed for display. + """ + if self._display_repr is not UNDEFINED: + return self._display_repr + + try: + dict_repr = self._as_display_dict + json_repr: str | None = JSON_DUMP(dict_repr) if dict_repr else None + object.__setattr__(self, "_display_repr", json_repr) + except (ValueError, TypeError): + object.__setattr__(self, "_display_repr", None) + _LOGGER.error( + "Unable to serialize entry %s to JSON. Bad data found at %s", + self.entity_id, + format_unserializable_data( + find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) + ), + ) + # Mypy doesn't understand the __setattr__ business + return self._display_repr # type: ignore[return-value] + @property def as_partial_dict(self) -> dict[str, Any]: """Return a partial dict representation of the entry.""" @@ -173,13 +261,14 @@ class RegistryEntry: @property def partial_json_repr(self) -> str | None: """Return a cached partial JSON representation of the entry.""" - if self._json_repr is not None: - return self._json_repr + if self._partial_repr is not UNDEFINED: + return self._partial_repr try: dict_repr = self.as_partial_dict - object.__setattr__(self, "_json_repr", JSON_DUMP(dict_repr)) + object.__setattr__(self, "_partial_repr", JSON_DUMP(dict_repr)) except (ValueError, TypeError): + object.__setattr__(self, "_partial_repr", None) _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", self.entity_id, @@ -187,7 +276,8 @@ class RegistryEntry: find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) ), ) - return self._json_repr + # Mypy doesn't understand the __setattr__ business + return self._partial_repr # type: ignore[return-value] @callback def write_unavailable_state(self, hass: HomeAssistant) -> None: @@ -859,7 +949,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: @@ -939,7 +1029,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, @@ -985,19 +1075,6 @@ async def async_load(hass: HomeAssistant) -> None: await hass.data[DATA_REGISTRY].async_load() -@bind_hass -async def async_get_registry(hass: HomeAssistant) -> EntityRegistry: - """Get entity registry. - - This is deprecated and will be removed in the future. Use async_get instead. - """ - report( - "uses deprecated `async_get_registry` to access entity registry, use async_get" - " instead" - ) - return async_get(hass) - - @callback def async_entries_for_device( registry: EntityRegistry, device_id: str, include_disabled_entities: bool = False diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index 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 7490206f03..38d811c2af 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. @@ -176,7 +176,7 @@ def async_track_state_change( else: entity_ids = tuple(entity_id.lower() for entity_id in entity_ids) - job = HassJob(action) + job = HassJob(action, f"track state change {entity_ids} {from_state} {to_state}") @callback def state_change_filter(event: Event) -> bool: @@ -296,7 +296,7 @@ def _async_track_state_change_event( event_filter=_async_state_change_filter, ) - job = HassJob(action) + job = HassJob(action, f"track state change event {entity_ids}") for entity_id in entity_ids: entity_callbacks.setdefault(entity_id, []).append(job) @@ -393,7 +393,7 @@ def async_track_entity_registry_updated_event( event_filter=_async_entity_registry_updated_filter, ) - job = HassJob(action) + job = HassJob(action, f"track entity registry updated event {entity_ids}") for entity_id in entity_ids: entity_callbacks.setdefault(entity_id, []).append(job) @@ -412,18 +412,21 @@ def async_track_entity_registry_updated_event( return remove_listener +@callback +def _async_domain_has_listeners( + domain: str, callbacks: dict[str, list[HassJob[[Event], Any]]] +) -> bool: + """Check if the domain has any listeners.""" + return domain in callbacks or MATCH_ALL in callbacks + + @callback def _async_dispatch_domain_event( hass: HomeAssistant, event: Event, callbacks: dict[str, list[HassJob[[Event], Any]]] ) -> None: + """Dispatch domain event listeners.""" domain = split_entity_id(event.data["entity_id"])[0] - - if domain not in callbacks and MATCH_ALL not in callbacks: - return - - listeners = callbacks.get(domain, []) + callbacks.get(MATCH_ALL, []) - - for job in listeners: + for job in callbacks.get(domain, []) + callbacks.get(MATCH_ALL, []): try: hass.async_run_hass_job(job, event) except Exception: # pylint: disable=broad-except @@ -460,14 +463,13 @@ def _async_track_state_added_domain( @callback def _async_state_change_filter(event: Event) -> bool: """Filter state changes by entity_id.""" - return event.data.get("old_state") is None + return event.data.get("old_state") is None and _async_domain_has_listeners( + split_entity_id(event.data["entity_id"])[0], domain_callbacks + ) @callback def _async_state_change_dispatcher(event: Event) -> None: """Dispatch state changes by entity_id.""" - if event.data.get("old_state") is not None: - return - _async_dispatch_domain_event(hass, event, domain_callbacks) hass.data[TRACK_STATE_ADDED_DOMAIN_LISTENER] = hass.bus.async_listen( @@ -476,7 +478,7 @@ def _async_track_state_added_domain( event_filter=_async_state_change_filter, ) - job = HassJob(action) + job = HassJob(action, f"track state added domain event {domains}") for domain in domains: domain_callbacks.setdefault(domain, []).append(job) @@ -514,14 +516,13 @@ def async_track_state_removed_domain( @callback def _async_state_change_filter(event: Event) -> bool: """Filter state changes by entity_id.""" - return event.data.get("new_state") is None + return event.data.get("new_state") is None and _async_domain_has_listeners( + split_entity_id(event.data["entity_id"])[0], domain_callbacks + ) @callback def _async_state_change_dispatcher(event: Event) -> None: """Dispatch state changes by entity_id.""" - if event.data.get("new_state") is not None: - return - _async_dispatch_domain_event(hass, event, domain_callbacks) hass.data[TRACK_STATE_REMOVED_DOMAIN_LISTENER] = hass.bus.async_listen( @@ -530,7 +531,7 @@ def async_track_state_removed_domain( event_filter=_async_state_change_filter, ) - job = HassJob(action) + job = HassJob(action, f"track state removed domain event {domains}") for domain in domains: domain_callbacks.setdefault(domain, []).append(job) @@ -569,7 +570,9 @@ class _TrackStateChangeFiltered: """Handle removal / refresh of tracker init.""" self.hass = hass self._action = action - self._action_as_hassjob = HassJob(action) + self._action_as_hassjob = HassJob( + action, f"track state change filtered {track_states}" + ) self._listeners: dict[str, Callable[[], None]] = {} self._last_track_states: TrackStates = track_states @@ -764,7 +767,7 @@ def async_track_template( Callable to unregister the listener. """ - job = HassJob(action) + job = HassJob(action, f"track template {template}") @callback def _template_changed_listener( @@ -821,7 +824,7 @@ class TrackTemplateResultInfo: ) -> None: """Handle removal / refresh of tracker init.""" self.hass = hass - self._job = HassJob(action) + self._job = HassJob(action, f"track template result {track_templates}") for track_template_ in track_templates: track_template_.template.hass = hass @@ -835,6 +838,10 @@ class TrackTemplateResultInfo: self._track_state_changes: _TrackStateChangeFiltered | None = None self._time_listeners: dict[Template, Callable[[], None]] = {} + def __repr__(self) -> str: + """Return the representation.""" + return f"" + def async_setup(self, raise_on_template_error: bool, strict: bool = False) -> None: """Activation of template tracking.""" block_render = False @@ -1215,7 +1222,7 @@ def async_track_same_state( async_remove_state_for_cancel: CALLBACK_TYPE | None = None async_remove_state_for_listener: CALLBACK_TYPE | None = None - job = HassJob(action) + job = HassJob(action, f"track same state {period} {entity_ids}") @callback def clear_listener() -> None: @@ -1277,14 +1284,23 @@ def async_track_point_in_time( point_in_time: datetime, ) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in time.""" - job = action if isinstance(action, HassJob) else HassJob(action) + job = ( + action + if isinstance(action, HassJob) + else HassJob(action, f"track point in time {point_in_time}") + ) @callback def utc_converter(utc_now: datetime) -> None: """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) @@ -1324,7 +1340,11 @@ def async_track_point_in_utc_time( hass.async_run_hass_job(job, utc_point_in_time) - job = action if isinstance(action, HassJob) else HassJob(action) + job = ( + action + if isinstance(action, HassJob) + else HassJob(action, f"track point in utc time {utc_point_in_time}") + ) delta = expected_fire_timestamp - time.time() cancel_callback = hass.loop.call_later(delta, run_action, job) @@ -1357,7 +1377,11 @@ def async_call_later( """Call the action.""" hass.async_run_hass_job(job, time_tracker_utcnow()) - job = action if isinstance(action, HassJob) else HassJob(action) + job = ( + action + if isinstance(action, HassJob) + else HassJob(action, f"call_later {delay}") + ) cancel_callback = hass.loop.call_later(delay, run_action, job) @callback @@ -1378,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) + job = HassJob( + action, f"track time interval {interval}", cancel_on_shutdown=cancel_on_shutdown + ) def next_interval() -> datetime: """Return the next interval.""" @@ -1400,7 +1429,14 @@ def async_track_time_interval( ) hass.async_run_hass_job(job, now) - interval_listener_job = HassJob(interval_listener) + 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, job_name, cancel_on_shutdown=cancel_on_shutdown + ) remove = async_track_point_in_utc_time(hass, interval_listener_job, next_interval()) def remove_listener() -> None: @@ -1479,7 +1515,9 @@ def async_track_sunrise( hass: HomeAssistant, action: Callable[[], None], offset: timedelta | None = None ) -> CALLBACK_TYPE: """Add a listener that will fire a specified offset from sunrise daily.""" - listener = SunListener(hass, HassJob(action), SUN_EVENT_SUNRISE, offset) + listener = SunListener( + hass, HassJob(action, "track sunrise"), SUN_EVENT_SUNRISE, offset + ) listener.async_attach() return listener.async_detach @@ -1493,7 +1531,9 @@ def async_track_sunset( hass: HomeAssistant, action: Callable[[], None], offset: timedelta | None = None ) -> CALLBACK_TYPE: """Add a listener that will fire a specified offset from sunset daily.""" - listener = SunListener(hass, HassJob(action), SUN_EVENT_SUNSET, offset) + listener = SunListener( + hass, HassJob(action, "track sunset"), SUN_EVENT_SUNSET, offset + ) listener.async_attach() return listener.async_detach @@ -1518,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 @@ -1526,7 +1566,7 @@ def async_track_utc_time_change( # misalignment we use async_track_time_interval here return async_track_time_interval(hass, action, timedelta(seconds=1)) - job = HassJob(action) + job = HassJob(action, f"track time change {hour}:{minute}:{second} local={local}") matching_seconds = dt_util.parse_time_expression(second, 0, 59) matching_minutes = dt_util.parse_time_expression(minute, 0, 59) matching_hours = dt_util.parse_time_expression(hour, 0, 23) @@ -1543,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, + f"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 @@ -1630,12 +1677,6 @@ def _render_infos_needs_all_listener(render_infos: Iterable[RenderInfo]) -> bool if render_info.all_states or render_info.all_states_lifecycle: return True - # Previous call had an exception - # so we do not know which states - # to track - if render_info.exception: - return True - return False diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index e02759b09f..beb084d8c1 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -11,6 +11,11 @@ from typing_extensions import Self from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.loader import bind_hass +from homeassistant.util.ssl import ( + SSLCipherList, + client_context, + create_no_verify_ssl_context, +) from .frame import warn_use @@ -55,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. @@ -64,15 +70,20 @@ 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=verify_ssl, + verify=ssl_context, headers={USER_AGENT: SERVER_SOFTWARE}, **kwargs, ) original_aclose = client.aclose - client.aclose = warn_use( # type: ignore[assignment] + client.aclose = warn_use( # type: ignore[method-assign] client.aclose, "closes the Home Assistant httpx client" ) diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 9255824cdd..ddaede4496 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -8,15 +8,15 @@ import logging from typing import Any from homeassistant.const import EVENT_COMPONENT_LOADED -from homeassistant.core import Event, HomeAssistant -from homeassistant.loader import async_get_integration, bind_hass +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.loader import Integration, async_get_integrations, bind_hass from homeassistant.setup import ATTR_COMPONENT _LOGGER = logging.getLogger(__name__) DATA_INTEGRATION_PLATFORMS = "integration_platforms" -@dataclass(frozen=True) +@dataclass(slots=True, frozen=True) class IntegrationPlatform: """An integration platform.""" @@ -26,14 +26,24 @@ class IntegrationPlatform: async def _async_process_single_integration_platform_component( - hass: HomeAssistant, component_name: str, integration_platform: IntegrationPlatform + hass: HomeAssistant, + component_name: str, + integration: Integration | Exception, + integration_platform: IntegrationPlatform, ) -> None: """Process a single integration platform.""" if component_name in integration_platform.seen_components: return integration_platform.seen_components.add(component_name) - integration = await async_get_integration(hass, component_name) + if isinstance(integration, Exception): + _LOGGER.exception( + "Error importing integration %s for %s", + component_name, + integration_platform.platform_name, + ) + return + platform_name = integration_platform.platform_name try: @@ -75,14 +85,22 @@ async def async_process_integration_platform_for_component( integration_platforms: list[IntegrationPlatform] = hass.data[ DATA_INTEGRATION_PLATFORMS ] - await asyncio.gather( - *[ + integrations = await async_get_integrations(hass, (component_name,)) + tasks = [ + asyncio.create_task( _async_process_single_integration_platform_component( - hass, component_name, integration_platform - ) - for integration_platform in integration_platforms - ] - ) + hass, + component_name, + integrations[component_name], + integration_platform, + ), + name=f"process integration platform {integration_platform.platform_name} for {component_name}", + ) + for integration_platform in integration_platforms + if component_name not in integration_platform.seen_components + ] + if tasks: + await asyncio.gather(*tasks) @bind_hass @@ -98,25 +116,39 @@ async def async_process_integration_platforms( async def _async_component_loaded(event: Event) -> None: """Handle a new component loaded.""" - comp = event.data[ATTR_COMPONENT] - if "." not in comp: - await async_process_integration_platform_for_component(hass, comp) + await async_process_integration_platform_for_component( + hass, event.data[ATTR_COMPONENT] + ) - hass.bus.async_listen(EVENT_COMPONENT_LOADED, _async_component_loaded) + @callback + def _async_component_loaded_filter(event: Event) -> bool: + """Handle integration platforms loaded.""" + return "." not in event.data[ATTR_COMPONENT] + + hass.bus.async_listen( + EVENT_COMPONENT_LOADED, + _async_component_loaded, + event_filter=_async_component_loaded_filter, + ) integration_platforms: list[IntegrationPlatform] = hass.data[ DATA_INTEGRATION_PLATFORMS ] integration_platform = IntegrationPlatform(platform_name, process_platform, set()) integration_platforms.append(integration_platform) - if top_level_components := ( + if top_level_components := [ comp for comp in hass.config.components if "." not in comp - ): - await asyncio.gather( - *[ + ]: + integrations = await async_get_integrations(hass, top_level_components) + tasks = [ + asyncio.create_task( _async_process_single_integration_platform_component( - hass, comp, integration_platform - ) - for comp in top_level_components - ] - ) + hass, comp, integrations[comp], integration_platform + ), + name=f"process integration platform {platform_name} for {comp}", + ) + for comp in top_level_components + if comp not in integration_platform.seen_components + ] + if tasks: + await asyncio.gather(*tasks) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 4e7dcc5a5a..8b07c2adc9 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -11,6 +11,7 @@ from typing import Any, TypeVar import voluptuous as vol +from homeassistant.components.homeassistant.exposed_entities import async_should_expose from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -65,6 +66,7 @@ async def async_handle( text_input: str | None = None, context: Context | None = None, language: str | None = None, + assistant: str | None = None, ) -> IntentResponse: """Handle an intent.""" handler: IntentHandler = hass.data.get(DATA_KEY, {}).get(intent_type) @@ -79,7 +81,14 @@ async def async_handle( language = hass.config.language intent = Intent( - hass, platform, intent_type, slots or {}, text_input, context, language + hass, + platform=platform, + intent_type=intent_type, + slots=slots or {}, + text_input=text_input, + context=context, + language=language, + assistant=assistant, ) try: @@ -208,6 +217,7 @@ def async_match_states( entities: entity_registry.EntityRegistry | None = None, areas: area_registry.AreaRegistry | None = None, devices: device_registry.DeviceRegistry | None = None, + assistant: str | None = None, ) -> Iterable[State]: """Find states that match the constraints.""" if states is None: @@ -258,6 +268,14 @@ def async_match_states( states_and_entities = list(_filter_by_area(states_and_entities, area, devices)) + if assistant is not None: + # Filter by exposure + states_and_entities = [ + (state, entity) + for state, entity in states_and_entities + if async_should_expose(hass, assistant, state.entity_id) + ] + if name is not None: if devices is None: devices = device_registry.async_get(hass) @@ -387,6 +405,7 @@ class ServiceIntentHandler(IntentHandler): area=area, domains=domains, device_classes=device_classes, + assistant=intent_obj.assistant, ) ) @@ -496,6 +515,7 @@ class Intent: "context", "language", "category", + "assistant", ] def __init__( @@ -508,6 +528,7 @@ class Intent: context: Context, language: str, category: IntentCategory | None = None, + assistant: str | None = None, ) -> None: """Initialize an intent.""" self.hass = hass @@ -518,6 +539,7 @@ class Intent: self.context = context self.language = language self.category = category + self.assistant = assistant @callback def create_response(self) -> IntentResponse: @@ -568,7 +590,7 @@ class IntentResponseTargetType(str, Enum): CUSTOM = "custom" -@dataclass +@dataclass(slots=True) class IntentResponseTarget: """Target of the intent response.""" diff --git a/homeassistant/helpers/issue_registry.py b/homeassistant/helpers/issue_registry.py index 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/json.py b/homeassistant/helpers/json.py index 38afa37838..5f8a689412 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -9,7 +9,6 @@ from typing import Any, Final import orjson -from homeassistant.core import Event, State from homeassistant.util.file import write_utf8_file, write_utf8_file_atomic from homeassistant.util.json import ( # pylint: disable=unused-import # noqa: F401 JSON_DECODE_EXCEPTIONS, @@ -83,38 +82,28 @@ def json_bytes(data: Any) -> bytes: ) +def _strip_null(obj: Any) -> Any: + """Strip NUL from an object.""" + if isinstance(obj, str): + return obj.split("\0", 1)[0] + if isinstance(obj, dict): + return {key: _strip_null(o) for key, o in obj.items()} + if isinstance(obj, list): + return [_strip_null(o) for o in obj] + return obj + + def json_bytes_strip_null(data: Any) -> bytes: """Dump json bytes after terminating strings at the first NUL.""" - - def process_dict(_dict: dict[Any, Any]) -> dict[Any, Any]: - """Strip NUL from items in a dict.""" - return {key: strip_null(o) for key, o in _dict.items()} - - def process_list(_list: list[Any]) -> list[Any]: - """Strip NUL from items in a list.""" - return [strip_null(o) for o in _list] - - def strip_null(obj: Any) -> Any: - """Strip NUL from an object.""" - if isinstance(obj, str): - return obj.split("\0", 1)[0] - if isinstance(obj, dict): - return process_dict(obj) - if isinstance(obj, list): - return process_list(obj) - return obj - # We expect null-characters to be very rare, hence try encoding first and look # for an escaped null-character in the output. result = json_bytes(data) - if b"\\u0000" in result: - # We work on the processed result so we don't need to worry about - # Home Assistant extensions which allows encoding sets, tuples, etc. - data_processed = orjson.loads(result) - data_processed = strip_null(data_processed) - result = json_bytes(data_processed) + if b"\\u0000" not in result: + return result - return result + # We work on the processed result so we don't need to worry about + # Home Assistant extensions which allows encoding sets, tuples, etc. + return json_bytes(_strip_null(orjson.loads(result))) def json_dumps(data: Any) -> str: @@ -199,6 +188,11 @@ def find_paths_unserializable_data( This method is slow! Only use for error handling. """ + from homeassistant.core import ( # pylint: disable=import-outside-toplevel + Event, + State, + ) + to_process = deque([(bad_data, "$")]) invalid = {} 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 d7e30661b3..d31c12d0fd 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -212,11 +212,14 @@ class RestoreStateData: # Dump the initial states now. This helps minimize the risk of having # old states loaded by overwriting the last states once Home Assistant # has started and the old states have been read. - self.hass.async_create_task(_async_dump_states()) + self.hass.async_create_task(_async_dump_states(), "RestoreStateData dump") # 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 9f76a639e0..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.""" @@ -275,7 +275,7 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): ) # Create an async_get_options_flow method - cls.async_get_options_flow = _async_get_options_flow # type: ignore[assignment] + cls.async_get_options_flow = _async_get_options_flow # type: ignore[method-assign] # Create flow step methods for each step defined in the flow schema for step in cls.config_flow: diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 02fa9dc780..9ba4e7a9d8 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -66,6 +66,7 @@ from homeassistant.const import ( from homeassistant.core import ( SERVICE_CALL_LIMIT, Context, + Event, HassJob, HomeAssistant, callback, @@ -1074,7 +1075,17 @@ class _QueuedScriptRun(_ScriptRun): super()._finish() -async def _async_stop_scripts_after_shutdown(hass, point_in_time): +@callback +def _schedule_stop_scripts_after_shutdown(hass: HomeAssistant) -> None: + """Stop running Script objects started after shutdown.""" + async_call_later( + hass, _SHUTDOWN_MAX_WAIT, partial(_async_stop_scripts_after_shutdown, hass) + ) + + +async def _async_stop_scripts_after_shutdown( + hass: HomeAssistant, point_in_time: datetime +) -> None: """Stop running Script objects started after shutdown.""" hass.data[DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED] = None running_scripts = [ @@ -1091,11 +1102,9 @@ async def _async_stop_scripts_after_shutdown(hass, point_in_time): ) -async def _async_stop_scripts_at_shutdown(hass, event): +async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) -> None: """Stop running Script objects started before shutdown.""" - async_call_later( - hass, _SHUTDOWN_MAX_WAIT, partial(_async_stop_scripts_after_shutdown, hass) - ) + _schedule_stop_scripts_after_shutdown(hass) running_scripts = [ script diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 0ba5ee363e..fec9d25563 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -2,6 +2,8 @@ from __future__ import annotations from collections.abc import Callable, Mapping, Sequence +from enum import IntFlag +from functools import cache from typing import Any, Generic, Literal, TypedDict, TypeVar, cast from uuid import UUID @@ -79,27 +81,93 @@ class Selector(Generic[_T]): return {"selector": {self.selector_type: self.config}} -SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA = vol.Schema( +@cache +def _entity_features() -> dict[str, type[IntFlag]]: + """Return a cached lookup of entity feature enums.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntityFeature, + ) + from homeassistant.components.calendar import CalendarEntityFeature + from homeassistant.components.camera import CameraEntityFeature + from homeassistant.components.climate import ClimateEntityFeature + from homeassistant.components.cover import CoverEntityFeature + from homeassistant.components.fan import FanEntityFeature + from homeassistant.components.humidifier import HumidifierEntityFeature + from homeassistant.components.light import LightEntityFeature + from homeassistant.components.lock import LockEntityFeature + from homeassistant.components.media_player import MediaPlayerEntityFeature + from homeassistant.components.remote import RemoteEntityFeature + from homeassistant.components.siren import SirenEntityFeature + from homeassistant.components.update import UpdateEntityFeature + from homeassistant.components.vacuum import VacuumEntityFeature + from homeassistant.components.water_heater import WaterHeaterEntityFeature + + return { + "AlarmControlPanelEntityFeature": AlarmControlPanelEntityFeature, + "CalendarEntityFeature": CalendarEntityFeature, + "CameraEntityFeature": CameraEntityFeature, + "ClimateEntityFeature": ClimateEntityFeature, + "CoverEntityFeature": CoverEntityFeature, + "FanEntityFeature": FanEntityFeature, + "HumidifierEntityFeature": HumidifierEntityFeature, + "LightEntityFeature": LightEntityFeature, + "LockEntityFeature": LockEntityFeature, + "MediaPlayerEntityFeature": MediaPlayerEntityFeature, + "RemoteEntityFeature": RemoteEntityFeature, + "SirenEntityFeature": SirenEntityFeature, + "UpdateEntityFeature": UpdateEntityFeature, + "VacuumEntityFeature": VacuumEntityFeature, + "WaterHeaterEntityFeature": WaterHeaterEntityFeature, + } + + +def _validate_supported_feature(supported_feature: int | str) -> int: + """Validate a supported feature and resolve an enum string to its value.""" + + if isinstance(supported_feature, int): + return supported_feature + + known_entity_features = _entity_features() + + try: + _, enum, feature = supported_feature.split(".", 2) + except ValueError as exc: + raise vol.Invalid( + f"Invalid supported feature '{supported_feature}', expected " + ".." + ) from exc + + try: + return cast(int, getattr(known_entity_features[enum], feature).value) + except (AttributeError, KeyError) as exc: + raise vol.Invalid(f"Unknown supported feature '{supported_feature}'") from exc + + +ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( { # Integration that provided the entity vol.Optional("integration"): str, # Domain the entity belongs to - vol.Optional("domain"): vol.Any(str, [str]), + vol.Optional("domain"): vol.All(cv.ensure_list, [str]), # Device class of the entity - vol.Optional("device_class"): str, + vol.Optional("device_class"): vol.All(cv.ensure_list, [str]), + # Features supported by the entity + vol.Optional("supported_features"): [vol.All(str, _validate_supported_feature)], } ) -class SingleEntitySelectorConfig(TypedDict, total=False): +class EntityFilterSelectorConfig(TypedDict, total=False): """Class to represent a single entity selector config.""" integration: str domain: str | list[str] - device_class: str + device_class: str | list[str] + supported_features: list[str] -SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema( +DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( { # Integration linked to it with a config entry vol.Optional("integration"): str, @@ -108,18 +176,21 @@ SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema( # Model of device vol.Optional("model"): str, # Device has to contain entities matching this selector - vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, + vol.Optional("entity"): vol.All( + cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] + ), } ) -class SingleDeviceSelectorConfig(TypedDict, total=False): +class DeviceFilterSelectorConfig(TypedDict, total=False): """Class to represent a single device selector config.""" integration: str manufacturer: str model: str - entity: SingleEntitySelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + filter: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] class ActionSelectorConfig(TypedDict): @@ -176,8 +247,8 @@ class AddonSelector(Selector[AddonSelectorConfig]): class AreaSelectorConfig(TypedDict, total=False): """Class to represent an area selector config.""" - entity: SingleEntitySelectorConfig - device: SingleDeviceSelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] multiple: bool @@ -189,8 +260,14 @@ class AreaSelector(Selector[AreaSelectorConfig]): CONFIG_SCHEMA = vol.Schema( { - vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, - vol.Optional("device"): SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA, + vol.Optional("entity"): vol.All( + cv.ensure_list, + [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + ), + vol.Optional("device"): vol.All( + cv.ensure_list, + [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], + ), vol.Optional("multiple", default=False): cv.boolean, } ) @@ -209,6 +286,28 @@ class AreaSelector(Selector[AreaSelectorConfig]): return [vol.Schema(str)(val) for val in data] +class AssistPipelineSelectorConfig(TypedDict, total=False): + """Class to represent an assist pipeline selector config.""" + + +@SELECTORS.register("assist_pipeline") +class AssistPipelineSelector(Selector[AssistPipelineSelectorConfig]): + """Selector for an assist pipeline.""" + + selector_type = "assist_pipeline" + + CONFIG_SCHEMA = vol.Schema({}) + + def __init__(self, config: AssistPipelineSelectorConfig) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str: + """Validate the passed selection.""" + pipeline: str = vol.Schema(str)(data) + return pipeline + + class AttributeSelectorConfig(TypedDict, total=False): """Class to represent an attribute selector config.""" @@ -349,6 +448,38 @@ class ConfigEntrySelector(Selector[ConfigEntrySelectorConfig]): return config +class ConstantSelectorConfig(TypedDict, total=False): + """Class to represent a constant selector config.""" + + label: str + translation_key: str + value: str | int | bool + + +@SELECTORS.register("constant") +class ConstantSelector(Selector[ConstantSelectorConfig]): + """Constant selector.""" + + selector_type = "constant" + + CONFIG_SCHEMA = vol.Schema( + { + vol.Optional("label"): str, + vol.Optional("translation_key"): cv.string, + vol.Required("value"): vol.Any(str, int, bool), + } + ) + + def __init__(self, config: ConstantSelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + vol.Schema(self.config["value"])(data) + return self.config["value"] + + class DateSelectorConfig(TypedDict): """Class to represent a date selector config.""" @@ -399,7 +530,7 @@ class DeviceSelectorConfig(TypedDict, total=False): integration: str manufacturer: str model: str - entity: SingleEntitySelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] multiple: bool @@ -409,8 +540,14 @@ class DeviceSelector(Selector[DeviceSelectorConfig]): selector_type = "device" - CONFIG_SCHEMA = SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA.extend( - {vol.Optional("multiple", default=False): cv.boolean} + CONFIG_SCHEMA = DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA.extend( + { + vol.Optional("multiple", default=False): cv.boolean, + vol.Optional("filter"): vol.All( + cv.ensure_list, + [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], + ), + }, ) def __init__(self, config: DeviceSelectorConfig | None = None) -> None: @@ -457,7 +594,7 @@ class DurationSelector(Selector[DurationSelectorConfig]): return cast(dict[str, float], data) -class EntitySelectorConfig(SingleEntitySelectorConfig, total=False): +class EntitySelectorConfig(EntityFilterSelectorConfig, total=False): """Class to represent an entity selector config.""" exclude_entities: list[str] @@ -471,11 +608,15 @@ class EntitySelector(Selector[EntitySelectorConfig]): selector_type = "entity" - CONFIG_SCHEMA = SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("exclude_entities"): [str], vol.Optional("include_entities"): [str], vol.Optional("multiple", default=False): cv.boolean, + vol.Optional("filter"): vol.All( + cv.ensure_list, + [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + ), } ) @@ -540,6 +681,40 @@ class IconSelector(Selector[IconSelectorConfig]): return icon +class LanguageSelectorConfig(TypedDict, total=False): + """Class to represent an language selector config.""" + + languages: list[str] + native_name: bool + no_sort: bool + + +@SELECTORS.register("language") +class LanguageSelector(Selector[LanguageSelectorConfig]): + """Selector for an language.""" + + selector_type = "language" + + CONFIG_SCHEMA = vol.Schema( + { + vol.Optional("languages"): [str], + vol.Optional("native_name", default=False): cv.boolean, + vol.Optional("no_sort", default=False): cv.boolean, + } + ) + + def __init__(self, config: LanguageSelectorConfig) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str: + """Validate the passed selection.""" + language: str = vol.Schema(str)(data) + if "languages" in self.config and language not in self.config["languages"]: + raise vol.Invalid(f"Value {language} is not a valid option") + return language + + class LocationSelectorConfig(TypedDict, total=False): """Class to represent a location selector config.""" @@ -784,8 +959,8 @@ class SelectSelector(Selector[SelectSelectorConfig]): class TargetSelectorConfig(TypedDict, total=False): """Class to represent a target selector config.""" - entity: SingleEntitySelectorConfig - device: SingleDeviceSelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] class StateSelectorConfig(TypedDict, total=False): @@ -832,8 +1007,14 @@ class TargetSelector(Selector[TargetSelectorConfig]): CONFIG_SCHEMA = vol.Schema( { - vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, - vol.Optional("device"): SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA, + vol.Optional("entity"): vol.All( + cv.ensure_list, + [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + ), + vol.Optional("device"): vol.All( + cv.ensure_list, + [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], + ), } ) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 3c3da10db7..a9d7b906e7 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -4,9 +4,11 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable, Iterable import dataclasses -from functools import partial, wraps +from enum import Enum +from functools import cache, partial, wraps import logging -from typing import TYPE_CHECKING, Any, TypedDict, TypeGuard, TypeVar +from types import ModuleType +from typing import TYPE_CHECKING, Any, TypedDict, TypeGuard, TypeVar, cast import voluptuous as vol @@ -42,6 +44,7 @@ from . import ( entity_registry, template, ) +from .selector import TargetSelector from .typing import ConfigType, TemplateVarsType if TYPE_CHECKING: @@ -56,6 +59,113 @@ CONF_SERVICE_ENTITY_ID = "entity_id" _LOGGER = logging.getLogger(__name__) SERVICE_DESCRIPTION_CACHE = "service_description_cache" +ALL_SERVICE_DESCRIPTIONS_CACHE = "all_service_descriptions_cache" + + +@cache +def _base_components() -> dict[str, ModuleType]: + """Return a cached lookup of base components.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components import ( + alarm_control_panel, + calendar, + camera, + climate, + cover, + fan, + humidifier, + light, + lock, + media_player, + remote, + siren, + update, + vacuum, + water_heater, + ) + + return { + "alarm_control_panel": alarm_control_panel, + "calendar": calendar, + "camera": camera, + "climate": climate, + "cover": cover, + "fan": fan, + "humidifier": humidifier, + "light": light, + "lock": lock, + "media_player": media_player, + "remote": remote, + "siren": siren, + "update": update, + "vacuum": vacuum, + "water_heater": water_heater, + } + + +def _validate_option_or_feature(option_or_feature: str, label: str) -> Any: + """Validate attribute option or supported feature.""" + try: + domain, enum, option = option_or_feature.split(".", 2) + except ValueError as exc: + raise vol.Invalid( + f"Invalid {label} '{option_or_feature}', expected " + ".." + ) from exc + + base_components = _base_components() + if not (base_component := base_components.get(domain)): + raise vol.Invalid(f"Unknown base component '{domain}'") + + try: + attribute_enum = getattr(base_component, enum) + except AttributeError as exc: + raise vol.Invalid(f"Unknown {label} enum '{domain}.{enum}'") from exc + + if not issubclass(attribute_enum, Enum): + raise vol.Invalid(f"Expected {label} '{domain}.{enum}' to be an enum") + + try: + return getattr(attribute_enum, option).value + except AttributeError as exc: + raise vol.Invalid(f"Unknown {label} '{enum}.{option}'") from exc + + +def validate_attribute_option(attribute_option: str) -> Any: + """Validate attribute option.""" + return _validate_option_or_feature(attribute_option, "attribute option") + + +def validate_supported_feature(supported_feature: str) -> Any: + """Validate supported feature.""" + return _validate_option_or_feature(supported_feature, "supported feature") + + +# Basic schemas which translate attribute and supported feature enum names +# to their values. Full validation is done by hassfest.services +_FIELD_SCHEMA = vol.Schema( + { + vol.Optional("filter"): { + vol.Optional("attribute"): { + vol.Required(str): [vol.All(str, validate_attribute_option)], + }, + vol.Optional("supported_features"): [ + vol.All(str, validate_supported_feature) + ], + }, + }, + extra=vol.ALLOW_EXTRA, +) + +_SERVICE_SCHEMA = vol.Schema( + { + vol.Optional("target"): vol.Any(TargetSelector.CONFIG_SCHEMA, None), + vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}), + }, + extra=vol.ALLOW_EXTRA, +) + +_SERVICES_SCHEMA = vol.Schema({cv.slug: _SERVICE_SCHEMA}) class ServiceParams(TypedDict): @@ -90,7 +200,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.""" @@ -421,13 +531,16 @@ async def async_extract_config_entry_ids( def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: """Load services file for an integration.""" try: - return load_yaml(str(integration.file_path / "services.yaml")) + return cast( + JSON_TYPE, + _SERVICES_SCHEMA(load_yaml(str(integration.file_path / "services.yaml"))), + ) except FileNotFoundError: _LOGGER.warning( "Unable to find services.yaml for the %s integration", integration.domain ) return {} - except HomeAssistantError: + except (HomeAssistantError, vol.Invalid): _LOGGER.warning( "Unable to parse services.yaml for the %s integration", integration.domain ) @@ -447,17 +560,27 @@ async def async_get_all_descriptions( ) -> dict[str, dict[str, Any]]: """Return descriptions (i.e. user documentation) for all service calls.""" descriptions_cache = hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) - format_cache_key = "{}.{}".format services = hass.services.async_services() # See if there are new services not seen before. # Any service that we saw before already has an entry in description_cache. missing = set() + all_services = [] for domain in services: for service in services[domain]: - if format_cache_key(domain, service) not in descriptions_cache: + cache_key = (domain, service) + all_services.append(cache_key) + if cache_key not in descriptions_cache: missing.add(domain) - break + + # If we have a complete cache, check if it is still valid + if ALL_SERVICE_DESCRIPTIONS_CACHE in hass.data: + previous_all_services, previous_descriptions_cache = hass.data[ + ALL_SERVICE_DESCRIPTIONS_CACHE + ] + # If the services are the same, we can return the cache + if previous_all_services == all_services: + return cast(dict[str, dict[str, Any]], previous_descriptions_cache) # Files we loaded for missing descriptions loaded = {} @@ -483,7 +606,7 @@ async def async_get_all_descriptions( descriptions[domain] = {} for service in services[domain]: - cache_key = format_cache_key(domain, service) + cache_key = (domain, service) description = descriptions_cache.get(cache_key) # Cache missing descriptions @@ -510,9 +633,20 @@ async def async_get_all_descriptions( descriptions[domain][service] = description + hass.data[ALL_SERVICE_DESCRIPTIONS_CACHE] = (all_services, descriptions) return descriptions +@callback +def remove_entity_service_fields(call: ServiceCall) -> dict[Any, Any]: + """Remove entity service fields.""" + return { + key: val + for key, val in call.data.items() + if key not in cv.ENTITY_SERVICE_FIELDS + } + + @callback @bind_hass def async_set_service_schema( @@ -530,7 +664,8 @@ def async_set_service_schema( if "target" in schema: description["target"] = schema["target"] - hass.data[SERVICE_DESCRIPTION_CACHE][f"{domain}.{service}"] = description + hass.data.pop(ALL_SERVICE_DESCRIPTIONS_CACHE, None) + hass.data[SERVICE_DESCRIPTION_CACHE][(domain, service)] = description @bind_hass @@ -567,11 +702,7 @@ async def entity_service_call( # noqa: C901 # If the service function is a string, we'll pass it the service call data if isinstance(func, str): - data: dict | ServiceCall = { - key: val - for key, val in call.data.items() - if key not in cv.ENTITY_SERVICE_FIELDS - } + data: dict | ServiceCall = remove_entity_service_fields(call) # If the service function is not a string, we pass the service call else: data = call 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/signal.py b/homeassistant/helpers/signal.py index 9fd643a775..c7035d5a0d 100644 --- a/homeassistant/helpers/signal.py +++ b/homeassistant/helpers/signal.py @@ -1,4 +1,5 @@ """Signal handling related helpers.""" +import asyncio import logging import signal @@ -23,7 +24,9 @@ def async_register_signal_handling(hass: HomeAssistant) -> None: """ hass.loop.remove_signal_handler(signal.SIGTERM) hass.loop.remove_signal_handler(signal.SIGINT) - hass.async_create_task(hass.async_stop(exit_code)) + hass.data["homeassistant_stop"] = asyncio.create_task( + hass.async_stop(exit_code) + ) try: hass.loop.add_signal_handler(signal.SIGTERM, async_signal_handle, 0) diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 19e028af90..bd9b01cd6a 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -115,7 +115,9 @@ class Store(Generic[_T]): the second call will wait and return the result of the first call. """ if self._load_task is None: - self._load_task = self.hass.async_create_task(self._async_load()) + self._load_task = self.hass.async_create_task( + self._async_load(), f"Storage load {self.key}" + ) return await self._load_task diff --git a/homeassistant/helpers/sun.py b/homeassistant/helpers/sun.py index 25bef38ed0..cf944dfc47 100644 --- a/homeassistant/helpers/sun.py +++ b/homeassistant/helpers/sun.py @@ -82,7 +82,8 @@ def get_location_astral_event_next( kwargs["observer_elevation"] = elevation mod = -1 - while True: + first_err = None + while mod < 367: try: next_dt = ( cast(_AstralSunEventCallable, getattr(location, event))( @@ -94,9 +95,13 @@ def get_location_astral_event_next( ) if next_dt > utc_point_in_time: return next_dt - except ValueError: - pass + except ValueError as err: + if not first_err: + first_err = err mod += 1 + raise ValueError( + f"Unable to find event after one year, initial ValueError: {first_err}" + ) from first_err @callback diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 9aafe53925..6ff48d8324 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,8 @@ 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 import statistics @@ -40,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 ( @@ -48,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, ) @@ -57,6 +63,7 @@ from homeassistant.core import ( State, callback, split_entity_id, + valid_domain, valid_entity_id, ) from homeassistant.exceptions import TemplateError @@ -73,6 +80,7 @@ from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.thread import ThreadWithException from . import area_registry, device_registry, entity_registry, location as loc_helper +from .singleton import singleton from .typing import TemplateVarsType # mypy: allow-untyped-defs, no-check-untyped-defs @@ -81,10 +89,10 @@ _LOGGER = logging.getLogger(__name__) _SENTINEL = object() DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" -_RENDER_INFO = "template.render_info" _ENVIRONMENT = "template.environment" _ENVIRONMENT_LIMITED = "template.environment_limited" _ENVIRONMENT_STRICT = "template.environment_strict" +_HASS_LOADER = "template.hass_loader" _RE_JINJA_DELIMITERS = re.compile(r"\{%|\{\{|\{#") # Match "simple" ints and floats. -1.0, 1, +5, 5.0 @@ -113,13 +121,88 @@ _P = ParamSpec("_P") ALL_STATES_RATE_LIMIT = timedelta(minutes=1) DOMAIN_STATES_RATE_LIMIT = timedelta(seconds=1) +_render_info: ContextVar[RenderInfo | None] = ContextVar("_render_info", default=None) + + 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_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 def attach(hass: HomeAssistant, obj: Any) -> None: @@ -274,6 +357,8 @@ class RenderInfo: f" entities={self.entities}" f" rate_limit={self.rate_limit}" f" has_time={self.has_time}" + f" exception={self.exception}" + f" is_static={self.is_static}" ">" ) @@ -359,6 +444,7 @@ class Template: "_limited", "_strict", "_hash_cache", + "_renders", ) def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: @@ -375,6 +461,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: @@ -444,6 +531,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 @@ -519,6 +608,8 @@ class Template: This method must be run in the event loop. """ + self._renders += 1 + if self.is_static: return False @@ -561,7 +652,8 @@ class Template: self, variables: TemplateVarsType = None, strict: bool = False, **kwargs: Any ) -> RenderInfo: """Render the template and collect an entity filter.""" - assert self.hass and _RENDER_INFO not in self.hass.data + self._renders += 1 + assert self.hass and _render_info.get() is None render_info = RenderInfo(self) @@ -571,13 +663,13 @@ class Template: render_info._freeze_static() return render_info - self.hass.data[_RENDER_INFO] = render_info + token = _render_info.set(render_info) try: render_info._result = self.async_render(variables, strict=strict, **kwargs) except TemplateError as ex: render_info.exception = ex finally: - del self.hass.data[_RENDER_INFO] + _render_info.reset(token) render_info._freeze() return render_info @@ -610,6 +702,8 @@ class Template: This method must be run in the event loop. """ + self._renders += 1 + if self.is_static: return self.template @@ -673,7 +767,7 @@ class Template: def __repr__(self) -> str: """Representation of Template.""" - return 'Template("' + self.template + '")' + return f"Template" @cache @@ -705,7 +799,7 @@ class AllStates: if name in _RESERVED_NAMES: return None - if not valid_entity_id(f"{name}.entity"): + if not valid_domain(name): raise TemplateError(f"Invalid domain name '{name}'") return _domain_states(self._hass, name) @@ -715,13 +809,11 @@ class AllStates: __getitem__ = __getattr__ def _collect_all(self) -> None: - render_info = self._hass.data.get(_RENDER_INFO) - if render_info is not None: + if (render_info := _render_info.get()) is not None: render_info.all_states = True def _collect_all_lifecycle(self) -> None: - render_info = self._hass.data.get(_RENDER_INFO) - if render_info is not None: + if (render_info := _render_info.get()) is not None: render_info.all_states_lifecycle = True def __iter__(self) -> Generator[TemplateState, None, None]: @@ -777,14 +869,12 @@ class DomainStates: __getitem__ = __getattr__ def _collect_domain(self) -> None: - entity_collect = self._hass.data.get(_RENDER_INFO) - if entity_collect is not None: - entity_collect.domains.add(self._domain) + if (entity_collect := _render_info.get()) is not None: + entity_collect.domains.add(self._domain) # type: ignore[attr-defined] def _collect_domain_lifecycle(self) -> None: - entity_collect = self._hass.data.get(_RENDER_INFO) - if entity_collect is not None: - entity_collect.domains_lifecycle.add(self._domain) + if (entity_collect := _render_info.get()) is not None: + entity_collect.domains_lifecycle.add(self._domain) # type: ignore[attr-defined] def __iter__(self) -> Generator[TemplateState, None, None]: """Return the iteration over all the states.""" @@ -821,8 +911,8 @@ class TemplateStateBase(State): self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None def _collect_state(self) -> None: - if self._collect and (_render_info := self._hass.data.get(_RENDER_INFO)): - _render_info.entities.add(self._entity_id) + if self._collect and (render_info := _render_info.get()): + render_info.entities.add(self._entity_id) # type: ignore[attr-defined] # Jinja will try __getitem__ first and it avoids the need # to call is_safe_attribute @@ -830,8 +920,8 @@ class TemplateStateBase(State): """Return a property as an attribute for jinja.""" if item in _COLLECTABLE_STATE_ATTRIBUTES: # _collect_state inlined here for performance - if self._collect and (_render_info := self._hass.data.get(_RENDER_INFO)): - _render_info.entities.add(self._entity_id) + if self._collect and (render_info := _render_info.get()): + render_info.entities.add(self._entity_id) # type: ignore[attr-defined] return getattr(self._state, item) if item == "entity_id": return self._entity_id @@ -961,21 +1051,33 @@ class TemplateStateFromEntityId(TemplateStateBase): return f"