diff --git a/.core_files.yaml b/.core_files.yaml index 7bf7a09b36bf..7933556b6038 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -125,6 +125,7 @@ tests: &tests - tests/mock/** - tests/pylint/** - tests/scripts/** + - tests/syrupy.py - tests/test_util/** - tests/testing_config/** - tests/util/** diff --git a/.coveragerc b/.coveragerc index 7429966011a7..4b831fc3d3c2 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 @@ -249,7 +249,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 @@ -394,7 +395,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 @@ -516,9 +518,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 @@ -627,9 +626,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 @@ -639,6 +635,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 @@ -670,7 +672,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 @@ -771,7 +772,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 @@ -803,7 +808,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 @@ -966,18 +974,23 @@ 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 @@ -1276,9 +1289,11 @@ omit = 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 @@ -1346,6 +1361,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 @@ -1363,7 +1379,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 @@ -1493,7 +1508,7 @@ omit = homeassistant/components/zeversolar/coordinator.py homeassistant/components/zeversolar/entity.py homeassistant/components/zeversolar/sensor.py - homeassistant/components/zha/api.py + homeassistant/components/zha/websocket_api.py homeassistant/components/zha/core/channels/* homeassistant/components/zha/core/device.py homeassistant/components/zha/core/gateway.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1711ab68fdee..042eb94b1954 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/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5bb755750e18..237fc2888ab3 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 2440cb7ff29a..8a4c7d467088 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 23b355a223fd..c64efda390e7 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. @@ -107,3 +108,4 @@ To help with the load of incoming pull requests: [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 [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 531efe5674ff..ff53757bdd6a 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 with: fetch-depth: 0 @@ -67,7 +67,7 @@ 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.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 @@ -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.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -198,7 +198,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 +232,7 @@ jobs: - khadas-vim3 - odroid-c2 - odroid-c4 + - odroid-m1 - odroid-n2 - odroid-xu - qemuarm @@ -248,7 +249,7 @@ jobs: - yellow steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Set build additional args run: | @@ -275,7 +276,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 +292,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -330,7 +331,7 @@ jobs: - "homeassistant" steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Login to DockerHub if: matrix.registry == 'homeassistant' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0c6e0c173a22..e4fd319e7159 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,7 +31,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 - HA_SHORT_VERSION: 2023.3 + HA_SHORT_VERSION: 2023.4 DEFAULT_PYTHON: "3.10" ALL_PYTHON_VERSIONS: "['3.10', '3.11']" # 10.3 is the oldest supported version @@ -79,7 +79,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -203,7 +203,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -212,7 +212,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.2.6 + uses: actions/cache@v3.3.1 with: path: venv key: >- @@ -227,9 +227,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.6 + 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 }} @@ -247,7 +248,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -256,7 +257,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -265,7 +266,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.6 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -285,55 +286,6 @@ 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-22.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.6 - 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.6 - 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-22.04 @@ -342,7 +294,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -351,7 +303,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -360,7 +312,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.6 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -391,7 +343,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -400,7 +352,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -409,7 +361,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.6 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -429,7 +381,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -438,7 +390,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -447,7 +399,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.6 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -455,19 +407,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" @@ -548,7 +487,7 @@ 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.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -562,15 +501,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.6 + 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.6 + uses: actions/cache@v3.3.1 with: path: ${{ env.PIP_CACHE }} key: >- @@ -615,7 +555,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -624,7 +564,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -647,7 +587,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -656,7 +596,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -680,7 +620,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -689,7 +629,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -724,7 +664,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -740,7 +680,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.6 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -748,7 +688,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v3.2.6 + uses: actions/cache@v3.3.1 with: path: .mypy_cache key: >- @@ -790,7 +730,7 @@ jobs: name: Run pip check ${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -799,7 +739,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -843,7 +783,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -852,7 +792,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -969,7 +909,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -978,7 +918,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -1001,6 +941,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 @@ -1073,16 +1017,16 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.1.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.3.0 + 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@v3.2.6 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -1105,6 +1049,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 @@ -1145,14 +1093,17 @@ jobs: - pytest steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - 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 with: + fail_ci_if_error: true flags: full-suite - name: Upload coverage to Codecov (partial coverage) if: needs.info.outputs.test_full_suite == 'false' uses: codecov/codecov-action@v3.1.1 + with: + fail_ci_if_error: true diff --git a/.github/workflows/matchers/flake8.json b/.github/workflows/matchers/flake8.json deleted file mode 100644 index e059a1cf5f74..000000000000 --- 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 d8aaa998accd..5fb977f74d1e 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 8f8244c49033..86bfa5f9bb9d 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 144d6cbae163..63069b86ef8c 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -22,7 +22,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Get information id: info @@ -54,6 +54,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 @@ -79,7 +82,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -116,7 +119,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -176,7 +179,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio + skip-binary: aiohttp;grpcio;sqlalchemy legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -191,7 +194,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio + skip-binary: aiohttp;grpcio;sqlalchemy legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab481ac4eaf0..fd196f19db3b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,24 +1,10 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.247 + rev: v0.0.256 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 hooks: @@ -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 a4d1d99079dc..aab23e230789 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 13fd49391e94..533d5239cab2 100644 --- a/.strict-typing +++ b/.strict-typing @@ -186,6 +186,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.* @@ -296,6 +297,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 +311,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/tasks.json b/.vscode/tasks.json index 849716d7ba8c..7af7a426d62b 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", diff --git a/.yamllint b/.yamllint index c2f877a2b7a0..e587d75d7992 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 e782f050926e..f50ff89d05a6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -228,8 +228,6 @@ build.json @home-assistant/supervisor /homeassistant/components/cups/ @fabaff /homeassistant/components/daikin/ @fredrike /tests/components/daikin/ @fredrike -/homeassistant/components/darksky/ @fabaff -/tests/components/darksky/ @fabaff /homeassistant/components/debugpy/ @frenck /tests/components/debugpy/ @frenck /homeassistant/components/deconz/ @Kane610 @@ -401,6 +399,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 @@ -659,8 +658,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 @@ -784,7 +783,8 @@ build.json @home-assistant/supervisor /tests/components/nexia/ @bdraco /homeassistant/components/nextbus/ @vividboarder /tests/components/nextbus/ @vividboarder -/homeassistant/components/nextcloud/ @meichthys +/homeassistant/components/nextcloud/ @mib1185 +/tests/components/nextcloud/ @mib1185 /homeassistant/components/nextdns/ @bieniu /tests/components/nextdns/ @bieniu /homeassistant/components/nfandroidtv/ @tkdrob @@ -825,7 +825,8 @@ build.json @home-assistant/supervisor /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 @@ -1055,8 +1056,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 @@ -1139,8 +1140,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 @@ -1213,8 +1214,6 @@ 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 @@ -1293,8 +1292,8 @@ 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 @@ -1309,6 +1308,8 @@ build.json @home-assistant/supervisor /tests/components/vizio/ @raman325 /homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare /tests/components/vlc_telnet/ @rodripf @MartinHjelmare +/homeassistant/components/voice_assistant/ @balloob @synesthesiam +/tests/components/voice_assistant/ @balloob @synesthesiam /homeassistant/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund /homeassistant/components/volvooncall/ @molobrakos @@ -1354,8 +1355,8 @@ 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 diff --git a/Dockerfile.dev b/Dockerfile.dev index 863ac5690bc6..116446d18182 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -5,7 +5,6 @@ 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 diff --git a/README.rst b/README.rst index 6f5e0e69892b..084949dc44e7 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 000000000000..546e4afdcfa6 --- /dev/null +++ b/homeassistant/auth/jwt_wrapper.py @@ -0,0 +1,116 @@ +"""Provide a wrapper around JWT that caches decoding tokens. + +Since we decode the same tokens over and over again +we can cache the result of the decode of valid tokens +to speed up the process. +""" +from __future__ import annotations + +from datetime import timedelta +from functools import lru_cache, partial +from typing import Any + +from jwt import DecodeError, PyJWS, PyJWT + +from homeassistant.util.json import json_loads + +JWT_TOKEN_CACHE_SIZE = 16 +MAX_TOKEN_SIZE = 8192 + +_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss") + +_VERIFY_OPTIONS: dict[str, Any] = {f"verify_{key}": True for key in _VERIFY_KEYS} | { + "require": [] +} +_NO_VERIFY_OPTIONS = {f"verify_{key}": False for key in _VERIFY_KEYS} + + +class _PyJWSWithLoadCache(PyJWS): + """PyJWS with a dedicated load implementation.""" + + @lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE) + # We only ever have a global instance of this class + # so we do not have to worry about the LRU growing + # each time we create a new instance. + def _load(self, jwt: str | bytes) -> tuple[bytes, bytes, dict, bytes]: + """Load a JWS.""" + return super()._load(jwt) + + +_jws = _PyJWSWithLoadCache() + + +@lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE) +def _decode_payload(json_payload: str) -> dict[str, Any]: + """Decode the payload from a JWS dictionary.""" + try: + payload = json_loads(json_payload) + except ValueError as err: + raise DecodeError(f"Invalid payload string: {err}") from err + if not isinstance(payload, dict): + raise DecodeError("Invalid payload string: must be a json object") + return payload + + +class _PyJWTWithVerify(PyJWT): + """PyJWT with a fast decode implementation.""" + + def decode_payload( + self, jwt: str, key: str, options: dict[str, Any], algorithms: list[str] + ) -> dict[str, Any]: + """Decode a JWT's payload.""" + if len(jwt) > MAX_TOKEN_SIZE: + # Avoid caching impossible tokens + raise DecodeError("Token too large") + return _decode_payload( + _jws.decode_complete( + jwt=jwt, + key=key, + algorithms=algorithms, + options=options, + )["payload"] + ) + + def verify_and_decode( + self, + jwt: str, + key: str, + algorithms: list[str], + issuer: str | None = None, + leeway: int | float | timedelta = 0, + options: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Verify a JWT's signature and claims.""" + merged_options = {**_VERIFY_OPTIONS, **(options or {})} + payload = self.decode_payload( + jwt=jwt, + key=key, + options=merged_options, + algorithms=algorithms, + ) + # These should never be missing since we verify them + # but this is an additional safeguard to make sure + # nothing slips through. + assert "exp" in payload, "exp claim is required" + assert "iat" in payload, "iat claim is required" + self._validate_claims( # type: ignore[no-untyped-call] + payload=payload, + options=merged_options, + issuer=issuer, + leeway=leeway, + ) + return payload + + +_jwt = _PyJWTWithVerify() # type: ignore[no-untyped-call] +verify_and_decode = _jwt.verify_and_decode +unverified_hs256_token_decode = lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)( + partial( + _jwt.decode_payload, key="", algorithms=["HS256"], options=_NO_VERIFY_OPTIONS + ) +) + +__all__ = [ + "unverified_hs256_token_decode", + "verify_and_decode", +] diff --git a/homeassistant/auth/permissions/models.py b/homeassistant/auth/permissions/models.py index aa1a777ced26..9b9c384c74d2 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 753fda5ae9be..d7c1a7c9eea2 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 e87ee1ae2820..d98680c70d4c 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), ) @@ -508,19 +511,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( diff --git a/homeassistant/brands/homeseer.json b/homeassistant/brands/homeseer.json new file mode 100644 index 000000000000..cfc36968c15c --- /dev/null +++ b/homeassistant/brands/homeseer.json @@ -0,0 +1,5 @@ +{ + "domain": "homeseer", + "name": "HomeSeer", + "iot_standards": ["zwave"] +} diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index d0e631fb04c5..690b38b48717 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/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index 9c18e2ba907c..5397fed5e1d4 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/climate.py b/homeassistant/components/advantage_air/climate.py index 362701f3b9f3..53a41994fc6e 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -116,6 +116,30 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): """Return the current fan modes.""" return ADVANTAGE_AIR_FAN_MODES.get(self._ac["fan"]) + async def async_turn_on(self) -> None: + """Set the HVAC State to on.""" + await self.aircon( + { + self.ac_key: { + "info": { + "state": ADVANTAGE_AIR_STATE_ON, + } + } + } + ) + + async def async_turn_off(self) -> None: + """Set the HVAC State to off.""" + await self.aircon( + { + self.ac_key: { + "info": { + "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: @@ -181,24 +205,32 @@ 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.aircon( + { + self.ac_key: { + "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_OPEN}} + } + } + ) + + async def async_turn_off(self) -> None: + """Set the HVAC State to off.""" + await self.aircon( + { + self.ac_key: { + "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}} + } + } + ) + 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.""" diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 754471c9d8b0..53e15c651a7b 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 4f95f26afc09..50ebdd6d4dd0 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -26,5 +26,42 @@ "requests_remaining": "Remaining allowed requests", "requests_per_day": "Allowed requests per day" } + }, + "entity": { + "sensor": { + "caqi": { + "name": "Common air quality index" + }, + "pm1": { + "name": "[%key:component::sensor::entity_component::pm1::name%]" + }, + "pm25": { + "name": "[%key:component::sensor::entity_component::pm25::name%]" + }, + "pm10": { + "name": "[%key:component::sensor::entity_component::pm10::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "pressure": { + "name": "[%key:component::sensor::entity_component::pressure::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "co": { + "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" + }, + "no2": { + "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" + }, + "so2": { + "name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" + }, + "o3": { + "name": "[%key:component::sensor::entity_component::ozone::name%]" + } + } } } diff --git a/homeassistant/components/airq/__init__.py b/homeassistant/components/airq/__init__.py index 4bc64e1e8251..06d7ba30749c 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 90a6b9e0555f..41eda912e982 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 000000000000..78e9580c6310 --- /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 a47c308279d8..7f0d51fcaa87 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -51,6 +51,13 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) # Keys must match those in the data dictionary SENSOR_TYPES: list[AirQEntityDescription] = [ + AirQEntityDescription( + key="c2h4o", + name="Acetaldehyde", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("c2h4o"), + ), AirQEntityDescription( key="nh3_MR100", name="Ammonia", @@ -58,6 +65,27 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("nh3_MR100"), ), + AirQEntityDescription( + key="ash3", + name="Arsine", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ash3"), + ), + AirQEntityDescription( + key="br2", + name="Bromine", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("br2"), + ), + AirQEntityDescription( + key="ch4s", + name="CH4S", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ch4s"), + ), AirQEntityDescription( key="cl2_M20", name="Chlorine", @@ -65,6 +93,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("cl2_M20"), ), + AirQEntityDescription( + key="clo2", + name="ClO2", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("clo2"), + ), AirQEntityDescription( key="co", name="CO", @@ -80,6 +115,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("co2"), ), + AirQEntityDescription( + key="cs2", + name="CS2", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("cs2"), + ), AirQEntityDescription( key="dewpt", name="Dew point", @@ -95,6 +137,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ethanol"), ), + AirQEntityDescription( + key="c2h4", + name="Ethylene", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("c2h4"), + ), AirQEntityDescription( key="ch2o_M10", name="Formaldehyde", @@ -102,6 +151,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ch2o_M10"), ), + AirQEntityDescription( + key="f2", + name="Fluorine", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("f2"), + ), AirQEntityDescription( key="h2s", name="H2S", @@ -109,6 +165,27 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2s"), ), + AirQEntityDescription( + key="hcl", + name="HCl", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("hcl"), + ), + AirQEntityDescription( + key="hcn", + name="HCN", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("hcn"), + ), + AirQEntityDescription( + key="hf", + name="HF", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("hf"), + ), AirQEntityDescription( key="health", name="Health Index", @@ -140,6 +217,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2_M1000"), ), + AirQEntityDescription( + key="h2o2", + name="Hydrogen peroxide", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("h2o2"), + ), AirQEntityDescription( key="ch4_MIPEX", name="Methane", @@ -172,12 +256,11 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ value=lambda data: data.get("no2"), ), AirQEntityDescription( - key="o3", - name="Ozone", - device_class=SensorDeviceClass.OZONE, - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + key="acid_M100", + name="Organic acid", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, - value=lambda data: data.get("o3"), + value=lambda data: data.get("acid_M100"), ), AirQEntityDescription( key="oxygen", @@ -187,6 +270,14 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ value=lambda data: data.get("oxygen"), icon="mdi:leaf", ), + AirQEntityDescription( + key="o3", + name="Ozone", + device_class=SensorDeviceClass.OZONE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("o3"), + ), AirQEntityDescription( key="performance", name="Performance Index", @@ -195,6 +286,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ icon="mdi:head-check", value=lambda data: data.get("performance", 0.0) / 10.0, ), + AirQEntityDescription( + key="ph3", + name="PH3", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ph3"), + ), AirQEntityDescription( key="pm1", name="PM1", @@ -245,6 +343,20 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("c3h8_MIPEX"), ), + AirQEntityDescription( + key="refigerant", + name="Refrigerant", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("refigerant"), + ), + AirQEntityDescription( + key="sih4", + name="SiH4", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("sih4"), + ), AirQEntityDescription( key="so2", name="SO2", @@ -299,6 +411,14 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc_ionsc"), ), + AirQEntityDescription( + key="virus", + name="Virus Index", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:virus-off", + value=lambda data: data.get("virus", 0.0), + ), ] diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index 89d3b0faf14f..e5170e9b0a29 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 8815ccdbb959..2cf526e5626b 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,18 @@ class AladdinDevice(CoverEntity): async def async_update(self) -> None: """Update status of cover.""" - await self._acc.get_doors(self._serial) + try: + await self._acc.get_doors(self._serial) + self._attr_available = True + + except session_manager.ConnectionError: + self._attr_available = False + + except session_manager.InvalidPasswordError: + self._attr_available = False + await self.hass.async_create_task( + self.hass.config_entries.async_reload(self._entry_id) + ) @property def is_closed(self) -> bool | None: diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index dd0c3d03a437..de4f3df257a0 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 4764d5cfcbef..a097aa98535a 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 303243d66cbe..9106942c5e55 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 5126f49d92b8..6b01cab2becc 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/strings.json b/homeassistant/components/alert/strings.json index fb31ecd0577d..4d948b2f4d11 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/errors.py b/homeassistant/components/alexa/errors.py index 5f0de6f74670..7f4b41b9ec74 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 1521afcae5a2..6f53d86d4444 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/state_report.py b/homeassistant/components/alexa/state_report.py index 783397ca0479..a189c364c02d 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 a0250938fb45..e1f7afce174c 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 7e21b9ac603d..97e0af7f18ed 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/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 9162d7841d1e..43201aba77a0 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/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index ad53fb03113b..2542ed5177e0 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -27,7 +27,9 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: async_call_later(hass, 900, analytics.send_analytics) # Send every day - async_track_time_interval(hass, analytics.send_analytics, INTERVAL) + async_track_time_interval( + hass, analytics.send_analytics, INTERVAL, name="analytics daily" + ) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule) diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index a462d433c7f0..453fc7735144 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/arcam_fmj/device_trigger.py b/homeassistant/components/arcam_fmj/device_trigger.py index f3722c81ec56..ecaec0e0e7df 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/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py index b456aa3f7039..48b8d9f13c47 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 d0794553b420..7b8c547fd536 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 420ffb2d8a8d..f03734e63dff 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__) @@ -102,7 +102,7 @@ async def async_setup_platform( """Set up the ARWN platform.""" @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 +115,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/august/manifest.json b/homeassistant/components/august/manifest.json index 213f0237e124..84b5ae7e2052 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.1"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.14"] } diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py index 5223b8b4a388..62aef44a9eeb 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/sensor.py b/homeassistant/components/aurora/sensor.py index 2710badb516c..de5e566e2680 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/automation/strings.json b/homeassistant/components/automation/strings.json index ea03868e6399..4e433119a2ae 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 dc48e0f92c37..f42a46999fbf 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/backup/manager.py b/homeassistant/components/backup/manager.py index e38312dd6ebb..69df310bd556 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,6 +19,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import integration_platform from homeassistant.helpers.json import save_json from homeassistant.util import dt +from homeassistant.util.json import json_loads_object from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER @@ -100,11 +101,11 @@ class BackupManager: try: with tarfile.open(backup_path, "r:") 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 +187,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 +197,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,12 +216,16 @@ 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 ) as tar_file: @@ -245,6 +245,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/baf/manifest.json b/homeassistant/components/baf/manifest.json index 8229a912fbb3..b5b5b76967e6 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 ca4b591cf9ef..91fe110d388a 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -39,6 +39,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 +49,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 +59,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 +72,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 +83,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 +97,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 +108,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, diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json index b81c681f829b..152a89bde315 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/issues.py b/homeassistant/components/bayesian/issues.py index 0f97b86b0e90..fbc3a86258d6 100644 --- a/homeassistant/components/bayesian/issues.py +++ b/homeassistant/components/bayesian/issues.py @@ -2,7 +2,7 @@ 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 a9b0312673b5..9c83aaa1734a 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 5d17fb92cb13..f2bbc72e7a5f 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 fd7cc76aada9..d75b69dfaf89 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/cover.py b/homeassistant/components/blebox/cover.py index 80e2fbd30e7b..658a9bc30cca 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/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index d5cf65d8724a..6d4e67119d5a 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -143,7 +143,7 @@ class ActiveBluetoothDataUpdateCoordinator( self._last_poll = monotonic_time_coarse() if not self.last_poll_successful: - self.logger.debug("%s: Polling recovered") + self.logger.debug("%s: Polling recovered", self.address) self.last_poll_successful = True self._async_handle_bluetooth_poll() diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index aabc27ff14ea..b450c6122503 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -136,7 +136,7 @@ class ActiveBluetoothProcessorCoordinator( self._last_poll = monotonic_time_coarse() if not self.last_poll_successful: - self.logger.debug("%s: Polling recovered") + self.logger.debug("%s: Polling recovered", self.address) self.last_poll_successful = True for processor in self._processors: diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 903f14a92273..d5e2ca0edbb5 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -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,7 +227,10 @@ class BaseHaRemoteScanner(BaseHaScanner): self._async_expire_devices(dt_util.utcnow()) cancel_track = async_track_time_interval( - self.hass, self._async_expire_devices, timedelta(seconds=30) + self.hass, + self._async_expire_devices, + timedelta(seconds=30), + name=f"{self.name} Bluetooth scanner device expire", ) cancel_stop = self.hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, self._async_save_history @@ -339,12 +345,27 @@ class BaseHaRemoteScanner(BaseHaScanner): tx_power=NO_RSSI_VALUE if tx_power is None else tx_power, 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 - ) + if prev_discovery: + # + # 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 + ) self._discovered_device_advertisement_datas[address] = ( device, advertisement_data, diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index bc210516562f..75809f42e6cb 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -276,6 +276,7 @@ class BluetoothManager: self.hass, self._async_check_unavailable, timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), + name="Bluetooth manager unavailable tracking", ) @hass_callback diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 8331117c9c96..31b9bdb5d5e6 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,11 +15,11 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.19.5", - "bleak-retry-connector==2.13.0", - "bluetooth-adapters==0.15.2", + "bleak==0.20.1", + "bleak-retry-connector==3.0.2", + "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.0.3", "bluetooth-data-tools==0.3.1", - "dbus-fast==1.84.1" + "dbus-fast==1.84.2" ] } diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index a80386c25ef4..911862a4221f 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/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 6b463423c73d..cf17796105b1 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -224,10 +224,28 @@ 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 @@ -235,7 +253,9 @@ class HaBleakClientWrapper(BleakClient): wrapped_backend = self._async_get_best_available_backend_and_device(manager) self._backend = wrapped_backend.client( wrapped_backend.device, - disconnected_callback=self.__disconnected_callback, + disconnected_callback=self._make_disconnected_callback( + self.__disconnected_callback + ), timeout=self.__timeout, hass=manager.hass, ) diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py index d3d19f430871..3739734223e7 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/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 4f05794e311f..0cde37ba6b34 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -1,6 +1,7 @@ """Config flow for BMW ConnectedDrive integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from bimmer_connected.api.authentication import MyBMWAuthentication @@ -55,36 +56,61 @@ class BMWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" errors: dict[str, str] = {} + if user_input is not None: unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}" - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() + if not self._reauth_entry: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() info = None try: info = await validate_input(self.hass, user_input) + entry_data = { + **user_input, + CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), + } except CannotConnect: errors["base"] = "cannot_connect" if info: + if self._reauth_entry: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=entry_data + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload( + self._reauth_entry.entry_id + ) + ) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=info["title"], - data={ - **user_input, - CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), - }, + data=entry_data, ) - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, self._reauth_entry.data if self._reauth_entry else {} ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 0f03505ff295..ae139d4c64a6 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -12,6 +12,7 @@ from httpx import HTTPError, HTTPStatusError, TimeoutException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN @@ -65,8 +66,9 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): 401, 403, ): - # Clear refresh token only on issues with authorization + # Clear refresh token only and trigger reauth self._update_config_entry_refresh_token(None) + raise ConfigEntryAuthFailed(str(err)) from err raise UpdateFailed(f"Error communicating with BMW API: {err}") from err if self.account.refresh_token != old_refresh_token: diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index cafaced5223e..f1768d5a0c7a 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer_connected==0.12.1"] + "requirements": ["bimmer_connected==0.13.0"] } diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 3e93cccb8c6b..506175becd91 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -14,7 +14,8 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 8c9fef6bd7f3..4e3218ba0414 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -174,7 +174,10 @@ class BondEntity(Entity): 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.hass, + self._async_update_if_bpup_not_alive, + _FALLBACK_SCAN_INTERVAL, + name=f"Bond {self.entity_id} fallback polling", ) ) diff --git a/homeassistant/components/braviatv/button.py b/homeassistant/components/braviatv/button.py index 768ded6b64f4..b382d97a2aee 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 295a56b32443..c5b42e73beef 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.2"], "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 917bd1d5419f..ff5691f9aed2 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 d66f44acc6c7..aacaf81465b3 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/sensor.py b/homeassistant/components/broadlink/sensor.py index 33f1badcc1b1..50c58d41667e 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 d324b0272a81..adff2303c741 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/manifest.json b/homeassistant/components/brother/manifest.json index bd5d877b4f36..cba44b68c6ac 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.2.0"], + "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 274576f0f31d..191bfff249c8 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 9d7d42abefa0..3ee3fe7609ff 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/const.py b/homeassistant/components/brottsplatskartan/const.py new file mode 100644 index 000000000000..87c42b01f4bb --- /dev/null +++ b/homeassistant/components/brottsplatskartan/const.py @@ -0,0 +1,33 @@ +"""Adds constants for brottsplatskartan integration.""" + +import logging + +LOGGER = logging.getLogger(__package__) + +CONF_AREA = "area" +DEFAULT_NAME = "Brottsplatskartan" + +AREAS = [ + "N/A", + "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/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index d76cb7c8a5fa..da53a9fc0eca 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections import defaultdict from datetime import timedelta -import logging import uuid import brottsplatskartan @@ -16,38 +15,10 @@ 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__) - -CONF_AREA = "area" - -DEFAULT_NAME = "Brottsplatskartan" +from .const import AREAS, CONF_AREA, DEFAULT_NAME, 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( { vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, @@ -99,7 +70,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/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index fcff6a925e5f..cbc6dd00471b 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 f53e395f0c53..0e945d13d484 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 4d89c851245b..0ffa3bc699bd 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 48475bbeac94..65aa1bd6a612 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/manifest.json b/homeassistant/components/bthome/manifest.json index 47f980c78fea..87a84e5fab00 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/sensor.py b/homeassistant/components/bthome/sensor.py index 4b3781834895..9b5def30054c 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/button/device_action.py b/homeassistant/components/button/device_action.py index 70033729692b..8398b4990cd6 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 673806be7d2a..fbf054996c3d 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 ca774c57d773..4fd888538937 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 ab3c47b96909..9a01cd2186ff 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/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index c77d6c9c67a3..0b1c37cea5fe 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, @@ -67,6 +68,23 @@ SCAN_INTERVAL = datetime.timedelta(seconds=60) VALID_FREQS = {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"} +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.""" @@ -89,7 +107,7 @@ 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]: - """Test that all keys that are datetime values have the same timezone.""" + """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) @@ -98,23 +116,59 @@ def _as_local_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]] return validate -def _is_sorted(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: - """Verify that the specified values are sequential.""" +def _has_duration( + start_key: str, end_key: str +) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Verify that the time span between start and end is positive.""" def validate(obj: dict[str, Any]) -> dict[str, Any]: """Test that all keys in the dict are in order.""" - values = [] - for k in keys: - if not (value := obj.get(k)): - return obj - values.append(value) - if all(values) and values != sorted(values): - raise vol.Invalid(f"Values were not in order: {values}") + if (start := obj.get(start_key)) and (end := obj.get(end_key)): + duration = end - start + if duration.total_seconds() <= 0: + raise vol.Invalid(f"Expected positive event duration ({start}, {end})") 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: {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) + + CREATE_EVENT_SERVICE = "create_event" CREATE_EVENT_SCHEMA = vol.All( cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), @@ -123,6 +177,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, @@ -149,8 +204,43 @@ CREATE_EVENT_SCHEMA = vol.All( ), _has_consistent_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME), _as_local_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME), - _is_sorted(EVENT_START_DATE, EVENT_END_DATE), - _is_sorted(EVENT_START_DATETIME, EVENT_END_DATETIME), + _has_duration(EVENT_START_DATE, EVENT_END_DATE), + _has_duration(EVENT_START_DATETIME, EVENT_END_DATETIME), +) + +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_duration(EVENT_START, EVENT_END), + ) +) + +# 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"), + _has_consistent_timezone("start", "end"), + _as_local_timezone("start", "end"), + _has_duration("start", "end"), + ), + extra=vol.ALLOW_EXTRA, ) @@ -243,6 +333,19 @@ 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 + def _event_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: """Convert CalendarEvent dataclass items to dictionary of attributes.""" @@ -316,30 +419,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.""" @@ -444,9 +523,10 @@ class CalendarEventView(http.HomeAssistantView): try: calendar_event_list = await entity.async_get_events( - request.app["hass"], start_date, end_date + request.app["hass"], dt.as_local(start_date), dt.as_local(end_date) ) except HomeAssistantError as err: + _LOGGER.debug("Error reading events: %s", err) return self.json_message( f"Error reading events: {err}", HTTPStatus.INTERNAL_SERVER_ERROR ) @@ -481,38 +561,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 - - @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), - _as_local_timezone(EVENT_START, EVENT_END), - _is_sorted(EVENT_START, EVENT_END), - ) - ), + CONF_EVENT: WEBSOCKET_EVENT_SCHEMA, } ) @websocket_api.async_response @@ -595,21 +648,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), - _as_local_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 aa47cb3592e9..3fbab6742a98 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 dfe278a92d45..5d1a3ccf0f40 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 3af9a78e6071..898953c18acd 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 1e51c746e183..f8a6014e2618 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 11e75c50cfc6..b1e4768a0a77 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, ) @@ -378,7 +379,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" diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index 28e4e1eeacbd..160f896c86ca 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 3b8767ec8cd7..0722ec1c5e6d 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 cc4a130a251a..48921303ce04 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 0540380bc994..b701890d85db 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 508b2b2d8b35..1424d41006dc 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 9ce98ec4fe8c..a5ca469d1016 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 3c9934d5cbf1..0119ad658015 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 c6179d822157..97dc27cfa090 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 0b0bedb49bba..005e744b53ff 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 40d518456b4e..33e114c87f5d 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 8c6c8f2d97a5..5e3fe15d5667 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 e9b852ada8dd..8352b566afe9 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -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( { @@ -309,7 +310,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_call_later( hass=hass, - delay=timedelta(hours=1), + delay=timedelta(hours=STARTUP_REPAIR_DELAY), action=async_startup_repairs, ) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 08d43644249b..900779f6b019 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -47,6 +47,7 @@ class CloudClient(Interface): self._google_config: google_config.CloudGoogleConfig | None = None self._alexa_config_init_lock = asyncio.Lock() self._google_config_init_lock = asyncio.Lock() + self._relayer_region: str | None = None @property def base_path(self) -> Path: @@ -84,6 +85,11 @@ class CloudClient(Interface): """Return true if we want start a remote connection.""" return self._prefs.remote_enabled + @property + def relayer_region(self) -> str | None: + """Return the connected relayer region.""" + return self._relayer_region + async def get_alexa_config(self) -> alexa_config.CloudAlexaConfig: """Return Alexa config.""" if self._alexa_config is None: @@ -256,6 +262,11 @@ class CloudClient(Interface): "headers": {"Content-Type": response.content_type}, } + async def async_system_message(self, payload: dict[Any, Any] | None) -> None: + """Handle system messages.""" + if payload and (region := payload.get("region")): + self._relayer_region = region + async def async_cloudhooks_update(self, data: dict[str, dict[str, str]]) -> None: """Update local list of cloudhooks.""" await self._prefs.async_update(cloudhooks=data) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 9fb4ffc7047e..9d5ed2ca28e0 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -12,9 +12,7 @@ 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" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index ea1a0aa27e6b..6c4115ae28a7 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -559,8 +559,6 @@ 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, } ) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index ce8377f18707..2bff4003669a 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.61.0"] + "requirements": ["hass-nabucasa==0.63.1"] } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 17ec00026bcf..7f27e7cf39ba 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -18,7 +18,6 @@ from .const import ( PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, - PREF_ALIASES, PREF_CLOUD_USER, PREF_CLOUDHOOKS, PREF_DISABLE_2FA, @@ -30,7 +29,6 @@ from .const import ( PREF_GOOGLE_LOCAL_WEBHOOK_ID, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, - PREF_OVERRIDE_NAME, PREF_REMOTE_DOMAIN, PREF_SHOULD_EXPOSE, PREF_TTS_DEFAULT_VOICE, @@ -118,9 +116,7 @@ class CloudPreferences: self, *, entity_id, - override_name=UNDEFINED, disable_2fa=UNDEFINED, - aliases=UNDEFINED, should_expose=UNDEFINED, ): """Update config for a Google entity.""" @@ -129,9 +125,7 @@ class CloudPreferences: 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: diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index e437fca9ed35..432a4db0f772 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -5,6 +5,7 @@ "can_reach_cloud": "Reach Home Assistant Cloud", "can_reach_cloud_auth": "Reach Authentication Server", "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 70618ab38ef0..13062db57d67 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -1,7 +1,9 @@ """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 @@ -19,6 +21,8 @@ from homeassistant.components.stt import ( from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + SUPPORT_LANGUAGES = [ "da-DK", "de-DE", @@ -88,7 +92,7 @@ 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 = ( @@ -101,7 +105,8 @@ class CloudProvider(Provider): result = await self.cloud.voice.process_stt( stream, content, metadata.language ) - except VoiceError: + except VoiceError as err: + _LOGGER.debug("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 9f836114b3e0..b1f1774aa479 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -28,6 +28,7 @@ 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 diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 00eacf7ca528..bbf4ef287d68 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -1,7 +1,7 @@ """Support for the cloud for text to speech service.""" from hass_nabucasa import Cloud -from hass_nabucasa.voice import MAP_VOICE, VoiceError +from hass_nabucasa.voice import MAP_VOICE, AudioOutput, VoiceError import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider @@ -99,7 +99,10 @@ class CloudProvider(Provider): # Process TTS try: data = await self.cloud.voice.process_tts( - message, language, gender=options[CONF_GENDER] + message, + language, + gender=options[CONF_GENDER], + output=AudioOutput.MP3, ) except VoiceError: return (None, None) diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index ecba1900b641..69d2bd9e9041 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 c0713d0780b2..fe0640d3efa7 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 f4a3a29f29fd..0c2edb8f1912 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 8298201228f4..e477affc8541 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 c41e26c21bb9..412456ff6e5f 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 5dbbbf88e581..f459e4156619 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: @@ -121,12 +138,11 @@ class CommandSensor(SensorEntity): else: _LOGGER.warning("Empty reply found when expecting JSON data") - if value is None: - value = STATE_UNKNOWN elif 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 7142f14e82d5..3c344891fbad 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -24,12 +24,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import setup_reload_service +from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import call_shell_with_timeout, check_output_or_log from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .utils import call_shell_with_timeout, check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -51,15 +51,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Find and return switches controlled by shell commands.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) devices: dict[str, Any] = config.get(CONF_SWITCHES, {}) switches = [] @@ -92,7 +92,7 @@ def setup_platform( _LOGGER.error("No switches added") return - add_entities(switches) + async_add_entities(switches) class CommandSwitch(SwitchEntity): @@ -123,11 +123,16 @@ class CommandSwitch(SwitchEntity): self._attr_unique_id = unique_id self._attr_should_poll = bool(command_state) - def _switch(self, command: str) -> bool: + async def _switch(self, command: str) -> bool: """Execute the actual commands.""" _LOGGER.info("Running command: %s", command) - success = call_shell_with_timeout(command, self._timeout) == 0 + success = ( + await self.hass.async_add_executor_job( + call_shell_with_timeout, command, self._timeout + ) + == 0 + ) if not success: _LOGGER.error("Command failed: %s", command) @@ -160,26 +165,30 @@ class CommandSwitch(SwitchEntity): if TYPE_CHECKING: return None - def update(self) -> None: + async def async_update(self) -> None: """Update device state.""" if self._command_state: - payload = str(self._query_state()) + payload = str(await self.hass.async_add_executor_job(self._query_state)) if self._icon_template: - self._attr_icon = self._icon_template.render_with_possible_json_value( - payload + self._attr_icon = ( + self._icon_template.async_render_with_possible_json_value(payload) ) if self._value_template: - payload = self._value_template.render_with_possible_json_value(payload) - self._attr_is_on = payload.lower() == "true" + payload = self._value_template.async_render_with_possible_json_value( + payload, None + ) + self._attr_is_on = None + if payload: + self._attr_is_on = payload.lower() == "true" - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - if self._switch(self._command_on) and not self._command_state: + if await self._switch(self._command_on) and not self._command_state: self._attr_is_on = True - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - if self._switch(self._command_off) and not self._command_state: + if await self._switch(self._command_off) and not self._command_state: self._attr_is_on = False - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py new file mode 100644 index 000000000000..2d42732190ef --- /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 5a39b786e278..72a493f8c1f0 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/scene.py b/homeassistant/components/config/scene.py index befbfd052af4..037cd55d6a03 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 570c18d3cde1..0574e4bfcedc 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 c99af1f89ce0..de4c8208ee03 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 275908819852..677610a1618b 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 fc4ca9e358d4..bab8c8634cae 100644 --- a/homeassistant/components/control4/director_utils.py +++ b/homeassistant/components/control4/director_utils.py @@ -10,13 +10,7 @@ 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__) @@ -53,10 +47,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 2c92010901bc..574866411963 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -175,7 +175,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. @@ -203,7 +203,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 +226,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 125e3c2e38fe..765f0dce78ce 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/const.py b/homeassistant/components/conversation/const.py index b79a557698f1..1cae975c957d 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -3,6 +3,7 @@ DOMAIN = "conversation" DEFAULT_EXPOSED_DOMAINS = { + "binary_sensor", "climate", "cover", "fan", @@ -16,3 +17,5 @@ DEFAULT_EXPOSED_DOMAINS = { "vacuum", "water_heater", } + +DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 28c19f9acd52..49569f66ac0d 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -18,9 +18,9 @@ import yaml from homeassistant import core, setup from homeassistant.helpers import ( - area_registry, - device_registry, - entity_registry, + area_registry as ar, + device_registry as dr, + entity_registry as er, intent, template, translation, @@ -28,7 +28,7 @@ from homeassistant.helpers import ( 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, DEFAULT_EXPOSED_DOMAINS, DOMAIN _LOGGER = logging.getLogger(__name__) _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" @@ -95,12 +95,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, ) @@ -471,14 +471,20 @@ class DefaultAgent(AbstractConversationAgent): states = [ state for state in self.hass.states.async_all() if is_entity_exposed(state) ] - entities = entity_registry.async_get(self.hass) - devices = device_registry.async_get(self.hass) + entities = er.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_key, attr_value in state.attributes.items(): + if attr_key not in DEFAULT_EXPOSED_ATTRIBUTES: + continue + context[attr_key] = attr_value entity = entities.async_get(state.entity_id) if entity is not None: @@ -506,7 +512,7 @@ class DefaultAgent(AbstractConversationAgent): entity_names.append((state.name, state.name, context)) # 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) @@ -518,6 +524,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 7630eed01f19..0753fcd5af9e 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.2.28"] + "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.3.29"] } diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index 129797c356f9..289e70e80670 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 index a1c4f876f660..a3bc07ee0a1d 100644 --- a/homeassistant/components/coronavirus/__init__.py +++ b/homeassistant/components/coronavirus/__init__.py @@ -8,7 +8,11 @@ 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 import ( + aiohttp_client, + entity_registry as er, + update_coordinator, +) from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -31,16 +35,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) @callback - def _async_migrator(entity_entry: entity_registry.RegistryEntry): + def _async_migrator(entity_entry: er.RegistryEntry): """Migrate away from unstable ID.""" country, info_type = entity_entry.unique_id.rsplit("-", 1) if not country.isnumeric(): return None return {"new_unique_id": f"{entry.title}-{info_type}"} - await entity_registry.async_migrate_entries( - hass, entry.entry_id, _async_migrator - ) + await er.async_migrate_entries(hass, entry.entry_id, _async_migrator) if not entry.unique_id: hass.config_entries.async_update_entry(entry, unique_id=entry.data["country"]) diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json new file mode 100644 index 000000000000..548d1554080d --- /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 c3c0e928f0ff..9b2bb05bb0f7 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 diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index bb66d54b79bc..6144bdb6dbf6 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 b0be418f3123..aad225c80396 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 cb98c542d431..2f61bd95083e 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/darksky/__init__.py b/homeassistant/components/darksky/__init__.py deleted file mode 100644 index 90a5d06dc0e5..000000000000 --- 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 6ff20b11f2ae..000000000000 --- 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 ef1ef0466d40..000000000000 --- 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 25672908670e..000000000000 --- 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/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index ba34ec48e0f1..7874786adbab 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/default_config/__init__.py b/homeassistant/components/default_config/__init__.py index 574d97c6d29f..d91d06949e69 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 d4faaddaa5d6..a1add4759489 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -33,6 +33,7 @@ "schedule", "script", "ssdp", + "stream", "sun", "system_health", "tag", diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 12b7ce0dd8d0..eed194640dd7 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 5b3989384cd6..f9e89543d26b 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/geo_location.py b/homeassistant/components/demo/geo_location.py index cc29205b7201..2af7437e0f68 100644 --- a/homeassistant/components/demo/geo_location.py +++ b/homeassistant/components/demo/geo_location.py @@ -110,7 +110,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 67a7b346a3e3..84758f0c294d 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 cdbe8dc1bd53..add04c236e76 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -98,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 9c3cf89d80ea..923092fad20a 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, @@ -63,12 +63,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/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index 2d6a127ff373..660e4c770b0c 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 5e636c5cfae5..eab4c1df3a60 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/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 3856458c3dd7..f819668f0905 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_tracker/device_condition.py b/homeassistant/components/device_tracker/device_condition.py index 1a6adabda63f..96ee70baca82 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 231fab65d352..150b58722754 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 bc792ee89207..5f2ada3a5a76 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -423,7 +423,12 @@ def async_setup_scanner_platform( hass.async_create_task(async_see_device(**kwargs)) - async_track_time_interval(hass, async_device_tracker_scan, interval) + 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)) diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index 48cb667e7308..c15b9723c972 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_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index e927ea933381..809dc9086be5 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -53,7 +53,6 @@ SENSOR_TYPES: dict[str, DevoloBinarySensorEntityDescription] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:router-network", - name="Connected to router", value_func=_is_connected_to_router, ), } diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index 79f2eb1f495b..eb6e9cf6ec6e 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 diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index a26d8dce8f6f..8b665d7bf024 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -57,4 +57,5 @@ class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]): name=entry.title, sw_version=device.firmware_version, ) + self._attr_translation_key = self.entity_description.key self._attr_unique_id = f"{device.serial_number}_{self.entity_description.key}" diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 2c2637c2f8dd..aeeab2ce89b0 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -54,7 +54,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lan", - name="Connected PLC devices", value_func=lambda data: len( {device.mac_address_from for device in data.data_rates} ), @@ -62,7 +61,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription[list[ConnectedStationInfo]]( key=CONNECTED_WIFI_CLIENTS, icon="mdi:wifi", - name="Connected Wifi clients", state_class=SensorStateClass.MEASUREMENT, value_func=len, ), @@ -71,7 +69,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:wifi-marker", - name="Neighboring Wifi networks", value_func=len, ), } diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 6c320710a1ba..3472886cd5b0 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -27,5 +27,31 @@ "home_control": "The devolo Home Control Central Unit does not work with this integration.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "connected_to_router": { + "name": "Connected to router" + } + }, + "sensor": { + "connected_plc_devices": { + "name": "Connected PLC devices" + }, + "connected_wifi_clients": { + "name": "Connected Wifi clients" + }, + "neighboring_wifi_networks": { + "name": "Neighboring Wifi networks" + } + }, + "switch": { + "switch_guest_wifi": { + "name": "Enable guest Wifi" + }, + "switch_leds": { + "name": "Enable LEDs" + } + } } } diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index fa2447985dad..6f387fdf05f4 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -42,7 +42,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { SWITCH_GUEST_WIFI: DevoloSwitchEntityDescription[WifiGuestAccessGet]( key=SWITCH_GUEST_WIFI, icon="mdi:wifi", - name="Enable guest Wifi", is_on_func=lambda data: data.enabled is True, turn_on_func=lambda device: device.device.async_set_wifi_guest_access(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_wifi_guest_access(False), # type: ignore[union-attr] @@ -51,7 +50,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { key=SWITCH_LEDS, entity_category=EntityCategory.CONFIG, icon="mdi:led-off", - name="Enable LEDs", is_on_func=bool, turn_on_func=lambda device: device.device.async_set_led_setting(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_led_setting(False), # type: ignore[union-attr] diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 74dd1f66abf9..facc80ce8e43 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -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 0765c762b86a..2e1d758746db 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.15"] } diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 0ffd6fe49efe..204992b48fa9 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -60,7 +60,6 @@ class ServiceDetails(NamedTuple): SERVICE_HANDLERS = { SERVICE_ENIGMA2: ServiceDetails("media_player", "enigma2"), "yamaha": ServiceDetails("media_player", "yamaha"), - "frontier_silicon": ServiceDetails("media_player", "frontier_silicon"), "openhome": ServiceDetails("media_player", "openhome"), "bluesound": ServiceDetails("media_player", "bluesound"), } diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index 219f0497ff0e..bcd402e6a63e 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 63bdb8fa6032..eddb2633beac 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 a4f2c2fee2dd..56215d3d9a60 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/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py index 2f57d9802b90..4903e46b8dc8 100644 --- a/homeassistant/components/dormakaba_dkey/__init__.py +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -5,7 +5,7 @@ from datetime import timedelta import logging from py_dormakaba_dkey import DKEYLock -from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS +from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS, NotAssociated from py_dormakaba_dkey.models import AssociationData from homeassistant.components import bluetooth @@ -13,7 +13,7 @@ from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackM from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_ASSOCIATION_DATA, DOMAIN, UPDATE_SECONDS @@ -60,6 +60,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await lock.update() await lock.disconnect() + except NotAssociated as ex: + raise ConfigEntryAuthFailed("Not associated") from ex except DKEY_EXCEPTIONS as ex: raise UpdateFailed(str(ex)) from ex diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 3da1fd841fd4..f03861d015ee 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Dormakaba dKey integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -12,6 +13,7 @@ from homeassistant import config_entries from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, + async_last_service_info, ) from homeassistant.const import CONF_ADDRESS from homeassistant.data_entry_flow import FlowResult @@ -32,12 +34,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None = None + def __init__(self) -> None: """Initialize the config flow.""" self._lock: DKEYLock | None = None # Populated by user step self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} - # Populated by bluetooth and user steps + # Populated by bluetooth, reauth_confirm and user steps self._discovery_info: BluetoothServiceInfoBleak | None = None async def async_step_user( @@ -113,6 +117,36 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_associate() + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle reauthorization request.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle reauthorization flow.""" + errors = {} + reauth_entry = self._reauth_entry + assert reauth_entry is not None + + if user_input is not None: + if ( + discovery_info := async_last_service_info( + self.hass, reauth_entry.data[CONF_ADDRESS], True + ) + ) is None: + errors = {"base": "no_longer_in_range"} + else: + self._discovery_info = discovery_info + return await self.async_step_associate() + + return self.async_show_form( + step_id="reauth_confirm", data_schema=vol.Schema({}), errors=errors + ) + async def async_step_associate( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -143,14 +177,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: + data = { + CONF_ADDRESS: self._discovery_info.device.address, + CONF_ASSOCIATION_DATA: association_data.to_json(), + } + if reauth_entry := self._reauth_entry: + self.hass.config_entries.async_update_entry(reauth_entry, data=data) + await self.hass.config_entries.async_reload(reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=lock.device_info.device_name or lock.device_info.device_id or lock.name, - data={ - CONF_ADDRESS: self._discovery_info.device.address, - CONF_ASSOCIATION_DATA: association_data.to_json(), - }, + data=data, ) return self.async_show_form( diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index d07deaca829e..efe9d3acb52c 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -11,6 +11,9 @@ "bluetooth_confirm": { "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" }, + "reauth_confirm": { + "description": "The activation code is no longer valid, a new unused activation code is needed.\n\n" + }, "associate": { "description": "Provide an unused activation code.\n\nTo create an activation code, create a new key in the dKey admin app, then choose to share the key and share an activation code.\n\nMake sure to close the dKey admin app before proceeding.", "data": { @@ -19,6 +22,7 @@ } }, "error": { + "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and try again.", "invalid_code": "Invalid activation code. An activation code consist of 8 characters, separated by a dash, e.g. GBZT-HXC0.", "wrong_code": "Wrong activation code. Note that an activation code can only be used once." }, @@ -26,6 +30,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } } diff --git a/homeassistant/components/dsmr_reader/config_flow.py b/homeassistant/components/dsmr_reader/config_flow.py index 2f08894d1257..44ff66636548 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 ddf149d680fe..33bba375fd3f 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 72e24c52724a..c618995ed455 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 17e28cca8840..fce274e89177 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/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 0954269628a9..803530fd6f84 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==0.2.2"] + "requirements": ["easyenergy==0.2.3"] } diff --git a/homeassistant/components/edl21/__init__.py b/homeassistant/components/edl21/__init__.py index f1cd59847444..2ece8517dbdd 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 000000000000..b66a988958b9 --- /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 000000000000..2bde0ff379a9 --- /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 dc7e861ce837..f6363473def3 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.9"] } diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 497f6867dfab..611c1b6ddfde 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,14 +334,14 @@ 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"]) @@ -336,18 +375,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,43 +397,19 @@ 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() @@ -403,6 +421,11 @@ class EDL21Entity(SensorEntity): } 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,28 +457,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): + def extra_state_attributes(self) -> Mapping[str, Any]: """Enumerate supported attributes.""" return { self._state_attrs[k]: v @@ -464,7 +472,7 @@ class EDL21Entity(SensorEntity): } @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 000000000000..284e8229c59b --- /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 0fb58319b48e..1f544a7a97b1 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/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index 58648123dcfd..e546318a4ddd 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 0dd602f1ecdc..97673a79b9a5 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -40,14 +40,14 @@ class ElgatoButtonEntityDescription( BUTTONS = [ ElgatoButtonEntityDescription( key="identify", - name="Identify", + translation_key="identify", icon="mdi:help", entity_category=EntityCategory.CONFIG, press_fn=lambda client: client.identify(), ), ElgatoButtonEntityDescription( key="restart", - name="Restart", + translation_key="restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_fn=lambda client: client.restart(), diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index 2692cf10850a..371840de013a 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 fc0007ac3016..c5fc016aeb90 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 00159099718f..78af3adfa539 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/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index d5e677abcc97..dc7159001d80 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 41c25943a772..f779f5d8e946 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/energy/types.py b/homeassistant/components/energy/types.py index 9a599cb9a59a..819ed6ac5a8e 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 a2c3ad094da7..0a89c3d92706 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -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 2075d0000330..7830d3649f26 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 8bdfb36ad750..05d23ca44645 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/switch.py b/homeassistant/components/enocean/switch.py index 28727bfb7670..11ca8a2a625e 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: diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index cd3235f1be5c..4a105e5a067d 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 f5a954b16d41..e109c25d3403 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -183,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 c2c2485d9480..8e1f17492fba 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env_canada==0.5.29"] + "requirements": ["env_canada==0.5.30"] } diff --git a/homeassistant/components/esera_onewire/__init__.py b/homeassistant/components/esera_onewire/__init__.py new file mode 100644 index 000000000000..1adcf6cf63af --- /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 000000000000..8d5e944c5c11 --- /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 59db885d4508..192a19e480be 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -345,6 +345,10 @@ async def async_setup_entry( # noqa: C901 disconnect_cb() entry_data.disconnect_callbacks = [] entry_data.available = False + # Clear out the states so that we will always dispatch + # the next state update of that type when the device reconnects + for state_keys in entry_data.state.values(): + state_keys.clear() entry_data.async_update_device_state(hass) async def on_connect_error(err: Exception) -> None: @@ -760,7 +764,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, ) ) diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index 4a70b906b1fc..e62b54655c88 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 7eb38edbf444..914021b467ef 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/cover.py b/homeassistant/components/esphome/cover.py index 99d5f16b271b..9d82b2852916 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from aioesphomeapi import CoverInfo, CoverOperation, CoverState +from aioesphomeapi import APIVersion, CoverInfo, CoverOperation, CoverState from homeassistant.components.cover import ( ATTR_POSITION, @@ -41,9 +41,10 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" - flags = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP - ) + flags = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + if self._api_version < APIVersion(1, 8) or self._static_info.supports_stop: + flags |= CoverEntityFeature.STOP if self._static_info.supports_position: flags |= CoverEntityFeature.SET_POSITION if self._static_info.supports_tilt: diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index a389d7464351..d7f25f319ac0 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -39,6 +39,7 @@ from homeassistant.helpers.storage import Store from .dashboard import async_get_dashboard +_SENTINEL = object() SAVE_DELAY = 120 _LOGGER = logging.getLogger(__name__) @@ -203,14 +204,26 @@ 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) + current_state_by_type = self.state[state_type] + current_state = current_state_by_type.get(key, _SENTINEL) + if current_state == state: + _LOGGER.debug( + "%s: ignoring duplicate update with and key %s: %s", + self.name, + key, + state, + ) + return _LOGGER.debug( "%s: dispatching update with key %s: %s", self.name, - subscription_key, + key, state, ) + current_state_by_type[key] = state + subscription_key = (state_type, key) if subscription_key in self.state_subscriptions: self.state_subscriptions[subscription_key]() diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 95b6c091d5f6..bf3e269221ed 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.5.1", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.6.1", "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 f8566e863c6b..673a90580e0e 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -115,7 +115,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/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index fbd49102f3c3..489ff97eb4a6 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 65b5df100dd1..0456e7ade9e6 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 4c8b1418fa51..77598ad6a1c7 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 b9183772b6c3..d052a4b82166 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 cc4537bb9b94..ba8ed336a51c 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 91fa32ad9b2f..5e258e427057 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 7e27ea29f98f..d4bd5f2e419d 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 fdd95a822de1..b16d6da6df56 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/file/notify.py b/homeassistant/components/file/notify.py index 4a0b4c11ca63..3238fe911029 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 9e08615d4ab8..73f060e79b70 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 3f58e636b0e6..8633e6ec466f 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/fints/sensor.py b/homeassistant/components/fints/sensor.py index 6ef0467f7b65..479e59d9cdf2 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 8a80ac610f72..d746e63ca522 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 d703699a4338..c53c01c84a75 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 31e23565a6f6..9afe5890162e 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/flipr/manifest.json b/homeassistant/components/flipr/manifest.json index e7b9c8bf8142..73a0b3edb26d 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/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 86b73c762fb6..e6f89536baf6 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -209,7 +209,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 5bb47fbe7988..a9b1ef61db59 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.36"] } diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index 31a199ab88d4..96decd0b8cf6 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/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index d42c72b65d1c..e1f1ece055bd 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 65f63829c05e..95a418ae40fa 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 fe11b056880b..ae28fd8d111a 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/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index d355906ec6eb..d2edb99e0265 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,16 @@ async def async_setup_entry( async_add_entities(entities, True) -class FritzBoxBinarySensor(FritzBoxBaseEntity, BinarySensorEntity): +class FritzBoxBinarySensor(FritzBoxBaseCoordinatorEntity, BinarySensorEntity): """Define FRITZ!Box connectivity class.""" - def __init__( - self, - avm_wrapper: AvmWrapper, - device_friendly_name: str, - description: BinarySensorEntityDescription, - ) -> None: - """Init FRITZ!Box connectivity class.""" - self.entity_description = description - self._attr_name = f"{device_friendly_name} {description.name}" - self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" - super().__init__(avm_wrapper, device_friendly_name) + entity_description: FritzBinarySensorEntityDescription - def update(self) -> None: - """Update data.""" - _LOGGER.debug("Updating FRITZ!Box binary sensors") - - if self.entity_description.key == "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.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 a186d50960ec..f732e32b75ab 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 09103a0bcc84..89a51581bf70 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -35,7 +35,8 @@ from homeassistant.helpers import ( update_coordinator, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from .const import ( @@ -136,7 +137,9 @@ class HostInfo(TypedDict): status: bool -class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): +class FritzBoxTools( + update_coordinator.DataUpdateCoordinator[dict[str, bool | StateType]] +): """FritzBoxTools class.""" def __init__( @@ -175,6 +178,9 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): self._latest_firmware: str | None = None self._update_available: bool = False self._release_url: str | None = None + self._entity_update_functions: dict[ + str, Callable[[FritzStatus, StateType], Any] + ] = {} async def async_setup( self, options: MappingProxyType[str, Any] | None = None @@ -237,12 +243,36 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): ) self.device_is_router = self.fritz_status.has_wan_enabled - async def _async_update_data(self) -> None: + def register_entity_updates( + self, key: str, update_fn: Callable[[FritzStatus, StateType], Any] + ) -> Callable[[], None]: + """Register an entity to be updated by coordinator.""" + + def unregister_entity_updates() -> None: + """Unregister an entity to be updated by coordinator.""" + if key in self._entity_update_functions: + _LOGGER.debug("unregister entity %s from updates", key) + self._entity_update_functions.pop(key) + + if key not in self._entity_update_functions: + _LOGGER.debug("register entity %s for updates", key) + self._entity_update_functions[key] = update_fn + return unregister_entity_updates + + async def _async_update_data(self) -> dict[str, bool | StateType]: """Update FritzboxTools data.""" + enity_data: dict[str, bool | StateType] = {} try: await self.async_scan_devices() + for key, update_fn in self._entity_update_functions.items(): + _LOGGER.debug("update entity %s", key) + enity_data[key] = await self.hass.async_add_executor_job( + update_fn, self.fritz_status, self.data.get(key) + ) except FRITZ_EXCEPTIONS as ex: raise update_coordinator.UpdateFailed(ex) from ex + _LOGGER.debug("enity_data: %s", enity_data) + return enity_data @property def unique_id(self) -> str: @@ -981,6 +1011,54 @@ class FritzBoxBaseEntity: ) +@dataclass +class FritzRequireKeysMixin: + """Fritz entity description mix in.""" + + value_fn: Callable[[FritzStatus, Any], Any] + + +@dataclass +class FritzEntityDescription(EntityDescription, FritzRequireKeysMixin): + """Fritz entity base description.""" + + +class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity): + """Fritz host coordinator entity base class.""" + + coordinator: AvmWrapper + entity_description: FritzEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + avm_wrapper: AvmWrapper, + device_name: str, + description: FritzEntityDescription, + ) -> None: + """Init device info class.""" + super().__init__(avm_wrapper) + self.async_on_remove( + avm_wrapper.register_entity_updates(description.key, description.value_fn) + ) + self.entity_description = description + self._device_name = device_name + self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return DeviceInfo( + configuration_url=f"http://{self.coordinator.host}", + connections={(dr.CONNECTION_NETWORK_MAC, self.coordinator.mac)}, + identifiers={(DOMAIN, self.coordinator.unique_id)}, + manufacturer="AVM", + model=self.coordinator.model, + name=self._device_name, + sw_version=self.coordinator.current_firmware, + ) + + @dataclass class ConnectionInfo: """Fritz sensor connection information class.""" diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index 212710a638cd..e32ee1527969 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/sensor.py b/homeassistant/components/fritz/sensor.py index 628d56dc4508..2b156046098d 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -5,9 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta import logging -from typing import Any -from fritzconnection.core.exceptions import FritzConnectionException from fritzconnection.lib.fritzstatus import FritzStatus from homeassistant.components.sensor import ( @@ -25,9 +23,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity +from .common import ( + AvmWrapper, + ConnectionInfo, + FritzBoxBaseCoordinatorEntity, + FritzEntityDescription, +) from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION _LOGGER = logging.getLogger(__name__) @@ -139,14 +143,7 @@ def _retrieve_link_attenuation_received_state( @dataclass -class FritzRequireKeysMixin: - """Fritz sensor data class.""" - - value_fn: Callable[[FritzStatus, Any], Any] - - -@dataclass -class FritzSensorEntityDescription(SensorEntityDescription, FritzRequireKeysMixin): +class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescription): """Describes Fritz sensor entity.""" is_suitable: Callable[[ConnectionInfo], bool] = lambda info: info.wan_enabled @@ -155,20 +152,20 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzRequireKeysMixi SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( FritzSensorEntityDescription( key="external_ip", - name="External IP", + translation_key="external_ip", icon="mdi:earth", value_fn=_retrieve_external_ip_state, ), FritzSensorEntityDescription( key="external_ipv6", - name="External IPv6", + translation_key="external_ipv6", icon="mdi:earth", value_fn=_retrieve_external_ipv6_state, is_suitable=lambda info: info.ipv6_active, ), FritzSensorEntityDescription( key="device_uptime", - name="Device Uptime", + translation_key="device_uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_device_uptime_state, @@ -176,14 +173,14 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="connection_uptime", - name="Connection Uptime", + translation_key="connection_uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_connection_uptime_state, ), FritzSensorEntityDescription( key="kb_s_sent", - name="Upload Throughput", + translation_key="kb_s_sent", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, @@ -192,7 +189,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="kb_s_received", - name="Download Throughput", + translation_key="kb_s_received", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, @@ -201,7 +198,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="max_kb_s_sent", - name="Max Connection Upload Throughput", + translation_key="max_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", @@ -210,7 +207,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="max_kb_s_received", - name="Max Connection Download Throughput", + translation_key="max_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", @@ -219,7 +216,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="gb_sent", - name="GB sent", + translation_key="gb_sent", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -228,7 +225,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="gb_received", - name="GB received", + translation_key="gb_received", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -237,7 +234,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_kb_s_sent", - name="Link Upload Throughput", + translation_key="link_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", @@ -245,7 +242,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_kb_s_received", - name="Link Download Throughput", + translation_key="link_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", @@ -253,7 +250,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_noise_margin_sent", - name="Link Upload Noise Margin", + translation_key="link_noise_margin_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:upload", value_fn=_retrieve_link_noise_margin_sent_state, @@ -261,7 +258,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_noise_margin_received", - name="Link Download Noise Margin", + translation_key="link_noise_margin_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:download", value_fn=_retrieve_link_noise_margin_received_state, @@ -269,7 +266,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_attenuation_sent", - name="Link Upload Power Attenuation", + translation_key="link_attenuation_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:upload", value_fn=_retrieve_link_attenuation_sent_state, @@ -277,7 +274,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( ), FritzSensorEntityDescription( key="link_attenuation_received", - name="Link Download Power Attenuation", + translation_key="link_attenuation_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:download", value_fn=_retrieve_link_attenuation_received_state, @@ -304,36 +301,12 @@ async def async_setup_entry( async_add_entities(entities, True) -class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): +class FritzBoxSensor(FritzBoxBaseCoordinatorEntity, SensorEntity): """Define FRITZ!Box connectivity class.""" entity_description: FritzSensorEntityDescription - def __init__( - self, - avm_wrapper: AvmWrapper, - device_friendly_name: str, - description: FritzSensorEntityDescription, - ) -> None: - """Init FRITZ!Box connectivity class.""" - self.entity_description = description - self._last_device_value: str | None = None - self._attr_available = True - self._attr_name = f"{device_friendly_name} {description.name}" - self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" - super().__init__(avm_wrapper, device_friendly_name) - - def update(self) -> None: - """Update data.""" - _LOGGER.debug("Updating FRITZ!Box sensors") - - status: FritzStatus = self._avm_wrapper.fritz_status - try: - self._attr_native_value = ( - self._last_device_value - ) = self.entity_description.value_fn(status, self._last_device_value) - except FritzConnectionException: - _LOGGER.error("Error getting the state from the FRITZ!Box", exc_info=True) - self._attr_available = False - return - self._attr_available = True + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data.get(self.entity_description.key) diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 2d76479f2b9b..45262d6f8ac1 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -52,5 +52,39 @@ } } } + }, + "entity": { + "binary_sensor": { + "is_connected": { "name": "Connection" }, + "is_linked": { "name": "Link" } + }, + "button": { + "cleanup": { "name": "Cleanup" }, + "firmware_update": { "name": "Firmware update" }, + "reboot": { + "name": "[%key:component::button::entity_component::restart::name%]" + }, + "reconnect": { "name": "Reconnect" } + }, + "sensor": { + "connection_uptime": { "name": "Connection uptime" }, + "device_uptime": { "name": "Last restart" }, + "external_ip": { "name": "External IP" }, + "external_ipv6": { "name": "External IPv6" }, + "gb_received": { "name": "GB received" }, + "gb_sent": { "name": "GB sent" }, + "kb_s_received": { "name": "Download throughput" }, + "kb_s_sent": { "name": "Upload throughput" }, + "link_attenuation_received": { + "name": "Link download power attenuation" + }, + "link_attenuation_sent": { "name": "Link upload power attenuation" }, + "link_kb_s_received": { "name": "Link download throughput" }, + "link_kb_s_sent": { "name": "Link upload throughput" }, + "link_noise_margin_received": { "name": "Link download noise margin" }, + "link_noise_margin_sent": { "name": "Link upload noise margin" }, + "max_kb_s_received": { "name": "Max connection download throughput" }, + "max_kb_s_sent": { "name": "Max connection upload throughput" } + } } } diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index fc65ed96459c..bd246dd914fc 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 35fd41240dfc..f87beb34079c 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 24431f78aca1..f83dd4545924 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 e604f1d37b56..29df2f51a340 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 ea63ab983c12..3c3275e0ff00 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 4d045c2c98d7..33b4b8d51527 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,7 +156,7 @@ 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, suitable=suitable_comfort_temperature, @@ -175,7 +164,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="eco_temperature", - name="Eco Temperature", + translation_key="eco_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, suitable=suitable_eco_temperature, @@ -183,7 +172,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="nextchange_temperature", - name="Next Scheduled Temperature", + translation_key="nextchange_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, suitable=suitable_nextchange_temperature, @@ -191,20 +180,20 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( ), FritzSensorEntityDescription( key="nextchange_time", - name="Next Scheduled Change Time", + translation_key="nextchange_time", device_class=SensorDeviceClass.TIMESTAMP, 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", suitable=suitable_nextchange_temperature, native_value=value_nextchange_preset, ), FritzSensorEntityDescription( key="scheduled_preset", - name="Current Scheduled Preset", + translation_key="scheduled_preset", suitable=suitable_nextchange_temperature, native_value=value_scheduled_preset, ), diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index 738c454e237d..0b4becd6ff7e 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 386e60ba1990..df19bca7b13c 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/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py index 16e55f12726d..94fd5f256aad 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 8c7055db8b5f..4e706db032fc 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 711e363eebaa..4a0f96ed8e61 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 b152b2d65d8f..8c04e5919683 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 2c13e81ee3c2..8417870eb0ae 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230309.1"] + "requirements": ["home-assistant-frontend==20230405.0"] } diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py index ddd74ca8efe5..4a884063f83c 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 PlatformNotReady + +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 PlatformNotReady 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 000000000000..0ccc61e99c10 --- /dev/null +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -0,0 +1,243 @@ +"""Config flow for Frontier Silicon Media Player integration.""" +from __future__ import annotations + +import logging +from typing import Any +from urllib.parse import urlparse + +from afsapi import AFSAPI, ConnectionError as FSConnectionError, InvalidPinException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.data_entry_flow import FlowResult + +from .const import ( + CONF_PIN, + CONF_WEBFSAPI_URL, + DEFAULT_PIN, + DEFAULT_PORT, + DOMAIN, + 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 + + 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") + + try: + afsapi = AFSAPI(webfsapi_url, import_info[CONF_PIN]) + + unique_id = await afsapi.get_radio_id() + 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: + # try to login with default pin + afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) + + unique_id = await afsapi.get_radio_id() + except InvalidPinException: + return self.async_abort(reason="invalid_auth") + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONF_WEBFSAPI_URL: self._webfsapi_url}, reload_on_update=True + ) + + self._name = await afsapi.get_friendly_name() + + return await self.async_step_confirm() + + async def _async_step_device_config_if_needed(self) -> FlowResult: + """Most users will not have changed the default PIN on their radio. + + 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} + + unique_id = await afsapi.get_radio_id() + 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_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: + unique_id = await afsapi.get_radio_id() + 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 9ee17c0320e1..34201fe8f4a3 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 322c1b90b264..9cc928e6f883 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 0e3eb168484a..54c17429b56e 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 000000000000..a7c3f3e439cb --- /dev/null +++ b/homeassistant/components/frontier_silicon/strings.json @@ -0,0 +1,36 @@ +{ + "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%]" + } + }, + "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 ae6cf083ed11..8c73d47dd743 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 cf7dd62decc1..60009eb6ae4d 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/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py index 06ab1aa08378..1d3dabc464ce 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 4db9d2fc8938..86904e3e9bc4 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 531eb05dcf9e..6563e26368ab 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_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 8ed2711d7cd0..73d876b354fd 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -22,6 +22,8 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, callback from homeassistant.helpers import condition @@ -175,6 +177,15 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): async def _async_startup(event): """Init on startup.""" sensor_state = self.hass.states.get(self._sensor_entity_id) + if sensor_state is None or sensor_state.state in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ): + _LOGGER.debug( + "The sensor state is %s, initialization is delayed", + sensor_state.state if sensor_state is not None else "None", + ) + return await self._async_sensor_changed(self._sensor_entity_id, None, sensor_state) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) @@ -436,7 +447,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. diff --git a/homeassistant/components/geo_json_events/const.py b/homeassistant/components/geo_json_events/const.py new file mode 100644 index 000000000000..4c73be3995ec --- /dev/null +++ b/homeassistant/components/geo_json_events/const.py @@ -0,0 +1,15 @@ +"""Define constants for the GeoJSON events integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Final + +DOMAIN: Final = "geo_json_events" + +ATTR_EXTERNAL_ID: Final = "external_id" +DEFAULT_RADIUS_IN_KM: Final = 20.0 +DEFAULT_SCAN_INTERVAL: Final = timedelta(minutes=5) +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 166da1184c67..df2978b654e4 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from datetime import datetime, timedelta +from datetime import timedelta import logging from typing import Any @@ -21,26 +21,23 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import aiohttp_client 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.typing import ConfigType, DiscoveryInfoType +from .const import ( + ATTR_EXTERNAL_ID, + DEFAULT_RADIUS_IN_KM, + DEFAULT_SCAN_INTERVAL, + SIGNAL_DELETE_ENTITY, + SIGNAL_UPDATE_ENTITY, + SOURCE, +) +from .manager import GeoJsonFeedEntityManager + _LOGGER = logging.getLogger(__name__) -ATTR_EXTERNAL_ID = "external_id" - -DEFAULT_RADIUS_IN_KM = 20.0 - -SCAN_INTERVAL = timedelta(minutes=5) - -SOURCE = "geo_json_events" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_URL): cv.string, @@ -59,7 +56,7 @@ async def async_setup_platform( ) -> None: """Set up the GeoJSON Events platform.""" url: str = config[CONF_URL] - scan_interval: timedelta = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + scan_interval: timedelta = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) coordinates: tuple[float, float] = ( config.get(CONF_LATITUDE, hass.config.latitude), config.get(CONF_LONGITUDE, hass.config.longitude), @@ -67,8 +64,21 @@ async def async_setup_platform( radius_in_km: float = config[CONF_RADIUS] # Initialize the entity manager. manager = GeoJsonFeedEntityManager( - hass, async_add_entities, scan_interval, coordinates, url, radius_in_km + hass, scan_interval, coordinates, url, radius_in_km ) + + @callback + def async_add_geolocation( + feed_manager: GenericFeedManager, + 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) + + async_dispatcher_connect(hass, manager.signal_new_entity, async_add_geolocation) + await manager.async_init() async def start_feed_manager(event: Event) -> None: @@ -78,71 +88,8 @@ async def async_setup_platform( 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, - ) - 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 @@ -159,12 +106,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 000000000000..a999d224ac78 --- /dev/null +++ b/homeassistant/components/geo_json_events/manager.py @@ -0,0 +1,84 @@ +"""Entity manager for generic GeoJSON events.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging + +from aio_geojson_generic_client import GenericFeedManager +from aio_geojson_generic_client.feed_entry import GenericFeedEntry + +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 DOMAIN, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY + +_LOGGER = logging.getLogger(__name__) + + +class GeoJsonFeedEntityManager: + """Feed Entity Manager for GeoJSON feeds.""" + + def __init__( + self, + hass: HomeAssistant, + 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, + ) + self._scan_interval = scan_interval + self.signal_new_entity = ( + f"{DOMAIN}_new_geolocation_{coordinates}-{url}-{radius_in_km}" + ) + + 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.""" + 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_location/strings.json b/homeassistant/components/geo_location/strings.json new file mode 100644 index 000000000000..4678790b520e --- /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 cc47883d05a8..892116121a0e 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 411a0375461c..6fa84f590f1f 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 9183aead1690..8fb2ff8535b4 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 25e02f44308c..33a879eeb255 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 1ade1a83cc7c..213fabc911bf 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 0fa5052e1291..a1b4abd2dc79 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 895775495f96..33ddfae6fe1a 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 6b3051a4bdd8..41954645f5c4 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 cabbb671aedc..f078cc074e9c 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 18db42b69c01..391dd8744261 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": "Particulate matter 10 μm 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": "Particulate matter 2.5 μm 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/glances/sensor.py b/homeassistant/components/glances/sensor.py index e0eaf3bb38a7..b8b5d80a2066 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -21,7 +21,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 import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -257,7 +257,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 diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 53e093758cc3..45d02dcd2e3d 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.30"] } diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index 9f997daec40c..3f9714aa372a 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 b4adf97c3e7d..d76d62028329 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -19,13 +19,16 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + POWER_VOLT_AMPERE_REACTIVE, EntityCategory, + UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfFrequency, UnitOfPower, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo @@ -111,6 +114,20 @@ _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { value=lambda coordinator, sensor: coordinator.total_sensor_value(sensor), available=lambda coordinator: coordinator.data is not None, ), + "VA": GoodweSensorEntityDescription( + key="VA", + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + entity_registry_enabled_default=False, + ), + "var": GoodweSensorEntityDescription( + key="var", + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + entity_registry_enabled_default=False, + ), "C": GoodweSensorEntityDescription( key="C", device_class=SensorDeviceClass.TEMPERATURE, @@ -123,6 +140,13 @@ _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, ), + "h": GoodweSensorEntityDescription( + key="h", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_registry_enabled_default=False, + ), "%": GoodweSensorEntityDescription( key="%", state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 934b34c126b2..25993760d807 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, @@ -290,6 +292,7 @@ async def async_setup_add_event_service( Event( summary=call.data[EVENT_SUMMARY], description=call.data[EVENT_DESCRIPTION], + location=call.data[EVENT_LOCATION], start=start, end=end, ), diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index d20155ad9090..363b75c2c54b 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 @@ -507,6 +508,7 @@ class GoogleCalendarEntity( "start": start, "end": end, EVENT_DESCRIPTION: kwargs.get(EVENT_DESCRIPTION), + EVENT_LOCATION: kwargs.get(EVENT_LOCATION), } ) if rrule := kwargs.get(EVENT_RRULE): @@ -603,6 +605,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> Event( summary=call.data[EVENT_SUMMARY], description=call.data[EVENT_DESCRIPTION], + location=call.data[EVENT_LOCATION], start=start, end=end, ), diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index 6a2c1974f665..add98441e39f 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/services.yaml b/homeassistant/components/google/services.yaml index a303ad7e18d2..e7eeef759475 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 20c4ab60e88f..bf511f8eaebb 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 196fa580ea82..e194242df91a 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_mail/notify.py b/homeassistant/components/google_mail/notify.py index eba38c324916..974b2e4e4bfe 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/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index a452d32e5441..317f2619beff 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 62189fdde063..01f98b996ddd 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 815e3b76f0b9..112b111bdca5 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 069f74bf7070..53a8fd062641 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 2ecfbeaca42d..38928302eb11 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 682890dddd60..0c4c59d24545 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 6315e79d61a9..33d240a9a4d1 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 9c39e1455286..07d08c7851d5 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 a349a6280040..15be22ddfbff 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 7e8ce9236493..378a7852343b 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 6c379832ced7..4c6e8dccc1eb 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 75a2423d9327..9f5054546812 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 8b60e1f14025..4b6b959ba17a 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 cb6e6cee7213..cb221d49417c 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 6cf1a6d46040..77e1d0f7d33f 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -568,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 diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index 010f65cd114c..ff41c6e4936e 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 cd83f684eac6..3d4a87b04074 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/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index 259ea660317a..d861068629ff 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/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 3ea42e331d4f..e6ff9888b159 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -69,6 +69,7 @@ from .const import ( ATTR_VERSION, DATA_KEY_ADDONS, DATA_KEY_CORE, + DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, DOMAIN, @@ -115,11 +116,13 @@ 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" @@ -229,6 +232,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 +244,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 +309,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): @@ -645,6 +674,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 +791,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 +812,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 +857,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/const.py b/homeassistant/components/hassio/const.py index 2710e146540d..cc9c58a3d27e 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -68,6 +68,7 @@ DATA_KEY_ADDONS = "addons" DATA_KEY_OS = "os" DATA_KEY_SUPERVISOR = "supervisor" DATA_KEY_CORE = "core" +DATA_KEY_HOST = "host" class SupervisorEntityModel(str, Enum): @@ -77,3 +78,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 6d936c6ce2f1..29cb53de70f4 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -130,4 +130,4 @@ class HassIODiscovery(HomeAssistantView): for entry in self.hass.config_entries.async_entries(service): if entry.source != config_entries.SOURCE_HASSIO: 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 dfa89ae911ab..3a6a5a9f7c30 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 762df4f79ca1..d7af26851d05 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -319,6 +319,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 +335,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. diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py index 31e728a97367..b9a97adcbc2c 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,9 +19,18 @@ 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( @@ -35,7 +45,7 @@ COMMON_ENTITY_DESCRIPTIONS = ( ), ) -ADDON_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + ( +STATS_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( entity_registry_enabled_default=False, key=ATTR_CPU_PERCENT, @@ -54,7 +64,49 @@ ADDON_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + ( ), ) +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", + name="OS Agent version", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key="apparmor_version", + name="Apparmor version", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key="disk_total", + name="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", + name="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", + name="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/hdmi_cec/media_player.py b/homeassistant/components/hdmi_cec/media_player.py index 25019ec6933c..df7df830fdb6 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 4184e9f82b75..9ad33caf0734 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 05d620583510..36f2f8945c01 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -168,7 +168,7 @@ class HistoryPeriodView(HomeAssistantView): """Fetch significant stats from the database as json.""" timer_start = time.perf_counter() - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: states = history.get_significant_states_with_session( hass, session, diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 5d0eb59942b2..a761021de553 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -189,21 +189,78 @@ 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, + filters: Filters | 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, + filters, + 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( @@ -221,43 +278,24 @@ 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, + filters, + 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]: diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 33f32e72292d..d9b331d82bb7 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] @@ -125,12 +125,12 @@ class HistoryStats: await self._async_history_from_db(current_period_start, current_period_end) 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( @@ -162,10 +162,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 +195,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 23143984f485..0c914e1fd415 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 fc3aedfde24c..2b02be17e9aa 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -163,13 +163,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 c9ee93634b2f..df1189f9e761 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 4d309fe68470..76d75e517254 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 833894726077..4920e1542d5e 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 f50ab7115509..85d8abd1cba2 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -145,11 +145,14 @@ class HomeConnectDevice: class DeviceWithPrograms(HomeConnectDevice): """Device with programs.""" - PROGRAMS: list[dict[str, str]] = [] - def get_programs_available(self): """Get the available programs.""" - return self.PROGRAMS + try: + programs_available = self.appliance.get_programs_available() + except (HomeConnectError, ValueError): + _LOGGER.debug("Unable to fetch available programs. Probably offline") + programs_available = None + return programs_available def get_program_switches(self): """Get a dictionary with info about program switches. @@ -157,7 +160,7 @@ class DeviceWithPrograms(HomeConnectDevice): There will be one switch for each program. """ programs = self.get_programs_available() - return [{ATTR_DEVICE: self, "program_name": p["name"]} for p in programs] + return [{ATTR_DEVICE: self, "program_name": p} for p in programs] def get_program_sensors(self): """Get a dictionary with info about program sensors. @@ -265,27 +268,6 @@ class Dryer( ): """Dryer class.""" - PROGRAMS = [ - {"name": "LaundryCare.Dryer.Program.Cotton"}, - {"name": "LaundryCare.Dryer.Program.Synthetic"}, - {"name": "LaundryCare.Dryer.Program.Mix"}, - {"name": "LaundryCare.Dryer.Program.Blankets"}, - {"name": "LaundryCare.Dryer.Program.BusinessShirts"}, - {"name": "LaundryCare.Dryer.Program.DownFeathers"}, - {"name": "LaundryCare.Dryer.Program.Hygiene"}, - {"name": "LaundryCare.Dryer.Program.Jeans"}, - {"name": "LaundryCare.Dryer.Program.Outdoor"}, - {"name": "LaundryCare.Dryer.Program.SyntheticRefresh"}, - {"name": "LaundryCare.Dryer.Program.Towels"}, - {"name": "LaundryCare.Dryer.Program.Delicates"}, - {"name": "LaundryCare.Dryer.Program.Super40"}, - {"name": "LaundryCare.Dryer.Program.Shirts15"}, - {"name": "LaundryCare.Dryer.Program.Pillow"}, - {"name": "LaundryCare.Dryer.Program.AntiShrink"}, - {"name": "LaundryCare.Dryer.Program.TimeCold"}, - {"name": "LaundryCare.Dryer.Program.TimeWarm"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() @@ -311,32 +293,6 @@ class Dishwasher( ): """Dishwasher class.""" - PROGRAMS = [ - {"name": "Dishcare.Dishwasher.Program.Auto1"}, - {"name": "Dishcare.Dishwasher.Program.Auto2"}, - {"name": "Dishcare.Dishwasher.Program.Auto3"}, - {"name": "Dishcare.Dishwasher.Program.Eco50"}, - {"name": "Dishcare.Dishwasher.Program.Quick45"}, - {"name": "Dishcare.Dishwasher.Program.Intensiv70"}, - {"name": "Dishcare.Dishwasher.Program.Normal65"}, - {"name": "Dishcare.Dishwasher.Program.Glas40"}, - {"name": "Dishcare.Dishwasher.Program.GlassCare"}, - {"name": "Dishcare.Dishwasher.Program.PreRinse"}, - {"name": "Dishcare.Dishwasher.Program.NightWash"}, - {"name": "Dishcare.Dishwasher.Program.Quick65"}, - {"name": "Dishcare.Dishwasher.Program.Normal45"}, - {"name": "Dishcare.Dishwasher.Program.Intensiv45"}, - {"name": "Dishcare.Dishwasher.Program.AutoHalfLoad"}, - {"name": "Dishcare.Dishwasher.Program.IntensivPower"}, - {"name": "Dishcare.Dishwasher.Program.MagicDaily"}, - {"name": "Dishcare.Dishwasher.Program.Super60"}, - {"name": "Dishcare.Dishwasher.Program.Kurz60"}, - {"name": "Dishcare.Dishwasher.Program.ExpressSparkle65"}, - {"name": "Dishcare.Dishwasher.Program.MachineCare"}, - {"name": "Dishcare.Dishwasher.Program.SteamFresh"}, - {"name": "Dishcare.Dishwasher.Program.MaximumCleaning"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() @@ -361,14 +317,6 @@ class Oven( ): """Oven class.""" - PROGRAMS = [ - {"name": "Cooking.Oven.Program.HeatingMode.PreHeating"}, - {"name": "Cooking.Oven.Program.HeatingMode.HotAir"}, - {"name": "Cooking.Oven.Program.HeatingMode.TopBottomHeating"}, - {"name": "Cooking.Oven.Program.HeatingMode.PizzaSetting"}, - {"name": "Cooking.Oven.Program.Microwave.600Watt"}, - ] - power_off_state = BSH_POWER_STANDBY def get_entity_info(self): @@ -395,30 +343,6 @@ class Washer( ): """Washer class.""" - PROGRAMS = [ - {"name": "LaundryCare.Washer.Program.Cotton"}, - {"name": "LaundryCare.Washer.Program.Cotton.CottonEco"}, - {"name": "LaundryCare.Washer.Program.EasyCare"}, - {"name": "LaundryCare.Washer.Program.Mix"}, - {"name": "LaundryCare.Washer.Program.DelicatesSilk"}, - {"name": "LaundryCare.Washer.Program.Wool"}, - {"name": "LaundryCare.Washer.Program.Sensitive"}, - {"name": "LaundryCare.Washer.Program.Auto30"}, - {"name": "LaundryCare.Washer.Program.Auto40"}, - {"name": "LaundryCare.Washer.Program.Auto60"}, - {"name": "LaundryCare.Washer.Program.Chiffon"}, - {"name": "LaundryCare.Washer.Program.Curtains"}, - {"name": "LaundryCare.Washer.Program.DarkWash"}, - {"name": "LaundryCare.Washer.Program.Dessous"}, - {"name": "LaundryCare.Washer.Program.Monsoon"}, - {"name": "LaundryCare.Washer.Program.Outdoor"}, - {"name": "LaundryCare.Washer.Program.PlushToy"}, - {"name": "LaundryCare.Washer.Program.ShirtsBlouses"}, - {"name": "LaundryCare.Washer.Program.SportFitness"}, - {"name": "LaundryCare.Washer.Program.Towels"}, - {"name": "LaundryCare.Washer.Program.WaterProof"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() @@ -437,23 +361,6 @@ class Washer( class CoffeeMaker(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteStart): """Coffee maker class.""" - PROGRAMS = [ - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado"}, - ] - power_off_state = BSH_POWER_STANDBY def get_entity_info(self): @@ -479,12 +386,6 @@ class Hood( ): """Hood class.""" - PROGRAMS = [ - {"name": "Cooking.Common.Program.Hood.Automatic"}, - {"name": "Cooking.Common.Program.Hood.Venting"}, - {"name": "Cooking.Common.Program.Hood.DelayedShutOff"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" remote_control = self.get_remote_control() @@ -532,8 +433,6 @@ class Freezer(DeviceWithDoor): class Hob(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteControl): """Hob class.""" - PROGRAMS = [{"name": "Cooking.Hob.Program.PowerLevelMode"}] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" remote_control = self.get_remote_control() diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 5602fd6b59a8..91dd742e802b 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -30,6 +30,7 @@ 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 ATTR_ENTRY_ID = "entry_id" @@ -38,6 +39,7 @@ _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 +260,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 +298,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 +327,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) diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index da52ff50d2f5..2fe27769c3fb 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/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index 0796d49d770b..d84b04c36527 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -80,11 +80,11 @@ 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 @@ -94,8 +94,12 @@ async def async_attach_trigger( event_context_schema(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 e3dc93a9788a..51686e54c55c 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 53d3fb1217f2..d822cd523fc6 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 25622e0a3c63..7fc780d7976c 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 f5473d66a5b2..a29cb5ff6da8 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 2a5022bebf3e..63f9b18cf9b1 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 7012111ed615..ffc0594baf3b 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__) @@ -85,7 +87,19 @@ 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) diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 20fdb97e3842..ff2bf9138f53 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -8,6 +8,7 @@ import logging from typing import Any import voluptuous as vol +import yarl from homeassistant import config_entries from homeassistant.components.hassio import ( @@ -15,6 +16,7 @@ from homeassistant.components.hassio import ( AddonInfo, AddonManager, AddonState, + hostname_from_addon_slug, is_hassio, ) from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN @@ -26,6 +28,7 @@ from homeassistant.data_entry_flow import ( FlowManager, FlowResult, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.singleton import singleton from .const import LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG @@ -63,12 +66,20 @@ class SerialPortSettings: flow_control: bool -def get_zigbee_socket(hass: HomeAssistant, addon_info: AddonInfo) -> str: +def get_zigbee_socket() -> str: """Return the zigbee socket. Raises AddonError on error """ - return f"socket://{addon_info.hostname}:9999" + hostname = hostname_from_addon_slug(SILABS_MULTIPROTOCOL_ADDON_SLUG) + return f"socket://{hostname}:9999" + + +def is_multiprotocol_url(url: str) -> bool: + """Return if the URL points at the Multiprotocol add-on.""" + parsed = yarl.URL(url) + hostname = hostname_from_addon_slug(SILABS_MULTIPROTOCOL_ADDON_SLUG) + return parsed.host == hostname class BaseMultiPanFlow(FlowHandler, ABC): @@ -289,7 +300,7 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): "new_discovery_info": { "name": self._zha_name(), "port": { - "path": get_zigbee_socket(self.hass, addon_info), + "path": get_zigbee_socket(), }, "radio_type": "ezsp", }, @@ -356,3 +367,51 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): if user_input is None: return self.async_show_form(step_id="addon_installed_other_device") return self.async_create_entry(title="", data={}) + + +async def check_multi_pan_addon(hass: HomeAssistant) -> None: + """Check the multi-PAN addon state, and start it if installed but not started. + + Does nothing if Hass.io is not loaded. + Raises on error or if the add-on is installed but not started. + """ + if not is_hassio(hass): + return + + addon_manager: AddonManager = get_addon_manager(hass) + try: + addon_info: AddonInfo = await addon_manager.async_get_addon_info() + except AddonError as err: + _LOGGER.error(err) + raise HomeAssistantError from err + + # Request the addon to start if it's not started + # addon_manager.async_start_addon returns as soon as the start request has been sent + # and does not wait for the addon to be started, so we raise below + if addon_info.state == AddonState.NOT_RUNNING: + await addon_manager.async_start_addon() + + if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING): + _LOGGER.debug("Multi pan addon installed and in state %s", addon_info.state) + raise HomeAssistantError + + +async def multi_pan_addon_using_device(hass: HomeAssistant, device_path: str) -> bool: + """Return True if the multi-PAN addon is using the given device. + + Returns False if Hass.io is not loaded, the addon is not running or the addon is + connected to another device. + """ + if not is_hassio(hass): + return False + + addon_manager: AddonManager = get_addon_manager(hass) + addon_info: AddonInfo = await addon_manager.async_get_addon_info() + + if addon_info.state != AddonState.RUNNING: + return False + + if addon_info.options["device"] != device_path: + return False + + return True diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 1de919b8c707..0f7ec7047155 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 9e22736fc71c..30015d1bae44 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/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index c9faa2e28e0b..d5a6202ea271 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, @@ -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( @@ -698,7 +701,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 +737,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 +833,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 +847,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 +861,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 +923,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 +992,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 +1054,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 +1073,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 adab539fb307..dc8a2a7c639b 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/config_flow.py b/homeassistant/components/homekit/config_flow.py index dddce5eae325..3747af3edc7d 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -28,8 +28,11 @@ 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, @@ -630,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) @@ -639,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, @@ -661,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 58e1e13a3f3b..4517f9c5a5e2 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -119,6 +119,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/type_covers.py b/homeassistant/components/homekit/type_covers.py index 4b21bfb77df0..05feb580572c 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_triggers.py b/homeassistant/components/homekit/type_triggers.py index b239d67877c7..eb2cd5d34ad4 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_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 4814e7833cfa..db85dbda3d57 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 e4eeea04f518..9db26d4c8e07 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 87f3dfb314ab..39e6df9d0ec0 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 055db90a68cb..4ea1a2fc7e01 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 000000000000..1680904bbca5 --- /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 000000000000..563f0103060a --- /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/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 93c29446a531..ff5448822894 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -63,6 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except ( aiosomecomfort.device.ConnectionError, aiosomecomfort.device.ConnectionTimeout, + aiosomecomfort.device.SomeComfortError, asyncio.TimeoutError, ) as ex: raise ConfigEntryNotReady( diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 9184b8c3d667..e9dae1e20745 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -292,21 +292,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) @@ -350,11 +351,9 @@ class HoneywellUSThermostat(ClimateEntity): # 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( diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 989e60574900..8f3b66ddeacb 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.11"] + "requirements": ["aiosomecomfort==0.0.14"] } diff --git a/homeassistant/components/horizon/media_player.py b/homeassistant/components/horizon/media_player.py index 3a05f09501fa..d91fe7019d60 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/http/__init__.py b/homeassistant/components/http/__init__.py index 1c201725c003..2d306ba5ee59 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 d10bd677e410..f2cfe0674047 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -13,6 +13,7 @@ from aiohttp.web import Application, Request, StreamResponse, middleware import jwt from yarl import URL +from homeassistant.auth import jwt_wrapper from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User from homeassistant.components import websocket_api @@ -173,7 +174,7 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: return False try: - claims = jwt.decode( + claims = jwt_wrapper.verify_and_decode( signature, secret, algorithms=["HS256"], options={"verify_iss": False} ) except jwt.InvalidTokenError: diff --git a/homeassistant/components/http/security_filter.py b/homeassistant/components/http/security_filter.py index a9b32bd7f4c8..e8e3aa4699c1 100644 --- a/homeassistant/components/http/security_filter.py +++ b/homeassistant/components/http/security_filter.py @@ -35,6 +35,9 @@ FILTERS: Final = re.compile( ) # fmt: on +# Unsafe bytes to be removed per WHATWG spec +UNSAFE_URL_BYTES = ["\t", "\r", "\n"] + @callback def setup_security_filter(app: Application) -> None: @@ -51,6 +54,21 @@ def setup_security_filter(app: Application) -> None: request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: """Process request and block commonly known exploit attempts.""" + for unsafe_byte in UNSAFE_URL_BYTES: + if unsafe_byte in request.path: + _LOGGER.warning( + "Filtered a request with an unsafe byte in path: %s", + request.raw_path, + ) + raise HTTPBadRequest + + if unsafe_byte in request.query_string: + _LOGGER.warning( + "Filtered a request with unsafe byte query string: %s", + request.raw_path, + ) + raise HTTPBadRequest + if FILTERS.search(_recursive_unquote(request.path)): _LOGGER.warning( "Filtered a potential harmful request to: %s", request.raw_path diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 0f661498713c..5e5b2c8dc946 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 @@ -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/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 52d12d200058..b8833b24d921 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/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index d87da5b5ac09..2b0ebdebcaa4 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/scene.py b/homeassistant/components/hue/scene.py index 1020879ce816..2c6c16797795 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, { diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index c3deee40023e..bc3ce49cb6b4 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/hue_event.py b/homeassistant/components/hue/v2/hue_event.py index 07a54e0f84f2..e0296bcb4346 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 9931b33a9968..f90848312636 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 f73d4bf31298..369c6eba0750 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 773caa72f952..1c027ba22e62 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 949b25fdd150..05812e35a362 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 ed1620c51a2d..5fbb248a8bc9 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 46e2fc160554..0fca7c0a0a20 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -20,10 +20,19 @@ "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%]" + } + }, + "dehumidifier": { + "name": "Dehumidifier" + }, + "humidifier": { + "name": "[%key:component::humidifier::entity_component::_::name%]" } } } diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index 00c9445a3b5b..8b83f7019152 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/ign_sismologia/geo_location.py b/homeassistant/components/ign_sismologia/geo_location.py index e78dafae8ee4..794da41ea126 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 48035d27a4d1..badf0f4e92f2 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 13bf8bb6d859..2400206c3a06 100644 --- a/homeassistant/components/ihc/manifest.json +++ b/homeassistant/components/ihc/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/ihc", "iot_class": "local_push", "loggers": ["ihcsdk"], - "requirements": ["defusedxml==0.7.1", "ihcsdk==2.7.6"] + "requirements": ["defusedxml==0.7.1", "ihcsdk==2.8.5"] } diff --git a/homeassistant/components/ihc/sensor.py b/homeassistant/components/ihc/sensor.py index d3c38687caa7..c1210a358d61 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 8e8edb0b7f7a..d4593dad5703 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 b635fb6aaeaf..861a2acc1f11 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/imap/__init__.py b/homeassistant/components/imap/__init__.py index 7e582aa04d4e..468181be5f7c 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 7306d07d06a5..8dd3019878f8 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -9,8 +9,9 @@ from aioimaplib import AioImapException import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult +from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv from .const import ( @@ -36,6 +37,13 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +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]: """Validate user input.""" @@ -80,25 +88,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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(STEP_USER_DATA_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 +135,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/coordinator.py b/homeassistant/components/imap/coordinator.py index e170f79e7f49..421cedad149d 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -3,16 +3,23 @@ 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 .const import CONF_CHARSET, CONF_FOLDER, CONF_SEARCH, CONF_SERVER, DOMAIN @@ -20,89 +27,299 @@ 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]) 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 = None + message_html = None + message_untyped_text = None + + for part in self.email_message.walk(): + if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: + if message_text is None: + message_text = part.get_payload() + elif part.get_content_type() == "text/html": + if message_html is None: + message_html = part.get_payload() + elif ( + part.get_content_type().startswith("text") + and message_untyped_text is None + ): + message_untyped_text = part.get_payload() + + if message_text is not None: + return message_text + + if message_html is not None: + return message_html + + if message_untyped_text is not None: + return message_untyped_text + + return self.email_message.get_payload() + + +class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): + """Base class for imap client.""" 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, + "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_background_task( - self.async_wait_server_push(), "Wait for IMAP data push" - ) - return len(lines[0].split()) + count: int = len(message_ids := lines[0].split()) + last_message_id = ( + str(message_ids[-1:][0], encoding=self.config_entry.data[CONF_CHARSET]) + if count + else None + ) + if count and last_message_id is not None: + 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], - ) + async def _cleanup(self, log_error: bool = False) -> None: + """Close resources.""" + if self.imap_client: + 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 - await self.async_request_refresh() async def shutdown(self, *_) -> None: """Close resources.""" - if self.imap_client: - if self.imap_client.has_pending_idle(): + 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: + self.async_set_update_error(ex) + await self._cleanup() + raise UpdateFailed() from ex + except InvalidFolder as ex: + _LOGGER.warning("Selected mailbox folder is invalid") + self.async_set_update_error(ex) + await self._cleanup() + raise ConfigEntryError("Selected mailbox folder is invalid.") from ex + except InvalidAuth as ex: + _LOGGER.warning("Username or password incorrect, starting reauthentication") + self.async_set_update_error(ex) + await self._cleanup() + 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() - await self.imap_client.stop_wait_server_push() - await self.imap_client.logout() + 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 asyncio.sleep(BACKOFF_TIME) + + async def shutdown(self, *_) -> None: + """Close resources.""" + if self._push_wait_task: + self._push_wait_task.cancel() + await super().shutdown() diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 20457209e994..776abc174a2c 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 25bcf840c334..d104f591c638 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -24,6 +24,7 @@ "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_folder": "The selected folder is invalid", "invalid_search": "The selected search is invalid" }, "abort": { @@ -31,10 +32,22 @@ "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%]" } } } diff --git a/homeassistant/components/input_boolean/strings.json b/homeassistant/components/input_boolean/strings.json index a32958592f2a..d8e1e133f55a 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/strings.json b/homeassistant/components/input_button/strings.json new file mode 100644 index 000000000000..cfd616fd5e73 --- /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/strings.json b/homeassistant/components/input_datetime/strings.json index 8d51025070e3..0c3a4b0b0d2f 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/strings.json b/homeassistant/components/input_number/strings.json index 35bbbebbdd77..11ed2f8bf10d 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/strings.json b/homeassistant/components/input_select/strings.json index c3cd5c0c71c8..f0dead7a1dd3 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/strings.json b/homeassistant/components/input_text/strings.json index dac5995acade..d713c395b67e 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/ipdb.py b/homeassistant/components/insteon/ipdb.py index 46302d3e6ad6..fea1262bffdf 100644 --- a/homeassistant/components/insteon/ipdb.py +++ b/homeassistant/components/insteon/ipdb.py @@ -4,14 +4,17 @@ 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, @@ -28,12 +31,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, @@ -54,9 +60,11 @@ from .const import ON_OFF_EVENTS DEVICE_PLATFORM = { AccessControl_Morningstar: {LOCK: [1]}, DimmableLightingControl: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_Dial: {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]}, + DimmableLightingControl_InLineLinc01: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_InLineLinc02: {LIGHT: [1], ON_OFF_EVENTS: [1]}, DimmableLightingControl_KeypadLinc_6: { LIGHT: [1], SWITCH: [3, 4, 5, 6], @@ -69,7 +77,8 @@ DEVICE_PLATFORM = { }, 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_SwitchLinc01: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_SwitchLinc02: {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]}, @@ -86,7 +95,9 @@ DEVICE_PLATFORM = { 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]}, + SwitchedLightingControl_I3Outlet: {SWITCH: [1, 2], ON_OFF_EVENTS: [1, 2]}, + SwitchedLightingControl_InLineLinc01: {SWITCH: [1], ON_OFF_EVENTS: [1]}, + SwitchedLightingControl_InLineLinc02: {SWITCH: [1], ON_OFF_EVENTS: [1]}, SwitchedLightingControl_KeypadLinc_6: { SWITCH: [1, 3, 4, 5, 6], ON_OFF_EVENTS: [1, 3, 4, 5, 6], @@ -97,7 +108,8 @@ DEVICE_PLATFORM = { }, 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_SwitchLinc01: {SWITCH: [1], ON_OFF_EVENTS: [1]}, + SwitchedLightingControl_SwitchLinc02: {SWITCH: [1], ON_OFF_EVENTS: [1]}, SwitchedLightingControl_ToggleLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, ClimateControl_Thermostat: {CLIMATE: [1]}, ClimateControl_WirelessThermostat: {CLIMATE: [1]}, diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 743e7e4fa19d..af9396399af9 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.4", - "insteon-frontend-home-assistant==0.3.3" + "pyinsteon==1.4.0", + "insteon-frontend-home-assistant==0.3.4" ], "usb": [ { diff --git a/homeassistant/components/intellifire/binary_sensor.py b/homeassistant/components/intellifire/binary_sensor.py index d189a09a739a..5a7407836f25 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 b6753adef763..5003ed91437b 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 3c427250f193..6ef63f5347ca 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 0f4385693895..debc8237fc83 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 f1fd81ab452b..383d61b8d410 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 12f66a3f2784..e888ea1bbcf5 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 ef0363696c46..98abaa38849d 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 a52f4897d234..6bc3d88287fc 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 ) diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 128c9332aebc..2ec898bfb0eb 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/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 0870e2234dc1..849a2055ce32 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 8e46bf27d555..bfd1b820c7a6 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/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 7fd5ed4129fa..d8810b0ad45f 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 5278750d36e3..d0d314fe67d2 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 e037f486aaa3..2a73a33bef80 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 000000000000..1a8b0bf70364 --- /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 a90a2c53c528..abaefec40824 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/lock.py b/homeassistant/components/isy994/lock.py index c5372135bbb9..9bf487def076 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 991b79e7be9c..3aa81027b4f7 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/services.py b/homeassistant/components/isy994/services.py index 05e0425c3f5f..ea66bc90130f 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -52,8 +52,14 @@ SERVICE_RENAME_NODE = "rename_node" 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" @@ -129,6 +135,13 @@ SERVICE_SET_ZWAVE_PARAMETER_SCHEMA = { vol.Required(CONF_SIZE): vol.All(vol.Coerce(int), vol.In(VALID_PARAMETER_SIZES)), } +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_SET_VARIABLE_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_ADDRESS, CONF_TYPE, CONF_NAME), vol.Schema( diff --git a/homeassistant/components/isy994/services.yaml b/homeassistant/components/isy994/services.yaml index e336eaa574be..89b6c4d33d3a 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: >- diff --git a/homeassistant/components/itunes/media_player.py b/homeassistant/components/itunes/media_player.py index c9b0e4a07af3..78fd8b2a5b65 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 3f2565bd8f4f..fd8d27ac4222 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 78fd87b8e2cb..4c9eb3a607c0 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 eb6e7d4a190e..a170ed30a749 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 95038d54f53f..4ee970207246 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -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 b7563dcd8627..3d5b150f39f9 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 60fae2caac71..2025e1a2a6cd 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/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index 68465c26c457..207c9e353a15 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 fd4265a4ef09..c51d30431be8 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/switch.py b/homeassistant/components/keymitt_ble/switch.py index 099ad1f228af..3e5883ae5d0c 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 f58df9dc11e8..60104545deaf 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -69,6 +69,7 @@ from .const import ( KNX_ADDRESS, SUPPORTED_PLATFORMS, ) +from .device import KNXInterfaceDevice from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure from .schema import ( BinarySensorSchema, @@ -254,13 +255,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) ], ) @@ -366,10 +368,17 @@ class KNXModule: self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {} self.entry = entry - self.init_xknx() + 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.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]] = {} @@ -382,14 +391,6 @@ 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.xknx.start() diff --git a/homeassistant/components/knx/device.py b/homeassistant/components/knx/device.py new file mode 100644 index 000000000000..452de577ce08 --- /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/expose.py b/homeassistant/components/knx/expose.py index 05e367faeec7..308fc4eacd17 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 e4260f5e868b..f5ef8f61b845 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 ce09032e1af2..0ad4404290a7 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["xknx"], "quality_scale": "platinum", - "requirements": ["xknx==2.6.0"] + "requirements": ["xknx==2.7.0"] } diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index c6206e883e50..a505714c0d0a 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -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)), diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 64cd6151f7c6..ef1539853425 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,95 @@ 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", + name="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", + name="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", + name="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", + name="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", + name="Telegrams incoming Error", + icon="mdi:help-network", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_incoming_error, + ), + KNXSystemEntityDescription( + key="telegrams_outgoing", + name="Telegrams outgoing", + icon="mdi:download-network", + entity_registry_enabled_default=False, + force_update=True, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_outgoing, + ), + KNXSystemEntityDescription( + key="telegrams_outgoing_error", + name="Telegrams outgoing Error", + icon="mdi:close-network", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_outgoing_error, + ), + KNXSystemEntityDescription( + key="telegram_count", + name="Telegrams", + icon="mdi:plus-network", + force_update=True, + state_class=SensorStateClass.TOTAL_INCREASING, + 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 +127,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 +187,48 @@ 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.""" + + 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_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/kodi/device_trigger.py b/homeassistant/components/kodi/device_trigger.py index 07fcf11c0771..c15c415bd9c3 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 1ebc5ad6b80d..3272491a06d3 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/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index f919d15d6b38..a9b9433c1b67 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 @@ -791,7 +792,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/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index dc86fb73d9bb..0250f17052bc 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/lametric/__init__.py b/homeassistant/components/lametric/__init__.py index 5fd531234b89..867b80cf4087 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 74edd9e0afb9..18a0c2f8f728 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 295003c853e5..b7c0e55745eb 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 c12d368efdfe..0c26d2c7dd58 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 f20732c63486..21cebe46f26e 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 f6807648b7b9..c33ec16d617f 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 eae5e91196cc..3a44267bd41f 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 diff --git a/homeassistant/components/landisgyr_heat_meter/const.py b/homeassistant/components/landisgyr_heat_meter/const.py index bded296f3f4e..079bcad25348 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 000000000000..c85c661e79c3 --- /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 5e10f3941865..a056f1f65645 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 284fb5b7f302..244515a07d4c 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,229 @@ 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="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: getattr(res, "volume_usage_m3", None), + ), + 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: getattr(res, "heat_usage_gj", None), + ), + HeatMeterSensorEntityDescription( + key="heat_previous_year_gj", + icon="mdi:fire", + name="Heat previous year GJ", + native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "heat_previous_year_gj", None), + ), + 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: getattr(res, "volume_previous_year_m3", None), + ), + HeatMeterSensorEntityDescription( + key="ownership_number", + name="Ownership number", + icon="mdi:identifier", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "ownership_number", None), + ), + HeatMeterSensorEntityDescription( + key="error_number", + name="Error number", + icon="mdi:home-alert", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "error_number", None), + ), + HeatMeterSensorEntityDescription( + key="device_number", + name="Device number", + icon="mdi:identifier", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "device_number", None), + ), + 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: getattr(res, "measurement_period_minutes", None), + ), + 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: getattr(res, "power_max_kw", None), + ), + 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: getattr(res, "power_max_previous_year_kw", None), + ), + 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: getattr(res, "flowrate_max_m3ph", None), + ), + 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: getattr(res, "flowrate_max_previous_year_m3ph", None), + ), + 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: getattr(res, "return_temperature_max_c", None), + ), + 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: getattr( + res, "return_temperature_max_previous_year_c", None + ), + ), + 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: getattr(res, "flow_temperature_max_c", None), + ), + 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: getattr(res, "flow_temperature_max_previous_year_c", None), + ), + 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: getattr(res, "operating_hours", None), + ), + 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: getattr(res, "flow_hours", None), + ), + 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: getattr(res, "fault_hours", None), + ), + 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: getattr(res, "fault_hours_previous_year", None), + ), + HeatMeterSensorEntityDescription( + key="yearly_set_day", + name="Yearly set day", + icon="mdi:clock-outline", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "yearly_set_day", None), + ), + HeatMeterSensorEntityDescription( + key="monthly_set_day", + name="Monthly set day", + icon="mdi:clock-outline", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "monthly_set_day", None), + ), + 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: getattr(res, "measuring_range_m3ph", None), + ), + HeatMeterSensorEntityDescription( + key="settings_and_firmware", + name="Settings and firmware", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "settings_and_firmware", None), + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -54,14 +285,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 +304,9 @@ 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 - - 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/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index 1af16a904d83..2074966e1e7b 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 feadedb6d496..ccf56db802e8 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/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index b2265d81da9b..1bdbc618fdf6 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, 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 @@ -167,15 +166,9 @@ 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 diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 0019f68ab9db..65f4e7ecefa7 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -42,7 +42,7 @@ "quality_scale": "platinum", "requirements": [ "aiolifx==0.8.9", - "aiolifx_effects==0.3.1", - "aiolifx_themes==0.4.0" + "aiolifx_effects==0.3.2", + "aiolifx_themes==0.4.5" ] } diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index d8543946df71..0c3a711a7387 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 7b75821ab432..605434af9162 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/services.yaml b/homeassistant/components/light/services.yaml index b7843a2f0ec3..d1221dd12107 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 ee1f8e13b614..935e38d33d96 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 3b0aeffaa6d2..d0440c832c8a 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 c5ebf874681f..cf76213a88e2 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 040b8688a429..291333d0b74f 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 25d454071cc6..c469d480ca6f 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/manifest.json b/homeassistant/components/litejet/manifest.json index b2b213d06f5f..136880257ce2 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/trigger.py b/homeassistant/components/litejet/trigger.py index 926ed69637f2..df5ffac9b99b 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 e635e80a6e92..0b162ee2e56d 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.1.2"] } diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index bc1613f1c28d..feac85ecac48 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -3,10 +3,10 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass -import itertools from typing import Any, Generic, TypeVar -from pylitterbot import FeederRobot, LitterRobot +from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot +from pylitterbot.robot.litterrobot4 import BrightnessLevel from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -18,14 +18,21 @@ from .const import DOMAIN from .entity import LitterRobotEntity, _RobotT from .hub import LitterRobotHub -_CastTypeT = TypeVar("_CastTypeT", int, float) +_CastTypeT = TypeVar("_CastTypeT", int, float, str) + +BRIGHTNESS_LEVEL_ICON_MAP: dict[BrightnessLevel | None, str] = { + BrightnessLevel.LOW: "mdi:lightbulb-on-30", + BrightnessLevel.MEDIUM: "mdi:lightbulb-on-50", + BrightnessLevel.HIGH: "mdi:lightbulb-on", + None: "mdi:lightbulb-question", +} @dataclass class RequiredKeysMixin(Generic[_RobotT, _CastTypeT]): """A class that describes robot select entity required keys.""" - current_fn: Callable[[_RobotT], _CastTypeT] + current_fn: Callable[[_RobotT], _CastTypeT | None] options_fn: Callable[[_RobotT], list[_CastTypeT]] select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]] @@ -37,26 +44,42 @@ class RobotSelectEntityDescription( """A class that describes robot select entities.""" entity_category: EntityCategory = EntityCategory.CONFIG + icon_fn: Callable[[_RobotT], str] | None = None -LITTER_ROBOT_SELECT = RobotSelectEntityDescription[LitterRobot, int]( - key="cycle_delay", - name="Clean cycle wait time minutes", - icon="mdi:timer-outline", - unit_of_measurement=UnitOfTime.MINUTES, - current_fn=lambda robot: robot.clean_cycle_wait_time_minutes, - options_fn=lambda robot: robot.VALID_WAIT_TIMES, - select_fn=lambda robot, option: robot.set_wait_time(int(option)), -) -FEEDER_ROBOT_SELECT = RobotSelectEntityDescription[FeederRobot, float]( - key="meal_insert_size", - name="Meal insert size", - icon="mdi:scale", - unit_of_measurement="cups", - current_fn=lambda robot: robot.meal_insert_size, - options_fn=lambda robot: robot.VALID_MEAL_INSERT_SIZES, - select_fn=lambda robot, option: robot.set_meal_insert_size(float(option)), -) +ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { + LitterRobot: RobotSelectEntityDescription[LitterRobot, int]( + key="cycle_delay", + name="Clean cycle wait time minutes", + icon="mdi:timer-outline", + unit_of_measurement=UnitOfTime.MINUTES, + current_fn=lambda robot: robot.clean_cycle_wait_time_minutes, + options_fn=lambda robot: robot.VALID_WAIT_TIMES, + select_fn=lambda robot, opt: robot.set_wait_time(int(opt)), + ), + LitterRobot4: RobotSelectEntityDescription[LitterRobot4, str]( + key="panel_brightness", + name="Panel brightness", + translation_key="brightness_level", + current_fn=lambda robot: bri.name.lower() + if (bri := robot.panel_brightness) is not None + else None, + options_fn=lambda _: [level.name.lower() for level in BrightnessLevel], + select_fn=lambda robot, opt: robot.set_panel_brightness( + BrightnessLevel[opt.upper()] + ), + icon_fn=lambda robot: BRIGHTNESS_LEVEL_ICON_MAP[robot.panel_brightness], + ), + FeederRobot: RobotSelectEntityDescription[FeederRobot, float]( + key="meal_insert_size", + name="Meal insert size", + icon="mdi:scale", + unit_of_measurement="cups", + current_fn=lambda robot: robot.meal_insert_size, + options_fn=lambda robot: robot.VALID_MEAL_INSERT_SIZES, + select_fn=lambda robot, opt: robot.set_meal_insert_size(float(opt)), + ), +} async def async_setup_entry( @@ -66,22 +89,16 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot selects using config entry.""" hub: LitterRobotHub = hass.data[DOMAIN][config_entry.entry_id] - entities: list[LitterRobotSelect] = list( - itertools.chain( - ( - LitterRobotSelect(robot=robot, hub=hub, description=LITTER_ROBOT_SELECT) - for robot in hub.litter_robots() - ), - ( - LitterRobotSelect(robot=robot, hub=hub, description=FEEDER_ROBOT_SELECT) - for robot in hub.feeder_robots() - ), - ) - ) + entities = [ + LitterRobotSelectEntity(robot=robot, hub=hub, description=description) + for robot in hub.account.robots + for robot_type, description in ROBOT_SELECT_MAP.items() + if isinstance(robot, robot_type) + ] async_add_entities(entities) -class LitterRobotSelect( +class LitterRobotSelectEntity( LitterRobotEntity[_RobotT], SelectEntity, Generic[_RobotT, _CastTypeT] ): """Litter-Robot Select.""" @@ -99,6 +116,13 @@ class LitterRobotSelect( options = self.entity_description.options_fn(self.robot) self._attr_options = list(map(str, options)) + @property + def icon(self) -> str | None: + """Return the icon to use in the frontend, if any.""" + if icon_fn := self.entity_description.icon_fn: + return str(icon_fn(self.robot)) + return super().icon + @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 2d40eb6a0448..b4aa8f0016d6 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 e71c6bca660c..b0387c6dcc92 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 000000000000..42170bbeb4cb --- /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 000000000000..952363650d69 --- /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 e6abc5118dea..f6435298f1e3 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 47a612274ac3..56e928307c17 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 000000000000..ebd2b8138528 --- /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 849cfdad5c99..e6f46324ed82 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 bcb9a2044119..2c5a2b5137b5 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 88737150c02f..4b6d9444fd89 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -9,7 +9,7 @@ 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 @@ -24,6 +24,7 @@ from homeassistant.components.calendar import ( ) 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 @@ -32,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, @@ -42,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) @@ -81,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: @@ -119,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("Error while deleting event: {err}") from err await self._async_store() await self.async_update_ha_state(force_refresh=True) @@ -139,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("Error while updating event: {err}") from err await self._async_store() await self.async_update_ha_state(force_refresh=True) @@ -189,4 +198,5 @@ def _get_calendar_event(event: Event) -> CalendarEvent: 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 000000000000..51b53ff0073f --- /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 a1659a9ba3b9..049f9de03ea3 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/device_action.py b/homeassistant/components/lock/device_action.py index 3ff8d10c7a29..01e7b21d4b6e 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 cdaa02de6189..c439fe99d148 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 9fc35fb13526..ec996d4f0b29 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/strings.json b/homeassistant/components/lock/strings.json index 9e4c4ea726a0..da4b5217b862 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 fb1b9d78b89d..ee2ae3da4d95 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 e1abd9876595..2d9911117f96 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 221612e1e978..c8f55331de13 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 3fc4b5dac8b4..ab073f296f7f 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 +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,9 +61,9 @@ 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 + self.context_id_bin: bytes | None = self.row.context_id_bin + self.context_user_id_bin: bytes | None = self.row.context_user_id_bin + self.context_parent_id_bin: bytes | None = self.row.context_parent_id_bin if data := getattr(row, "data", None): # If its an EventAsRow we can avoid the whole # json decode process as we already have the data @@ -55,9 +76,24 @@ class LazyEventPartialState: 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) + + @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(frozen=True) class EventAsRow: @@ -65,7 +101,7 @@ class EventAsRow: data: dict[str, Any] context: Context - context_id: str + context_id_bin: bytes time_fired_ts: float state_id: int event_data: str | None = None @@ -73,8 +109,8 @@ class EventAsRow: 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 @@ -85,13 +121,14 @@ 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), ) @@ -99,14 +136,15 @@ def async_event_to_row(event: Event) -> EventAsRow: # 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), icon=new_state.attributes.get(ATTR_ICON), diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 289ee677a21e..32301e98358a 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -10,8 +10,11 @@ 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_metadata_ids, process_datetime_to_timestamp, process_timestamp_to_utc_isoformat, ) @@ -51,10 +54,9 @@ 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 @@ -96,16 +98,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, + external_events=logbook_config.external_events, event_cache=EventCache({}), entity_name_cache=EntityNameCache(self.hass), include_entity_name=include_entity_name, @@ -151,16 +151,25 @@ class EventProcessor: # 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: + with session_scope(hass=self.hass, read_only=True) as session: + metadata_ids: list[int] | None = None + if self.entity_ids: + instance = get_instance(self.hass) + metadata_ids = extract_metadata_ids( + instance.states_meta_manager.get_many( + self.entity_ids, session, False + ) + ) + stmt = statement_for_request( + start_day, + end_day, + self.event_types, + self.entity_ids, + metadata_ids, + self.device_ids, + self.filters, + self.context_id, + ) return self.humanify(yield_rows(session.execute(stmt))) def humanify( @@ -261,14 +270,14 @@ class ContextLookup: """Memorize context origin.""" self.hass = hass self._memorize_new = True - self._lookup: dict[str | None, Row | EventAsRow | None] = {None: None} + self._lookup: dict[bytes | None, Row | EventAsRow | None] = {None: None} - def memorize(self, row: Row | EventAsRow) -> str | None: + def memorize(self, row: Row | EventAsRow) -> bytes | 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 + context_id_bin: bytes = row.context_id_bin + self._lookup.setdefault(context_id_bin, row) + return context_id_bin return None def clear(self) -> None: @@ -276,9 +285,9 @@ class ContextLookup: self._lookup.clear() self._memorize_new = False - def get(self, context_id: str) -> Row | EventAsRow | None: + def get(self, context_id_bin: bytes) -> Row | EventAsRow | None: """Get the context origin.""" - return self._lookup.get(context_id) + return self._lookup.get(context_id_bin) class ContextAugmenter: @@ -293,7 +302,7 @@ 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: bytes | None, row: Row | EventAsRow ) -> Row | EventAsRow | None: """Get the context row from the id or row context.""" if context_id: @@ -305,11 +314,11 @@ 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: 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)): return @@ -317,11 +326,12 @@ class ContextAugmenter: 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 diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index 8a2ee40de4f2..b83f7a4428ae 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 @@ -20,6 +22,7 @@ def statement_for_request( end_day_dt: dt, event_types: tuple[str, ...], 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, + 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, + 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, + 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, + [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 729a4d2195a2..70214fbb04ba 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, + 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) ) - 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 ca00f31615a8..08bf1b8ab9bf 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -10,17 +10,20 @@ 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, + STATES_CONTEXT_ID_BIN_INDEX, EventData, Events, + EventTypes, StateAttributes, States, + StatesMeta, ) from homeassistant.components.recorder.filters import like_domain_matchers +from homeassistant.components.recorder.queries import select_event_type_ids from ..const import ALWAYS_CONTINUOUS_DOMAINS, CONDITIONALLY_CONTINUOUS_DOMAINS @@ -44,18 +47,18 @@ PSEUDO_EVENT_STATE_CHANGED: Final = None EVENT_COLUMNS = ( Events.event_id.label("event_id"), - Events.event_type.label("event_type"), + EventTypes.event_type.label("event_type"), Events.event_data.label("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"), + StatesMeta.entity_id.label("entity_id"), SHARED_ATTRS_JSON["icon"].as_string().label("icon"), OLD_FORMAT_ATTRS_JSON["icon"].as_string().label("old_format_icon"), ) @@ -63,7 +66,7 @@ STATE_COLUMNS = ( 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"), ) @@ -79,9 +82,9 @@ 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"), + States.context_id_bin.label("context_id_bin"), + States.context_user_id_bin.label("context_user_id_bin"), + States.context_parent_id_bin.label("context_parent_id_bin"), literal(value=None, type_=sqlalchemy.Text).label("shared_data"), ) @@ -113,9 +116,10 @@ def select_events_context_id_subquery( ) -> 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_(select_event_type_ids(event_types))) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, (Events.data_id == EventData.data_id)) ) @@ -147,7 +151,8 @@ def select_events_without_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_(select_event_type_ids(event_types))) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, (Events.data_id == EventData.data_id)) ) @@ -161,32 +166,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 +187,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 +224,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 +241,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 +257,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 fa2deaf4c020..a5c06dc84cf7 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 ( @@ -33,10 +35,12 @@ def _select_device_id_context_ids_sub_query( 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_types) + .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( @@ -57,12 +61,15 @@ def _apply_devices_context_union( 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)) ), ) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 3d26443ce90a..ebb56befa50a 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 ( @@ -34,7 +36,7 @@ def _select_entities_context_ids_sub_query( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> Select: """Generate a subquery to find context ids for multiple entities.""" @@ -42,13 +44,13 @@ def _select_entities_context_ids_sub_query( select_events_context_id_subquery(start_day, end_day, event_types).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( @@ -56,7 +58,7 @@ def _apply_entities_context_union( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + 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.""" @@ -64,25 +66,28 @@ def _apply_entities_context_union( start_day, end_day, event_types, - entity_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)) ), ) @@ -91,7 +96,7 @@ def entities_stmt( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> StatementLambdaElement: """Generate a logbook query for multiple entities.""" @@ -103,19 +108,19 @@ def entities_stmt( start_day, end_day, event_types, - entity_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 43d11d0bdff0..f7ffde4f81a7 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, @@ -30,7 +36,7 @@ def _select_entities_device_id_context_ids_sub_query( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], ) -> Select: @@ -41,13 +47,13 @@ def _select_entities_device_id_context_ids_sub_query( 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( @@ -55,7 +61,7 @@ def _apply_entities_devices_context_union( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], ) -> CompoundSelect: @@ -63,26 +69,33 @@ def _apply_entities_devices_context_union( start_day, end_day, event_types, - entity_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)) ), ) @@ -91,7 +104,7 @@ def entities_devices_stmt( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], ) -> StatementLambdaElement: @@ -106,7 +119,7 @@ def entities_devices_stmt( start_day, end_day, event_types, - entity_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 dac0da83c360..6d24285ba11b 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 @@ -361,7 +361,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, diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index d85486a41e06..df275eaae939 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -66,13 +66,14 @@ 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 @@ -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 1b4e5cb36a68..89026a07b8a0 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/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py index d18ecf8bd4e5..f4ebe4376f34 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/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index 67672759706f..262a6701f56b 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 508e12924d38..d54bc6d0bdc9 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/magicseaweed/__init__.py b/homeassistant/components/magicseaweed/__init__.py deleted file mode 100644 index 848d02967fe3..000000000000 --- 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 4858e6be4f5f..000000000000 --- 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 aa59553ef81c..000000000000 --- 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 0aa8a584190e..000000000000 --- 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/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index d35f9b73ef36..da77aea6c4af 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 3857dd195428..fd6adb009aae 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 @@ -343,56 +346,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 +415,22 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( from_state=self._state, to_state=state, parse_result=False ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index b4d1b867e77e..a82614cbcc69 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -1,6 +1,8 @@ """Matter binary sensors.""" from __future__ import annotations +from dataclasses import dataclass + from chip.clusters import Objects as clusters from chip.clusters.Objects import uint from chip.clusters.Types import Nullable, NullValue @@ -15,7 +17,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -30,9 +32,18 @@ async def async_setup_entry( matter.register_platform_handler(Platform.BINARY_SENSOR, async_add_entities) +@dataclass +class MatterBinarySensorEntityDescription( + BinarySensorEntityDescription, MatterEntityDescription +): + """Describe Matter binary sensor entities.""" + + class MatterBinarySensor(MatterEntity, BinarySensorEntity): """Representation of a Matter binary sensor.""" + entity_description: MatterBinarySensorEntityDescription + @callback def _update_from_device(self) -> None: """Update from device.""" @@ -40,7 +51,7 @@ class MatterBinarySensor(MatterEntity, BinarySensorEntity): value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value in (None, NullValue): value = None - elif value_convert := self._entity_info.measurement_to_ha: + elif value_convert := self.entity_description.measurement_to_ha: value = value_convert(value) self._attr_is_on = value @@ -51,52 +62,53 @@ DISCOVERY_SCHEMAS = [ # instead of generic occupancy sensor MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="HueMotionSensor", device_class=BinarySensorDeviceClass.MOTION, name="Motion", + measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), entity_class=MatterBinarySensor, required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), vendor_id=(4107,), product_name=("Hue motion sensor",), - measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="ContactSensor", device_class=BinarySensorDeviceClass.DOOR, name="Contact", + # value is inverted on matter to what we expect + measurement_to_ha=lambda x: not x, ), entity_class=MatterBinarySensor, required_attributes=(clusters.BooleanState.Attributes.StateValue,), - # value is inverted on matter to what we expect - measurement_to_ha=lambda x: not x, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="OccupancySensor", device_class=BinarySensorDeviceClass.OCCUPANCY, name="Occupancy", + # The first bit = if occupied + measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), entity_class=MatterBinarySensor, required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), - # The first bit = if occupied - measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="BatteryChargeLevel", device_class=BinarySensorDeviceClass.BATTERY, name="Battery Status", + measurement_to_ha=lambda x: x + != clusters.PowerSource.Enums.BatChargeLevel.kOk, ), entity_class=MatterBinarySensor, required_attributes=(clusters.PowerSource.Attributes.BatChargeLevel,), # only add binary battery sensor if a regular percentage based is not available absent_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), - measurement_to_ha=lambda x: x != clusters.PowerSource.Enums.BatChargeLevel.kOk, ), ] diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 3fb8481dc94d..9df4484e00d2 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -11,6 +11,7 @@ from homeassistant.core import callback from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_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 @@ -18,10 +19,11 @@ from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, Platform.LIGHT: LIGHT_SCHEMAS, + Platform.LOCK: LOCK_SCHEMAS, Platform.SENSOR: SENSOR_SCHEMAS, Platform.SWITCH: SWITCH_SCHEMAS, } -SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS.keys()) +SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS) @callback @@ -107,7 +109,6 @@ def async_discover_entities( attributes_to_watch=attributes_to_watch, entity_description=schema.entity_description, entity_class=schema.entity_class, - measurement_to_ha=schema.measurement_to_ha, ) # prevent re-discovery of the same attributes diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index a1d67158ab05..bf0a74ef8457 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import abstractmethod from collections.abc import Callable +from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast @@ -11,7 +12,7 @@ from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import DOMAIN, ID_TYPE_DEVICE_ID from .helpers import get_device_id @@ -25,6 +26,14 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) +@dataclass +class MatterEntityDescription(EntityDescription): + """Describe the Matter entity.""" + + # convert the value from the primary attribute to the value used by HA + measurement_to_ha: Callable[[Any], Any] | None = None + + class MatterEntity(Entity): """Entity class for Matter devices.""" diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 080cc472f2db..10a52eb88055 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -372,7 +372,10 @@ DISCOVERY_SCHEMAS = [ clusters.ColorControl.Attributes.CurrentY, clusters.ColorControl.Attributes.ColorTemperatureMireds, ), - # restrict device type to prevent discovery in switch platform - not_device_type=(device_types.OnOffPlugInUnit,), + # 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 000000000000..f90d8eb485d0 --- /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 b81ac2c62b8d..190bf33dcf71 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==3.1.0"] + "requirements": ["python-matter-server==3.2.0"] } diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 3ce5f1846728..3ac7f66b83f5 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -1,8 +1,7 @@ """Models used for the Matter integration.""" +from __future__ import annotations -from collections.abc import Callable -from dataclasses import asdict, dataclass -from typing import Any +from dataclasses import dataclass from chip.clusters import Objects as clusters from chip.clusters.Objects import ClusterAttributeDescriptor @@ -12,16 +11,6 @@ from matter_server.client.models.node import MatterEndpoint from homeassistant.const import Platform from homeassistant.helpers.entity import EntityDescription - -class DataclassMustHaveAtLeastOne: - """A dataclass that must have at least one input parameter that is not None.""" - - def __post_init__(self) -> None: - """Post dataclass initialization.""" - if all(val is None for val in asdict(self).values()): - raise ValueError("At least one input parameter must not be None") - - SensorValueTypes = type[ clusters.uint | int | clusters.Nullable | clusters.float32 | float ] @@ -46,9 +35,6 @@ class MatterEntityInfo: # entity class to use to instantiate the entity entity_class: type - # [optional] function to call to convert the value from the primary attribute - measurement_to_ha: Callable[[SensorValueTypes], SensorValueTypes] | None = None - @property def primary_attribute(self) -> type[ClusterAttributeDescriptor]: """Return Primary Attribute belonging to the entity.""" @@ -59,7 +45,8 @@ class MatterEntityInfo: class MatterDiscoverySchema: """Matter discovery schema. - The Matter endpoint and it's (primary) Attribute for an entity must match these conditions. + The Matter endpoint and its (primary) Attribute + for an entity must match these conditions. """ # specify the hass platform for which this scheme applies (e.g. light, sensor) @@ -104,6 +91,3 @@ class MatterDiscoverySchema: # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False - - # [optional] function to call to convert the value from the primary attribute - measurement_to_ha: Callable[[Any], Any] | None = None diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 34760fbbf134..84e68695d639 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -1,6 +1,8 @@ """Matter sensors.""" from __future__ import annotations +from dataclasses import dataclass + from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue @@ -22,7 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -37,10 +39,16 @@ async def async_setup_entry( matter.register_platform_handler(Platform.SENSOR, async_add_entities) +@dataclass +class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescription): + """Describe Matter sensor entities.""" + + class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" _attr_state_class = SensorStateClass.MEASUREMENT + entity_description: MatterSensorEntityDescription @callback def _update_from_device(self) -> None: @@ -49,7 +57,7 @@ class MatterSensor(MatterEntity, SensorEntity): value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value in (None, NullValue): value = None - elif value_convert := self._entity_info.measurement_to_ha: + elif value_convert := self.entity_description.measurement_to_ha: value = value_convert(value) self._attr_native_value = value @@ -58,77 +66,77 @@ class MatterSensor(MatterEntity, SensorEntity): DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="TemperatureSensor", name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + measurement_to_ha=lambda x: x / 100, ), entity_class=MatterSensor, required_attributes=(clusters.TemperatureMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: x / 100, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="PressureSensor", name="Pressure", native_unit_of_measurement=UnitOfPressure.KPA, device_class=SensorDeviceClass.PRESSURE, + measurement_to_ha=lambda x: x / 10, ), entity_class=MatterSensor, required_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: x / 10, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="FlowSensor", name="Flow", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, device_class=SensorDeviceClass.WATER, # what is the device class here ? + measurement_to_ha=lambda x: x / 10, ), entity_class=MatterSensor, required_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: x / 10, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="HumiditySensor", name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, + measurement_to_ha=lambda x: x / 100, ), entity_class=MatterSensor, required_attributes=( clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue, ), - measurement_to_ha=lambda x: x / 100, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="LightSensor", name="Illuminance", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, + measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), ), entity_class=MatterSensor, required_attributes=(clusters.IlluminanceMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="PowerSource", name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + # value has double precision + measurement_to_ha=lambda x: int(x / 2), ), entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), - # value has double precision - measurement_to_ha=lambda x: int(x / 2), ), ] diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index e5c986104397..809d0ad73861 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -67,8 +67,11 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSwitch, required_attributes=(clusters.OnOff.Attributes.OnOff,), - # restrict device type to prevent discovery by light - # platform which also uses OnOff cluster - not_device_type=(device_types.OnOffLight, device_types.DimmableLight), + # 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 403627147f0d..c9adac23186c 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, @@ -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/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 3938cc64f7b9..0f827d607369 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 d1328a851d25..2b046868f164 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 3bf6c5956faf..9e3981ed9833 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 9b61c89dafb0..58fc0aca84fa 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 bb6c7d16f5a0..cee0ee200fe9 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/met/__init__.py b/homeassistant/components/met/__init__.py index c95c3abe05e6..c676f15336ec 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -18,15 +18,12 @@ from homeassistant.const import ( CONF_LONGITUDE, EVENT_CORE_CONFIG_UPDATE, Platform, - UnitOfLength, ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from homeassistant.util.unit_conversion import DistanceConverter -from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( CONF_TRACK_HOME, @@ -102,9 +99,7 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator["MetWeatherData"]): def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize global Met data updater.""" self._unsub_track_home: Callable[[], None] | None = None - self.weather = MetWeatherData( - hass, config_entry.data, hass.config.units is METRIC_SYSTEM - ) + self.weather = MetWeatherData(hass, config_entry.data) self.weather.set_coordinates() update_interval = timedelta(minutes=randrange(55, 65)) @@ -142,13 +137,10 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator["MetWeatherData"]): class MetWeatherData: """Keep data for Met.no weather entities.""" - def __init__( - self, hass: HomeAssistant, config: MappingProxyType[str, Any], is_metric: bool - ) -> None: + def __init__(self, hass: HomeAssistant, config: MappingProxyType[str, Any]) -> None: """Initialise the weather entity data.""" self.hass = hass self._config = config - self._is_metric = is_metric self._weather_data: metno.MetWeatherData self.current_weather_data: dict = {} self.daily_forecast: list[dict] = [] @@ -165,14 +157,6 @@ class MetWeatherData: latitude = self._config[CONF_LATITUDE] longitude = self._config[CONF_LONGITUDE] elevation = self._config[CONF_ELEVATION] - if not self._is_metric: - elevation = int( - round( - DistanceConverter.convert( - elevation, UnitOfLength.FEET, UnitOfLength.METERS - ) - ) - ) coordinates = { "lat": str(latitude), diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 453c0a9cee80..d8cb31077c21 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -6,10 +6,21 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import ( + CONF_ELEVATION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + UnitOfLength, +) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, +) from .const import ( CONF_TRACK_HOME, @@ -47,7 +58,14 @@ def _get_data_schema( vol.Required( CONF_LONGITUDE, default=hass.config.longitude ): cv.longitude, - vol.Required(CONF_ELEVATION, default=hass.config.elevation): int, + vol.Required( + CONF_ELEVATION, default=hass.config.elevation + ): NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + unit_of_measurement=UnitOfLength.METERS, + ) + ), } ) # Not tracking home, default values come from config entry @@ -62,7 +80,12 @@ def _get_data_schema( ): cv.longitude, vol.Required( CONF_ELEVATION, default=config_entry.data.get(CONF_ELEVATION) - ): int, + ): NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + unit_of_measurement=UnitOfLength.METERS, + ) + ), } ) diff --git a/homeassistant/components/metoffice/__init__.py b/homeassistant/components/metoffice/__init__.py index 057947d76e47..695c6c8f47d5 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 544dabd018ad..3bf50525ca9e 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 911d348365e0..4354b9b06bda 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 71d94a27fec4..14fbb83b61b9 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 26a589486206..9e0a610c7701 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/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 043d7375ae32..e8c534697693 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/climate.py b/homeassistant/components/modbus/climate.py index 5573ef0b7ec3..0a8b8dabeeb4 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 b7fcfee9053c..4191e1df56f4 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 b53cfda104ea..cb3501f3375c 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -390,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/modem_callerid/button.py b/homeassistant/components/modem_callerid/button.py index 63a88a8a4e5f..4b149deece31 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 4f84abd45331..1cb1043a5e00 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/moon/config_flow.py b/homeassistant/components/moon/config_flow.py index abdd60c7b658..08b2a4995f14 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 c244f1614718..f8e1cd24abea 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/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index fd783e0975bd..457f9058242d 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 ff126a226038..24dc4b67cd9b 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, @@ -52,11 +46,9 @@ from .client import ( # noqa: F401 subscribe, ) from .config_integration import ( - CONFIG_SCHEMA_BASE, CONFIG_SCHEMA_ENTRY, DEFAULT_VALUES, - DEPRECATED_CERTIFICATE_CONFIG_KEYS, - DEPRECATED_CONFIG_KEYS, + PLATFORM_CONFIG_SCHEMA_BASE, ) from .const import ( # noqa: F401 ATTR_PAYLOAD, @@ -74,7 +66,6 @@ from .const import ( # noqa: F401 CONF_QOS, CONF_STATE_TOPIC, CONF_TLS_INSECURE, - CONF_TLS_VERSION, CONF_TOPIC, CONF_TRANSPORT, CONF_WILL_MESSAGE, @@ -100,7 +91,6 @@ from .models import ( # noqa: F401 from .util import ( async_create_certificate_temp_files, get_mqtt_data, - migrate_certificate_file_to_content, mqtt_config_entry_enabled, valid_publish_topic, valid_qos_schema, @@ -147,23 +137,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, @@ -199,34 +188,8 @@ async def _async_setup_discovery( 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 @@ -249,30 +212,15 @@ def _filter_entry_config(hass: HomeAssistant, entry: ConfigEntry) -> None: hass.config_entries.async_update_entry(entry, data=filtered_data) -async def _async_merge_basic_config( +async def _async_auto_mend_config( hass: HomeAssistant, entry: ConfigEntry, yaml_config: dict[str, Any] ) -> None: - """Merge basic options in configuration.yaml config with config entry. + """Mends config fetched from config entry and adds missing values. 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] @@ -300,17 +248,16 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) - 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.""" + """Fetch fresh MQTT yaml config from the hass config.""" 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, {})) + hass_config = await conf_util.async_hass_config_yaml(hass) + mqtt_data.config = PLATFORM_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 {}) + # Add missing defaults to migrate older config entries + await _async_auto_mend_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") @@ -321,37 +268,6 @@ async def async_fetch_config( 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 @@ -361,10 +277,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" mqtt_data = get_mqtt_data(hass, True) - # Merge basic configuration, and add missing defaults for basic options + # Fetch 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 @@ -482,6 +399,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 +416,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( *( [ diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 865131132817..b685daaf6f18 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 ad89a35ec0a0..5585a6cee5f1 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -83,6 +83,8 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) DISCOVERY_COOLDOWN = 2 +INITIAL_SUBSCRIBE_COOLDOWN = 1.0 +SUBSCRIBE_COOLDOWN = 0.1 TIMEOUT_ACK = 10 SubscribePayloadType = str | bytes # Only bytes if encoding is None @@ -295,10 +297,86 @@ 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 def __init__( self, @@ -316,12 +394,16 @@ class MQTT: self._wildcard_subscriptions: list[Subscription] = [] 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 if self.hass.state == CoreState.running: self._ha_started.set() @@ -442,6 +524,11 @@ 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) + # wait for ACKs to be processed async with self._pending_operations_condition: await self._pending_operations_condition.wait_for(no_more_acks) @@ -494,6 +581,20 @@ 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 + if queue_only: + return + self._subscribe_debouncer.async_schedule() + async def async_subscribe( self, topic: str, @@ -516,15 +617,13 @@ 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() - # Only unsubscribe if currently connected if self.connected: self.hass.async_create_task(self._async_unsubscribe(topic)) @@ -543,21 +642,27 @@ class MQTT: _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. + 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] + async with self._paho_lock: mid = await self.hass.async_add_executor_job(_client_unsubscribe, topic) await self._register_mid(mid) 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.""" - + subscriptions: dict[str, int] # 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 @@ -573,16 +678,20 @@ class MQTT: 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: + for topic, qos in subscriptions.items(): 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 + subscriptions = self._pending_subscriptions + self._pending_subscriptions = {} + async with self._paho_lock: results = await self.hass.async_add_executor_job( _process_client_subscriptions ) + self._last_subscribe = time.time() tasks: list[Coroutine[Any, Any, None]] = [] errors: list[int] = [] @@ -639,6 +748,8 @@ class MQTT: 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, @@ -654,16 +765,19 @@ class MQTT: async def _async_resubscribe(self) -> None: """Resubscribe on reconnect.""" # Group subscriptions to only re-subscribe once for each topic. + self._max_qos.clear() 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 @@ -719,7 +833,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, @@ -785,13 +899,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 ) @@ -799,7 +914,9 @@ 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 ) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 66424f2c3dce..77c3856aac10 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -588,7 +588,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) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index bbd6861435bb..47f8a7cf492c 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -45,7 +45,6 @@ from .const import ( CONF_DISCOVERY_PREFIX, CONF_KEEPALIVE, CONF_TLS_INSECURE, - CONF_TLS_VERSION, CONF_TRANSPORT, CONF_WILL_MESSAGE, CONF_WS_HEADERS, @@ -72,7 +71,6 @@ DEFAULT_VALUES = { 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, @@ -182,7 +180,6 @@ CONFIG_SCHEMA_ENTRY = vol.Schema( 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, @@ -214,7 +211,6 @@ CONFIG_SCHEMA_BASE = PLATFORM_CONFIG_SCHEMA_BASE.extend( 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, @@ -236,7 +232,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 f7e2cbe5b1b4..41fd353359e3 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -33,7 +33,6 @@ 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" @@ -114,6 +113,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 cf565b42390b..a764b24b2e8d 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 diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 55b2f99d536b..e0b20436fe66 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, ) @@ -378,7 +387,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): 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 +407,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/mixins.py b/homeassistant/components/mqtt/mixins.py index b52c57ce24ff..cecb4b88bcdd 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, diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index a88fb97b8334..84735c55e08e 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: diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 1eabb509abd9..aea357bea623 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -284,7 +284,10 @@ class MqttSensor(MqttEntity, RestoreSensor): 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_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py index 5213f6754608..014257375430 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,31 @@ 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 is available and the entry is loaded + if not hass.config_entries.async_entries( + mqtt.DOMAIN + ) or not await hass.config_entries.async_wait_component( + hass.config_entries.async_entries(mqtt.DOMAIN)[0] + ): + _LOGGER.error("MQTT integration is not available") + return False + + conf: ConfigType = config[DOMAIN] publish_filter = convert_include_exclude_filter(conf) - base_topic = 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 +90,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/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index d8c3debe7ed8..5b8154e17aa5 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 5368f65b83e1..7f9326091fe2 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 de4cbff9b9d3..d7405dba187e 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 e83002ed870a..68f8bb566f1d 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, diff --git a/homeassistant/components/mysensors/notify.py b/homeassistant/components/mysensors/notify.py deleted file mode 100644 index 97d4175a6f20..000000000000 --- 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 7293a676a76f..000000000000 --- 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 c192db7549f3..dc5dc76c7ae3 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 e5b0968785fb..6067a98af084 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 c011bdfa427a..73276017254f 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -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 @@ -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( @@ -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 4c8a310ad11a..a55215962086 100644 --- a/homeassistant/components/nam/button.py +++ b/homeassistant/components/nam/button.py @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) RESTART_BUTTON: ButtonEntityDescription = ButtonEntityDescription( key="restart", - name="Restart", + translation_key="restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, ) diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 878e9b9d9696..3f9821a1e34f 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 17983505e919..682ca7756e0f 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 particulate matter 1 μm" + }, + "pmsx003_pm10": { + "name": "PMSx003 particulate matter 10 μm" + }, + "pmsx003_pm25": { + "name": "PMSx003 particulate matter 2.5 μm" + }, + "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 particulate matter 10 μm" + }, + "sds011_pm25": { + "name": "SDS011 particulate matter 2.5 μm" + }, + "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 particulate matter 1 μm" + }, + "sps30_pm10": { + "name": "SPS30 particulate matter 10 μm" + }, + "sps30_pm25": { + "name": "SPS30 particulate matter 2.5 μm" + }, + "sps30_pm4": { + "name": "SPS30 Particulate matter 4 μm" + }, + "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/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py index 187ac0ee8c2a..8eb607b20566 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 bf68d1988d63..c0c7042423bc 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -98,5 +98,15 @@ "title": "Nest Authentication Credentials must be updated", "description": "To improve security and reduce phishing risk Google has deprecated the authentication method used by Home Assistant.\n\n**This requires action by you to resolve** ([more info]({more_info_url}))\n\n1. Visit the integrations page\n1. Click Reconfigure on the Nest integration.\n1. Home Assistant will walk you through the steps to upgrade to Web Authentication.\n\nSee the Nest [integration instructions]({documentation_url}) for troubleshooting information." } + }, + "entity": { + "sensor": { + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + } + } } } diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index c6a519a37d0c..f3f45458d78b 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/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index fd2c399fb347..0ab3dd07edf6 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -221,7 +221,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: netgear_lte_config = config[DOMAIN] # Set up each modem - tasks = [_setup_lte(hass, lte_conf) for lte_conf in netgear_lte_config] + tasks = [ + hass.async_create_task(_setup_lte(hass, lte_conf)) + for lte_conf in netgear_lte_config + ] await asyncio.wait(tasks) # Load platforms for each modem diff --git a/homeassistant/components/netgear_lte/manifest.json b/homeassistant/components/netgear_lte/manifest.json index ae580dbb99b6..427aa9633c87 100644 --- a/homeassistant/components/netgear_lte/manifest.json +++ b/homeassistant/components/netgear_lte/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/netgear_lte", "iot_class": "local_polling", "loggers": ["eternalegypt"], - "requirements": ["eternalegypt==0.0.12"] + "requirements": ["eternalegypt==0.0.15"] } diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 269bd96aa31b..65829f713ef5 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 e9d5b4a8d7fc..3cf3cc3ae2a7 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 000000000000..c5019603c09c --- /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 000000000000..248128dd538c --- /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 000000000000..73a07a77e232 --- /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 000000000000..ed5882cfe749 --- /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 366c6eeb5640..fe4366c334d1 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 31caa46028f9..a5df872e0843 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 000000000000..782865032af8 --- /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 Netxcloud YAML configuration has been deprecated", + "description": "Configuring Netxcloud 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 bf45b9a48710..e2e37ccab2d7 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 e527e03ee8c6..2eafe2b477e5 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/sensor.py b/homeassistant/components/nextdns/sensor.py index 8b0578f386b9..ccbbb5e534ee 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 59319881c02b..517e229d0e4a 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -25,5 +25,294 @@ "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_blizzard": { + "name": "Block Blizzard" + }, + "block_bypass_methods": { + "name": "Block bypass methods" + }, + "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_hulu": { + "name": "Block Hulu" + }, + "block_imgur": { + "name": "Block Imgur" + }, + "block_instagram": { + "name": "Block Instagram" + }, + "block_leagueoflegends": { + "name": "Block League of Legends" + }, + "block_messenger": { + "name": "Block Messenger" + }, + "block_minecraft": { + "name": "Block Minecraft" + }, + "block_netflix": { + "name": "Block Netflix" + }, + "block_nrd": { + "name": "Block newly registered domains" + }, + "block_page": { + "name": "Block page" + }, + "block_parked_domains": { + "name": "Block parked domains" + }, + "block_pinterest": { + "name": "Block Pinterest" + }, + "block_piracy": { + "name": "Block piracy" + }, + "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_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 703a30ad7030..cd584b277134 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,7 +198,7 @@ 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", @@ -206,7 +206,7 @@ SWITCHES = ( ), 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", @@ -214,7 +214,7 @@ SWITCHES = ( ), 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 +222,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 +230,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 +238,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 +246,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,7 +254,7 @@ 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", @@ -270,7 +270,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 +278,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,7 +286,7 @@ 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", @@ -294,7 +294,7 @@ SWITCHES = ( ), 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 +302,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 +310,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,7 +318,7 @@ 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", @@ -326,7 +326,7 @@ SWITCHES = ( ), 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 +334,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 +342,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 +350,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 +358,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 +366,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 +374,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 +382,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 +390,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 +398,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 +406,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 +414,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 +422,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 +430,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 +438,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 +446,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 +454,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 +462,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 +470,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 +478,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 +486,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,7 +494,7 @@ 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", @@ -502,7 +502,7 @@ SWITCHES = ( ), 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 +510,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,7 +518,7 @@ 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", diff --git a/homeassistant/components/nissan_leaf/sensor.py b/homeassistant/components/nissan_leaf/sensor.py index 5b2f99b997b6..cd3524eaf879 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 827fb93a0121..0dafff996d06 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 b9a216875f4b..8fb227140a1f 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -162,7 +162,13 @@ 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 is None + or liveboard.get("departures") is None + or liveboard.get("departures").get("number") is None + or liveboard.get("departures").get("number") == "0" + or liveboard.get("departures").get("departure") is None + ): return next_departure = liveboard["departures"]["departure"][0] diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index d828fb78b783..bc2c328d647c 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/notify/legacy.py b/homeassistant/components/notify/legacy.py index 2d91e1c065a0..110671864e3a 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/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py index 6ebbccc44667..7106b4877860 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 3eb598ffd3e3..28e056e29fbc 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/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 3a75c10333bd..ee8cc4e7e972 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,23 @@ 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.exceptions import ConfigEntryNotReady +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.helpers.network import get_url from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -46,6 +61,29 @@ def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOp return bridge.locks, bridge.openers +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 +116,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 +126,86 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: sw_version=info["versions"]["firmwareVersion"], ) + 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) + hass_url = get_url( + hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False + ) + 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 ConfigEntryNotReady(f"Invalid credentials for Bridge: {err}") from err + except RequestException as err: + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady( + f"Error communicating with Bridge: {err}" + ) from 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 +225,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 +285,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 +350,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 93bf164acc51..2b3006eeb3bd 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/lock.py b/homeassistant/components/nuki/lock.py index 56b19b75a69d..55560d3bf8c2 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 ac69f97a9bea..8b87816fb7d0 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 55f74f5d8ad7..c4578c7d14d1 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 32b72c74252a..f139124e9614 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/number/const.py b/homeassistant/components/number/const.py index 91c1306c2261..48cd04dc26e8 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -127,6 +127,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. @@ -315,6 +324,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 +386,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, @@ -401,6 +423,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { }, 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 e4311f50dd25..971f8d5a514d 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 77ba7e7a913c..46db471305c3 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 eca280d7d43f..1ca61fd158f4 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/sensor.py b/homeassistant/components/nut/sensor.py index 72ca53fc6b82..6574577558e0 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 70ecbfb6d2e9..9ac05546b324 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -33,5 +33,89 @@ } } } + }, + "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 fed7642605d7..ef0731ee94c5 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 96844edd800c..109af7a565b8 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/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index 8e65423b73bb..810b24dca201 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -1 +1,18 @@ """The Obihai integration.""" + +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 from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/obihai/button.py b/homeassistant/components/obihai/button.py new file mode 100644 index 000000000000..0b84d40f4d2d --- /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 000000000000..2f8dd0075b82 --- /dev/null +++ b/homeassistant/components/obihai/config_flow.py @@ -0,0 +1,82 @@ +"""Config flow to configure the Obihai integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +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.Optional( + CONF_USERNAME, + default=DEFAULT_USERNAME, + ): str, + vol.Optional( + CONF_PASSWORD, + default=DEFAULT_PASSWORD, + ): str, + } +) + + +async def async_validate_creds(hass: HomeAssistant, user_input: dict[str, Any]) -> bool: + """Manage Obihai options.""" + return await hass.async_add_executor_job( + validate_auth, + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + + +class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Obihai.""" + + VERSION = 1 + + 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: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + if await async_validate_creds(self.hass, user_input): + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + ) + errors["base"] = "cannot_connect" + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=data_schema, + ) + + # DEPRECATED + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Handle a flow initialized by importing a config.""" + self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) + if await async_validate_creds(self.hass, config): + 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="cannot_connect") diff --git a/homeassistant/components/obihai/connectivity.py b/homeassistant/components/obihai/connectivity.py new file mode 100644 index 000000000000..93eeccd1bb7a --- /dev/null +++ b/homeassistant/components/obihai/connectivity.py @@ -0,0 +1,67 @@ +"""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, +) -> bool: + """Test if the given setting works as expected.""" + obi = get_pyobihai(host, username, password) + + login = obi.check_account() + if not login: + LOGGER.debug("Invalid credentials") + return False + + return True + + +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 000000000000..764534d4791c --- /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 867d7d875dcc..939c170f989f 100644 --- a/homeassistant/components/obihai/manifest.json +++ b/homeassistant/components/obihai/manifest.json @@ -1,7 +1,8 @@ { "domain": "obihai", "name": "Obihai", - "codeowners": ["@dshokouhi"], + "codeowners": ["@dshokouhi", "@ejpenney"], + "config_flow": true, "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 cff4e6232e74..61411b0ce271 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 000000000000..fb673675ad7e --- /dev/null +++ b/homeassistant/components/obihai/strings.json @@ -0,0 +1,25 @@ +{ + "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%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "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 653c15f18438..9c3049ff87d6 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 e255357646b3..b405140bc322 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 59ceb34d6fdf..f2a56e513f28 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 a21a093e0a12..65bd542fc307 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 734971cb2a13..2a7bd307ff88 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 86172a87512d..986be11d1695 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 a12f2bc79869..4d6d0f6965da 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/event.py b/homeassistant/components/onvif/event.py index 54c5b3b007bb..84d75bf80482 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -27,6 +27,13 @@ SUBSCRIPTION_ERRORS = ( ) +def _stringify_onvif_error(error: Exception) -> str: + """Stringify ONVIF error.""" + if isinstance(error, Fault): + return error.message or str(error) or "Device sent empty error" + return str(error) + + class EventManager: """ONVIF Event Manager.""" @@ -79,30 +86,30 @@ class EventManager: async def async_start(self) -> bool: """Start polling events.""" - if await self.device.create_pullpoint_subscription(): - # Create subscription manager - self._subscription = self.device.create_subscription_service( - "PullPointSubscription" - ) + if not await self.device.create_pullpoint_subscription(): + return False - # Renew immediately - await self.async_renew() + # Create subscription manager + self._subscription = self.device.create_subscription_service( + "PullPointSubscription" + ) - # 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)} - ) + # Renew immediately + await self.async_renew() - # Parse event initialization - await self.async_parse_messages(response.NotificationMessage) + # 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)} + ) - self.started = True - return True + # Parse event initialization + await self.async_parse_messages(response.NotificationMessage) - return False + self.started = True + return True async def async_stop(self) -> None: """Unsubscribe from events.""" @@ -112,7 +119,8 @@ class EventManager: if not self._subscription: return - await self._subscription.Unsubscribe() + with suppress(*SUBSCRIPTION_ERRORS): + await self._subscription.Unsubscribe() self._subscription = None async def async_restart(self, _now: dt.datetime | None = None) -> None: @@ -148,7 +156,7 @@ class EventManager: "Retrying later: %s" ), self.unique_id, - err, + _stringify_onvif_error(err), ) if not restarted: @@ -170,7 +178,11 @@ class EventManager: .isoformat(timespec="seconds") .replace("+00:00", "Z") ) - await self._subscription.Renew(termination_time) + with suppress(*SUBSCRIPTION_ERRORS): + # The first time we renew, we may get a Fault error so we + # suppress it. The subscription will be restarted in + # async_restart later. + await self._subscription.Renew(termination_time) def async_schedule_pull(self) -> None: """Schedule async_pull_messages to run.""" @@ -203,7 +215,7 @@ class EventManager: " '%s': %s" ), self.unique_id, - err, + _stringify_onvif_error(err), ) # Treat errors as if the camera restarted. Assume that the pullpoint # subscription is no longer valid. diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index db9f76189de8..ef4497fa284e 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==1.2.1", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==1.2.3", "WSDiscovery==2.0.0"] } diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 41ff6bcf9cd5..6f76142106ad 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -12,17 +12,17 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY 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,7 +63,7 @@ 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): @@ -75,14 +75,14 @@ class OpenAIAgent(conversation.AbstractConversationAgent): ) -> 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 +97,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 +123,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 +138,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 2db5e98a1f40..892d794bcaf1 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, @@ -131,13 +131,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 ed914efeb6ee..46f8603c5f16 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 0e245eb78b5f..88d347355e9e 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/opencv/image_processing.py b/homeassistant/components/opencv/image_processing.py index 7c3a881edf8f..41738100cab9 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 ef30d37bdcd6..b625d9976da2 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/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 51071c9a0a15..aebf1e26c339 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( diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index ed9b62ff4993..87a510216579 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 b5c75f1cc21b..527856ed56e3 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 a1071cc0a11c..adf72f5fe56f 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -11,6 +11,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/oralb", "iot_class": "local_push", - "loggers": ["oralb-ble"], + "loggers": ["oralb_ble"], "requirements": ["oralb-ble==0.17.6"] } diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 78c5893c889b..5942d67b50d5 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -2,81 +2,20 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine -import dataclasses -from functools import wraps -from typing import Any, Concatenate, ParamSpec, TypeVar import aiohttp import python_otbr_api -from python_otbr_api import tlv_parser -from python_otbr_api.pskc import compute_pskc from homeassistant.components.thread import async_add_dataset from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from . import websocket_api from .const import DOMAIN - -_R = TypeVar("_R") -_P = ParamSpec("_P") - -INSECURE_NETWORK_KEYS = ( - # Thread web UI default - bytes.fromhex("00112233445566778899AABBCCDDEEFF"), -) - -INSECURE_PASSPHRASES = ( - # Thread web UI default - "j01Nme", - # Thread documentation default - "J01NME", -) - - -def _handle_otbr_error( - func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]] -) -> Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]]: - """Handle OTBR errors.""" - - @wraps(func) - async def _func(self: OTBRData, *args: _P.args, **kwargs: _P.kwargs) -> _R: - try: - return await func(self, *args, **kwargs) - except python_otbr_api.OTBRError as exc: - raise HomeAssistantError("Failed to call OTBR API") from exc - - return _func - - -@dataclasses.dataclass -class OTBRData: - """Container for OTBR data.""" - - url: str - api: python_otbr_api.OTBR - - @_handle_otbr_error - async def set_enabled(self, enabled: bool) -> None: - """Enable or disable the router.""" - return await self.api.set_enabled(enabled) - - @_handle_otbr_error - async def get_active_dataset_tlvs(self) -> bytes | None: - """Get current active operational dataset in TLVS format, or None.""" - return await self.api.get_active_dataset_tlvs() - - @_handle_otbr_error - async def create_active_dataset( - self, dataset: python_otbr_api.OperationalDataSet - ) -> None: - """Create an active operational dataset.""" - return await self.api.create_active_dataset(dataset) +from .util import OTBRData, update_issues async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -85,54 +24,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def _warn_on_default_network_settings( - hass: HomeAssistant, entry: ConfigEntry, dataset_tlvs: bytes -) -> None: - """Warn user if insecure default network settings are used.""" - dataset = tlv_parser.parse_tlv(dataset_tlvs.hex()) - insecure = False - - if ( - network_key := dataset.get(tlv_parser.MeshcopTLVType.NETWORKKEY) - ) is not None and bytes.fromhex(network_key) in INSECURE_NETWORK_KEYS: - insecure = True - if ( - not insecure - and tlv_parser.MeshcopTLVType.EXTPANID in dataset - and tlv_parser.MeshcopTLVType.NETWORKNAME in dataset - and tlv_parser.MeshcopTLVType.PSKC in dataset - ): - ext_pan_id = dataset[tlv_parser.MeshcopTLVType.EXTPANID] - network_name = dataset[tlv_parser.MeshcopTLVType.NETWORKNAME] - pskc = bytes.fromhex(dataset[tlv_parser.MeshcopTLVType.PSKC]) - for passphrase in INSECURE_PASSPHRASES: - if pskc == compute_pskc(ext_pan_id, network_name, passphrase): - insecure = True - break - - if insecure: - ir.async_create_issue( - hass, - DOMAIN, - f"insecure_thread_network_{entry.entry_id}", - is_fixable=False, - is_persistent=False, - severity=ir.IssueSeverity.WARNING, - translation_key="insecure_thread_network", - ) - else: - ir.async_delete_issue( - hass, - DOMAIN, - f"insecure_thread_network_{entry.entry_id}", - ) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an Open Thread Border Router config entry.""" api = python_otbr_api.OTBR(entry.data["url"], async_get_clientsession(hass), 10) - otbrdata = OTBRData(entry.data["url"], api) + otbrdata = OTBRData(entry.data["url"], api, entry.entry_id) try: dataset_tlvs = await otbrdata.get_active_dataset_tlvs() except ( @@ -142,8 +38,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) as err: raise ConfigEntryNotReady("Unable to connect") from err if dataset_tlvs: - _warn_on_default_network_settings(hass, entry, dataset_tlvs) - await async_add_dataset(hass, entry.title, dataset_tlvs.hex()) + 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 @@ -156,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 0e9c8e960600..434b9026ae28 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -8,15 +8,17 @@ import aiohttp import python_otbr_api from python_otbr_api import tlv_parser import voluptuous as vol +import yarl from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.thread import async_get_preferred_dataset -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow from homeassistant.const import CONF_URL from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_CHANNEL, DOMAIN +from .util import get_allowed_channel _LOGGER = logging.getLogger(__name__) @@ -26,13 +28,12 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _connect_and_create_dataset(self, url: str) -> None: - """Connect to the OTBR and create a dataset if it doesn't have one.""" - api = python_otbr_api.OTBR(url, async_get_clientsession(self.hass), 10) + async def _connect_and_set_dataset(self, otbr_url: str) -> None: + """Connect to the OTBR and create or apply a dataset if it doesn't have one.""" + api = python_otbr_api.OTBR(otbr_url, async_get_clientsession(self.hass), 10) if await api.get_active_dataset_tlvs() is None: - # We currently have no way to know which channel zha is using, assume it's - # the default - zha_channel = DEFAULT_CHANNEL + allowed_channel = await get_allowed_channel(self.hass, otbr_url) + thread_dataset_channel = None thread_dataset_tlv = await async_get_preferred_dataset(self.hass) if thread_dataset_tlv: @@ -40,7 +41,9 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): thread_dataset_channel = int(channel_str, base=16) - if thread_dataset_tlv is not None and zha_channel == thread_dataset_channel: + if thread_dataset_tlv is not None and ( + not allowed_channel or allowed_channel == thread_dataset_channel + ): await api.set_active_dataset_tlvs(bytes.fromhex(thread_dataset_tlv)) else: _LOGGER.debug( @@ -48,7 +51,8 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): ) await api.create_active_dataset( python_otbr_api.OperationalDataSet( - channel=zha_channel, network_name="home-assistant" + channel=allowed_channel if allowed_channel else DEFAULT_CHANNEL, + network_name="home-assistant", ) ) await api.set_enabled(True) @@ -65,7 +69,7 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: url = user_input[CONF_URL] try: - await self._connect_and_create_dataset(url) + await self._connect_and_set_dataset(url) except ( python_otbr_api.OTBRError, aiohttp.ClientError, @@ -86,14 +90,28 @@ 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 + 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") @@ -101,5 +119,5 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(DOMAIN) return self.async_create_entry( title="Open Thread Border Router", - data={"url": url}, + data=config_entry_data, ) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 0a6482b040ee..c10a2417dc6a 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.5"] + "requirements": ["python-otbr-api==1.0.9"] } diff --git a/homeassistant/components/otbr/strings.json b/homeassistant/components/otbr/strings.json index f2efea0c1e8f..129cbec44683 100644 --- a/homeassistant/components/otbr/strings.json +++ b/homeassistant/components/otbr/strings.json @@ -19,6 +19,10 @@ "insecure_thread_network": { "title": "Insecure Thread network settings detected", "description": "Your Thread network is using a default network key or pass phrase.\n\nThis is a security risk, please create a new Thread network." + }, + "otbr_zha_channel_collision": { + "title": "OTBR and ZHA share the same radio but use different channels", + "description": "When OTBR and ZHA share the radio, they must use the same network channel.\n\nIf OTBR and ZHA attempt to connect to networks on different channels, neither Thread/Matter nor Zigbee will work.\n\nOTBR is configured with a Thread network on channel {otbr_channel}, ZHA is configured with a Zigbee network on channel {zha_channel}." } } } diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py new file mode 100644 index 000000000000..b2ce05f280ca --- /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 7c69a8d0a2d9..2189df363ba0 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -1,38 +1,36 @@ """Websocket API for OTBR.""" -from typing import TYPE_CHECKING import python_otbr_api +from python_otbr_api import tlv_parser +import voluptuous as vol -from homeassistant.components.websocket_api import ( - ActiveConnection, - async_register_command, - async_response, - websocket_command, -) +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 DEFAULT_CHANNEL, DOMAIN - -if TYPE_CHECKING: - from . import OTBRData +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) - async_register_command(hass, websocket_create_network) + 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: @@ -56,25 +54,23 @@ async def websocket_info( ) -@websocket_command( +@websocket_api.websocket_command( { "type": "otbr/create_network", } ) -@async_response +@websocket_api.require_admin +@websocket_api.async_response async def websocket_create_network( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + 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 - # We currently have no way to know which channel zha is using, assume it's - # the default - zha_channel = DEFAULT_CHANNEL - data: OTBRData = hass.data[DOMAIN] + channel = await get_allowed_channel(hass, data.url) or DEFAULT_CHANNEL try: await data.set_enabled(False) @@ -85,7 +81,7 @@ async def websocket_create_network( try: await data.create_active_dataset( python_otbr_api.OperationalDataSet( - channel=zha_channel, network_name="home-assistant" + channel=channel, network_name="home-assistant" ) ) except HomeAssistantError as exc: @@ -98,4 +94,105 @@ async def websocket_create_network( 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/overkiz/button.py b/homeassistant/components/overkiz/button.py index 23f1558b2252..8388e2c3b2de 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 e70315e099d0..9d54c04422a3 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 bb095436054f..46a330c97cca 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 3b02523ec20f..d79d2fca6867 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 c9885ada4212..c8e4920a1139 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 7c469518f864..1da7c48f9eb8 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 e90edad1133c..b6835d93ebb0 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 c3fd7cd964d1..aaae64e0454c 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 000000000000..fdaf0d61f1f7 --- /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 d176a137544d..0db01a2d84c2 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 1bc108b531d9..06f257d416b0 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 658f3f5c7f36..caa4f6c3868f 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.7"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 1e37d938cc6e..4c70bab70f5b 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 @@ -473,6 +478,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 5f4f3a046421..41405780124e 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/overkiz/switch.py b/homeassistant/components/overkiz/switch.py index b7416711e77e..a40bd731a0f2 100644 --- a/homeassistant/components/overkiz/switch.py +++ b/homeassistant/components/overkiz/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any +from typing import Any, cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from pyoverkiz.enums.ui import UIClass, UIWidget @@ -15,12 +15,12 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantOverkizData -from .const import DOMAIN +from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES from .entity import OverkizDescriptiveEntity @@ -107,6 +107,19 @@ SWITCH_DESCRIPTIONS: list[OverkizSwitchDescription] = [ ), entity_category=EntityCategory.CONFIG, ), + OverkizSwitchDescription( + key=UIWidget.DYNAMIC_SHUTTER, + name="Silent mode", + turn_on=OverkizCommand.ACTIVATE_OPTION, + turn_on_args=OverkizCommandParam.SILENCE, + turn_off=OverkizCommand.DEACTIVATE_OPTION, + turn_off_args=OverkizCommandParam.SILENCE, + is_on=lambda select_state: ( + OverkizCommandParam.SILENCE + in cast(list, select_state(OverkizState.CORE_ACTIVATED_OPTIONS)) + ), + icon="mdi:feather", + ), ] SUPPORTED_DEVICES = { @@ -123,7 +136,13 @@ async def async_setup_entry( data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] entities: list[OverkizSwitch] = [] - for device in data.platforms[Platform.SWITCH]: + for device in data.coordinator.data.values(): + if ( + device.widget in IGNORED_OVERKIZ_DEVICES + or device.ui_class in IGNORED_OVERKIZ_DEVICES + ): + continue + if description := SUPPORTED_DEVICES.get(device.widget) or SUPPORTED_DEVICES.get( device.ui_class ): diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index f983d0f98d4e..a1fc632c2fd1 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 29f48d47cd99..b2668f060a44 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_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index 14c440f0ec13..5e2ed77233be 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/person/__init__.py b/homeassistant/components/person/__init__.py index 523d21aa69c0..a5e56d007312 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 @@ -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 diff --git a/homeassistant/components/person/strings.json b/homeassistant/components/person/strings.json index c94499d92f5c..8ee8c3a56a24 100644 --- a/homeassistant/components/person/strings.json +++ b/homeassistant/components/person/strings.json @@ -1,9 +1,12 @@ { "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%]" + } } } } diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 3145e82a9423..55ac33d198f0 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 dab8d4fbe242..9b7e52c2119f 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 89cb29f0a078..c6ca70bdc847 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 dc2583858045..302e1b9accf7 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/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 4aa391b567fd..7ec1bf40c665 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/sensor.py b/homeassistant/components/pi_hole/sensor.py index dbca86613778..c6a8d5da83dc 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 2f04b8fe47e4..a997060eb58d 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -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 d92d78dbb52a..b9d8bf828d42 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/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 7500d9988af0..c8b4ce5a2043 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/plant/strings.json b/homeassistant/components/plant/strings.json index 2478564ca88e..853e5daee1f4 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 b215bc0d8216..559f4440aefe 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,6 @@ from .const import ( CONF_SERVER_IDENTIFIER, DISPATCHERS, DOMAIN, - GDM_DEBOUNCER, - GDM_SCANNER, PLATFORMS, PLATFORMS_COMPLETED, PLEX_SERVER_CONFIG, @@ -47,6 +45,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 +61,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 +79,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 +93,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 @@ -161,8 +165,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 +176,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 +218,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 +231,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 +268,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 +287,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 1ebe439ff7cc..10ae380a08aa 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 dea976f46dd5..3f761c9748a5 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" diff --git a/homeassistant/components/plex/helpers.py b/homeassistant/components/plex/helpers.py index 6a0f0780e009..6a334c5ff611 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 95ad3f39c706..d3a0cc0fb2e7 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 13422beec4f7..be5726796059 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 f4a2ac6e03ab..3b66fe0cf6d8 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 827712889e14..9684c79792a0 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 46c0df886119..62576471448d 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 a2c31f17eb1d..ba883883ddc8 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/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 8040e3397276..3fce1509c0f3 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/number.py b/homeassistant/components/plugwise/number.py index 904b0de7a4d2..5a3e394b1196 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 67779386a3c3..c45d47004b8f 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 354656ecd9ea..d708fe741c2e 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -17,8 +17,8 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfTemperature, - UnitOfTime, UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -305,7 +305,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( key="gas_consumed_interval", name="Gas consumed interval", icon="mdi:meter-gas", - native_unit_of_measurement=f"{UnitOfVolume.CUBIC_METERS}/{UnitOfTime.HOURS}", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 7d9f32f26512..afc921f1101e 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 2050de5fb988..d6f8a420cc84 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/point/__init__.py b/homeassistant/components/point/__init__.py index d4b837723003..6600a8240a0d 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/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index 213b7cc03dba..db8b212cc5e4 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", + "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 fab6932edd20..95ce69aed4a9 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 @@ -123,6 +180,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 +295,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 +327,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: schema=vol.Schema({vol.Required(CONF_TYPE): str}), ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LRU_STATS, + _lru_stats, + ) + async_register_admin_service( hass, DOMAIN, @@ -323,4 +455,102 @@ def _log_objects(*_): # integration is used at a time import objgraph # pylint: disable=import-outside-toplevel - _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=100)) + _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000)) + + +def _get_function_absfile(func: Any) -> str | None: + """Get the absolute file path of a function.""" + import inspect # pylint: disable=import-outside-toplevel + + abs_file: str | None = None + with suppress(Exception): + abs_file = inspect.getabsfile(func) + return abs_file + + +def _safe_repr(obj: Any) -> str: + """Get the repr of an object but keep going if there is an exception. + + We wrap repr to ensure if one object cannot be serialized, we can + still get the rest. + """ + try: + return repr(obj) + except Exception: # pylint: disable=broad-except + return f"Failed to serialize {type(obj)}" + + +def _find_backrefs_not_to_self(_object: Any) -> list[str]: + import objgraph # pylint: disable=import-outside-toplevel + + return [ + _safe_repr(backref) + for backref in objgraph.find_backref_chain( + _object, lambda obj: obj is not _object + ) + ] + + +def _log_object_sources( + max_objects: int, last_ids: set[int], last_stats: dict[str, int] +) -> None: + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import gc # pylint: disable=import-outside-toplevel + + gc.collect() + + objects = gc.get_objects() + new_objects: list[object] = [] + new_objects_overflow: dict[str, int] = {} + current_ids = set() + new_stats: dict[str, int] = {} + had_new_object_growth = False + try: + for _object in objects: + object_type = type(_object).__name__ + new_stats[object_type] = new_stats.get(object_type, 0) + 1 + + for _object in objects: + id_ = id(_object) + current_ids.add(id_) + if id_ in last_ids: + continue + object_type = type(_object).__name__ + if last_stats.get(object_type, 0) < new_stats[object_type]: + if len(new_objects) < max_objects: + new_objects.append(_object) + else: + new_objects_overflow.setdefault(object_type, 0) + new_objects_overflow[object_type] += 1 + + for _object in new_objects: + had_new_object_growth = True + object_type = type(_object).__name__ + _LOGGER.critical( + "New object %s (%s/%s) at %s: %s", + object_type, + last_stats.get(object_type, 0), + new_stats[object_type], + _get_function_absfile(_object) or _find_backrefs_not_to_self(_object), + _safe_repr(_object), + ) + + for object_type, count in last_stats.items(): + new_stats[object_type] = max(new_stats.get(object_type, 0), count) + finally: + # Break reference cycles + del objects + del new_objects + last_ids.clear() + last_ids.update(current_ids) + last_stats.clear() + last_stats.update(new_stats) + del new_stats + del current_ids + + if new_objects_overflow: + _LOGGER.critical("New objects overflowed by %s", new_objects_overflow) + elif not had_new_object_growth: + _LOGGER.critical("No new object growth found") diff --git a/homeassistant/components/profiler/services.yaml b/homeassistant/components/profiler/services.yaml index 8d9ae35ed107..3bd6d7636ac6 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 04f353e96b8a..9f594fc6dae7 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 133c182e2cc5..cfcb07773f5c 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,6 +60,14 @@ 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.""" diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py new file mode 100644 index 000000000000..848b763903a2 --- /dev/null +++ b/homeassistant/components/prosegur/camera.py @@ -0,0 +1,97 @@ +"""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]) + + 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/const.py b/homeassistant/components/prosegur/const.py index b066b320a174..3f5b86919708 100644 --- a/homeassistant/components/prosegur/const.py +++ b/homeassistant/components/prosegur/const.py @@ -3,3 +3,5 @@ DOMAIN = "prosegur" CONF_COUNTRY = "country" + +SERVICE_REQUEST_IMAGE = "request_image" diff --git a/homeassistant/components/prosegur/diagnostics.py b/homeassistant/components/prosegur/diagnostics.py new file mode 100644 index 000000000000..d24456983488 --- /dev/null +++ b/homeassistant/components/prosegur/diagnostics.py @@ -0,0 +1,29 @@ +"""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 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]) + + 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 1827939d097a..d5081a82dbfc 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.8"] } diff --git a/homeassistant/components/prosegur/services.yaml b/homeassistant/components/prosegur/services.yaml new file mode 100644 index 000000000000..0db63cb7adf8 --- /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/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 8e8842abf477..2764f22b0806 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 @@ -28,26 +27,25 @@ 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( { @@ -188,14 +186,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 +222,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 +232,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 @@ -250,12 +258,12 @@ class ProxmoxEntity(CoordinatorEntity): def __init__( self, coordinator: DataUpdateCoordinator, - unique_id, - name, - icon, - host_name, - node_name, - vm_id=None, + 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) @@ -295,7 +303,17 @@ class ProxmoxEntity(CoordinatorEntity): 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 +323,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 +342,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 9bb78d46ea73..828c81911480 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -51,7 +51,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 +78,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 +91,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 000000000000..6477c081463a --- /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/manifest.json b/homeassistant/components/proxmoxve/manifest.json index 1c6806957950..8cf3bc7932d4 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/prusalink/button.py b/homeassistant/components/prusalink/button.py index f2dd939122b5..7e95b209bad9 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 a6c16e2f5f2d..a8b8f387effb 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/sensor.py b/homeassistant/components/prusalink/sensor.py index 9edd09f0ff14..bcfadb291661 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -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,7 +74,7 @@ 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, @@ -85,7 +85,7 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { "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 +93,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 +113,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 9d6a1a2ed53f..34611e4fffb3 100644 --- a/homeassistant/components/prusalink/strings.json +++ b/homeassistant/components/prusalink/strings.json @@ -25,6 +25,40 @@ "pausing": "Pausing", "printing": "Printing" } + }, + "heatbed_temperature": { + "name": "Heatbed temperature" + }, + "nozzle_temperature": { + "name": "Nozzle temperature" + }, + "progress": { + "name": "Progress" + }, + "filename": { + "name": "Filename" + }, + "print_start": { + "name": "Print start" + }, + "print_finish": { + "name": "Print finish" + } + }, + "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 d9c5f2f6ddb6..0f5c57c5e4cb 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -23,7 +23,7 @@ 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 @@ -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: diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 5df92fd795aa..23438dd80c47 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/pushbullet/config_flow.py b/homeassistant/components/pushbullet/config_flow.py index e6259fa8ceea..1eca2bd890b4 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 fcc9d00dc7a4..1cc851bdb991 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 aef97991c664..b61469f6b2a8 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 92d22d117dcb..a6571ae7bf0f 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/pushsafer/notify.py b/homeassistant/components/pushsafer/notify.py index ddf4ca5ef4bf..5411db05e2d4 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 b8869cdee758..b78f49b74f9c 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["pvo==0.2.2"] + "requirements": ["pvo==1.0.0"] } diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py new file mode 100644 index 000000000000..5f9ad42f7fcd --- /dev/null +++ b/homeassistant/components/qbittorrent/const.py @@ -0,0 +1,3 @@ +"""Constants for qBittorrent.""" + +DEFAULT_NAME = "qBittorrent" diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index 2c1a7be74fa7..47090ab8b91c 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -3,6 +3,7 @@ "name": "qBittorrent", "codeowners": ["@geoffreylagaisse"], "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 14bc0eb2ed99..cafb8d8b21ee 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( CONF_NAME, @@ -27,14 +28,14 @@ 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 + _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 +44,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, ), ) @@ -85,8 +90,7 @@ def setup_platform( name = config.get(CONF_NAME) entities = [ - QBittorrentSensor(description, client, name, LoginRequired) - for description in SENSOR_TYPES + QBittorrentSensor(description, client, name) for description in SENSOR_TYPES ] add_entities(entities, True) @@ -106,12 +110,10 @@ class QBittorrentSensor(SensorEntity): description: SensorEntityDescription, qbittorrent_client, client_name, - exception, ) -> 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_available = False @@ -125,7 +127,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/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py index fc9fd7276154..1adddc485599 100644 --- a/homeassistant/components/qld_bushfire/geo_location.py +++ b/homeassistant/components/qld_bushfire/geo_location.py @@ -149,7 +149,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/quadrafire/__init__.py b/homeassistant/components/quadrafire/__init__.py new file mode 100644 index 000000000000..662e9d088728 --- /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 000000000000..fcd263e3212d --- /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 076c1d2722bc..c8e23b684160 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 1bb971e3e016..a109c4b99f75 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 bc27c0b2203a..c04a1a09f814 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/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index a800061b5836..2c71eac01933 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 a3acc2e43894..ca488ade461e 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 f0b31cdb4d61..21f53d72bfa5 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/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 5815c0ce1264..33650cfc2fef 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/sensor.py b/homeassistant/components/rainmachine/sensor.py index 3d56ff59fc5f..22943d73fcb4 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 ae445d82783d..60db5085951c 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 67ffc83d5bd1..d4131fdb022c 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/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index 2cb660921c59..5ec3a6ae1903 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["vehicle==0.4.0"] + "requirements": ["vehicle==1.0.0"] } diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 71795bfa6646..750f504d0964 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,6 +28,9 @@ 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, ) from .core import Recorder @@ -142,12 +145,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 +159,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 000000000000..aa3880bf1d64 --- /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 000000000000..66ae1a1407ee --- /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 000000000000..e32cbd4df7f9 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/events/schema.py @@ -0,0 +1,31 @@ +"""Events schema repairs.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...db_schema import EventData, Events +from ..schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, +) + +if TYPE_CHECKING: + from ... import Recorder + + +def validate_db_schema(instance: Recorder) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + return validate_table_schema_supports_utf8( + instance, EventData, (EventData.shared_data,) + ) | validate_db_schema_precision(instance, Events) + + +def correct_db_schema( + instance: Recorder, + schema_errors: set[str], +) -> None: + """Correct issues detected by validate_db_schema.""" + correct_db_schema_utf8(instance, EventData, schema_errors) + correct_db_schema_precision(instance, Events, schema_errors) diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py new file mode 100644 index 000000000000..ec05eafd1406 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -0,0 +1,218 @@ +"""Schema repairs.""" +from __future__ import annotations + +from collections.abc import Iterable, Mapping +import logging +from typing import TYPE_CHECKING + +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm.attributes import InstrumentedAttribute + +from ..const import SupportedDialect +from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE +from ..util import session_scope + +if TYPE_CHECKING: + from .. import Recorder + +_LOGGER = logging.getLogger(__name__) + +MYSQL_ERR_INCORRECT_STRING_VALUE = 1366 + +# This name can't be represented unless 4-byte UTF-8 unicode is supported +UTF8_NAME = "𓆚𓃗" + +# This number can't be accurately represented as a 32-bit float +PRECISE_NUMBER = 1.000000000000001 + + +def _get_precision_column_types( + table_object: type[DeclarativeBase], +) -> list[str]: + """Get the column names for the columns that need to be checked for precision.""" + return [ + column.key + for column in table_object.__table__.columns + if column.type is DOUBLE_TYPE + ] + + +def validate_table_schema_supports_utf8( + instance: Recorder, + table_object: type[DeclarativeBase], + columns: tuple[InstrumentedAttribute, ...], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + # Lack of full utf8 support is only an issue for MySQL / MariaDB + if instance.dialect_name != SupportedDialect.MYSQL: + return schema_errors + + try: + schema_errors = _validate_table_schema_supports_utf8( + instance, table_object, columns + ) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + + _log_schema_errors(table_object, schema_errors) + return schema_errors + + +def _validate_table_schema_supports_utf8( + instance: Recorder, + table_object: type[DeclarativeBase], + columns: tuple[InstrumentedAttribute, ...], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=instance.get_session(), read_only=True) as session: + db_object = table_object(**{column.key: UTF8_NAME for column in columns}) + table = table_object.__tablename__ + # Try inserting some data which needs utf8mb4 support + session.add(db_object) + try: + session.flush() + except OperationalError as err: + if err.orig and err.orig.args[0] == MYSQL_ERR_INCORRECT_STRING_VALUE: + _LOGGER.debug( + "Database %s statistics_meta does not support 4-byte UTF-8", + table, + ) + schema_errors.add(f"{table}.4-byte UTF-8") + return schema_errors + raise + finally: + session.rollback() + return schema_errors + + +def validate_db_schema_precision( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL + if instance.dialect_name not in ( + SupportedDialect.MYSQL, + SupportedDialect.POSTGRESQL, + ): + return schema_errors + try: + schema_errors = _validate_db_schema_precision(instance, table_object) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + + _log_schema_errors(table_object, schema_errors) + return schema_errors + + +def _validate_db_schema_precision( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + columns = _get_precision_column_types(table_object) + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=instance.get_session(), read_only=True) as session: + db_object = table_object(**{column: PRECISE_NUMBER for column in columns}) + table = table_object.__tablename__ + try: + session.add(db_object) + session.flush() + session.refresh(db_object) + _check_columns( + schema_errors=schema_errors, + stored={column: getattr(db_object, column) for column in columns}, + expected={column: PRECISE_NUMBER for column in columns}, + columns=columns, + table_name=table, + supports="double precision", + ) + finally: + session.rollback() + return schema_errors + + +def _log_schema_errors( + table_object: type[DeclarativeBase], schema_errors: set[str] +) -> None: + """Log schema errors.""" + if not schema_errors: + return + _LOGGER.debug( + "Detected %s schema errors: %s", + table_object.__tablename__, + ", ".join(sorted(schema_errors)), + ) + + +def _check_columns( + schema_errors: set[str], + stored: Mapping, + expected: Mapping, + columns: Iterable[str], + table_name: str, + supports: str, +) -> None: + """Check that the columns in the table support the given feature. + + Errors are logged and added to the schema_errors set. + """ + for column in columns: + if stored[column] == expected[column]: + continue + schema_errors.add(f"{table_name}.{supports}") + _LOGGER.error( + "Column %s in database table %s does not support %s (stored=%s != expected=%s)", + column, + table_name, + supports, + stored[column], + expected[column], + ) + + +def correct_db_schema_utf8( + instance: Recorder, table_object: type[DeclarativeBase], schema_errors: set[str] +) -> None: + """Correct utf8 issues detected by validate_db_schema.""" + table_name = table_object.__tablename__ + if f"{table_name}.4-byte UTF-8" in schema_errors: + from ..migration import ( # pylint: disable=import-outside-toplevel + _correct_table_character_set_and_collation, + ) + + _correct_table_character_set_and_collation(table_name, instance.get_session) + + +def correct_db_schema_precision( + instance: Recorder, + table_object: type[DeclarativeBase], + schema_errors: set[str], +) -> None: + """Correct precision issues detected by validate_db_schema.""" + table_name = table_object.__tablename__ + + if f"{table_name}.double precision" in schema_errors: + from ..migration import ( # pylint: disable=import-outside-toplevel + _modify_columns, + ) + + precision_columns = _get_precision_column_types(table_object) + # Attempt to convert timestamp columns to µs precision + session_maker = instance.get_session + engine = instance.engine + assert engine is not None, "Engine should be set" + _modify_columns( + session_maker, + engine, + table_name, + [f"{column} {DOUBLE_PRECISION_TYPE_SQL}" for column in precision_columns], + ) diff --git a/homeassistant/components/recorder/auto_repairs/states/schema.py b/homeassistant/components/recorder/auto_repairs/states/schema.py new file mode 100644 index 000000000000..258e15cbb521 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/states/schema.py @@ -0,0 +1,39 @@ +"""States schema repairs.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...db_schema import StateAttributes, States +from ..schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, +) + +if TYPE_CHECKING: + from ... import Recorder + +TABLE_UTF8_COLUMNS = { + States: (States.state,), + StateAttributes: (StateAttributes.shared_attrs,), +} + + +def validate_db_schema(instance: Recorder) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + for table, columns in TABLE_UTF8_COLUMNS.items(): + schema_errors |= validate_table_schema_supports_utf8(instance, table, columns) + schema_errors |= validate_db_schema_precision(instance, States) + return schema_errors + + +def correct_db_schema( + instance: Recorder, + schema_errors: set[str], +) -> None: + """Correct issues detected by validate_db_schema.""" + for table in (States, StateAttributes): + correct_db_schema_utf8(instance, table, schema_errors) + correct_db_schema_precision(instance, States, schema_errors) diff --git a/homeassistant/components/recorder/auto_repairs/statistics/__init__.py b/homeassistant/components/recorder/auto_repairs/statistics/__init__.py new file mode 100644 index 000000000000..64bfd4fbb292 --- /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 000000000000..8a24dcbf92b8 --- /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 000000000000..9b4687cb72d7 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/statistics/schema.py @@ -0,0 +1,43 @@ +"""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_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) + 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) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index effecf15a8b8..fbec19a2d1ee 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -43,6 +43,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 4e099a3b17fb..68b634d72355 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -11,11 +11,10 @@ import queue import sqlite3 import threading import time -from typing import Any, TypeVar, cast +from typing import Any, TypeVar 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 +from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select from sqlalchemy.engine import Engine from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import scoped_session, sessionmaker @@ -30,7 +29,6 @@ from homeassistant.const import ( 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, @@ -40,73 +38,87 @@ 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.enum import try_parse_enum -from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS from . import migration, statistics from .const import ( + CONTEXT_ID_AS_BINARY_SCHEMA_VERSION, DB_WORKER_PREFIX, DOMAIN, + EVENT_TYPE_IDS_SCHEMA_VERSION, KEEPALIVE_TIME, + LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION, MARIADB_PYMYSQL_URL_PREFIX, MARIADB_URL_PREFIX, MAX_QUEUE_BACKLOG, MYSQLDB_PYMYSQL_URL_PREFIX, MYSQLDB_URL_PREFIX, - SQLITE_MAX_BIND_VARS, SQLITE_URL_PREFIX, + STATES_META_SCHEMA_VERSION, + STATISTICS_ROWS_SCHEMA_VERSION, SupportedDialect, ) from .db_schema import ( + 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, - get_shared_attributes, - get_shared_event_datas, + has_entity_ids_to_migrate, + has_event_type_to_migrate, + has_events_context_ids_to_migrate, + has_states_context_ids_to_migrate, ) -from .run_history import RunHistory +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, + EventIdMigrationTask, + EventsContextIDMigrationTask, EventTask, + EventTypeIDMigrationTask, ImportStatisticsTask, KeepAliveTask, PerodicCleanupTask, PurgeTask, RecorderTask, + StatesContextIDMigrationTask, StatisticsTask, StopTask, SynchronizeTask, + UpdateStatesMetadataTask, UpdateStatisticsMetadataTask, WaitTask, ) from .util import ( build_mysqldb_conv, - chunked, dburl_to_path, end_incomplete_runs, + execute_stmt_lambda_element, + get_index_by_name, is_second_sunday, move_away_broken_database, session_scope, @@ -125,15 +137,6 @@ 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() @@ -168,13 +171,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 @@ -194,32 +198,35 @@ 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) # 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 @@ -289,7 +296,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 @@ -373,7 +383,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: @@ -495,11 +505,10 @@ class Recorder(threading.Thread): If the number of entities has increased, increase the size of the LRU cache to avoid thrashing. """ - state_attributes_lru = self._state_attributes_ids - current_size = state_attributes_lru.get_size() new_size = self.hass.states.async_entity_ids_count() * 2 - if new_size > current_size: - state_attributes_lru.set_size(new_size) + 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: @@ -545,6 +554,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, @@ -581,13 +599,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 @@ -619,6 +643,7 @@ class Recorder(threading.Thread): def run(self) -> None: """Start processing events to save.""" + self.thread_id = threading.get_ident() setup_result = self._setup_recorder() if not setup_result: @@ -640,12 +665,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 @@ -656,7 +682,6 @@ class Recorder(threading.Thread): # 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: @@ -678,18 +703,78 @@ class Recorder(threading.Thread): 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() self._shutdown() + 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 + + 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 @@ -703,6 +788,10 @@ class Recorder(threading.Thread): for task in startup_tasks: self._guarded_process_one_task_or_recover(task) + # Clear startup tasks since this thread runs forever + # and we don't want to hold them in memory + del startup_tasks + self.stop_requested = False while not self.stop_requested: self._guarded_process_one_task_or_recover(queue_.get()) @@ -722,51 +811,12 @@ class Recorder(threading.Thread): else: non_state_change_events.append(event_) - self._pre_process_state_change_events(state_change_events) - self._pre_process_non_state_change_events(non_state_change_events) - - def _pre_process_state_change_events(self, events: list[Event]) -> None: - """Load startup state attributes from the database. - - Since the _state_attributes_ids cache is empty at startup - we restore it from the database to avoid having to look up - the attributes in the database for every state change - until its primed. - """ assert self.event_session is not None - if hashes := [ - StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) - for event in events - if ( - shared_attrs_bytes := self._serialize_state_attributes_from_event(event) - ) - ]: - with self.event_session.no_autoflush: - for hash_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): - for id_, shared_attrs in self.event_session.execute( - get_shared_attributes(hash_chunk) - ).fetchall(): - self._state_attributes_ids[shared_attrs] = id_ - - def _pre_process_non_state_change_events(self, events: list[Event]) -> None: - """Load startup event attributes from the database. - - Since the _event_data_ids cache is empty at startup - we restore it from the database to avoid having to look up - the data in the database for every event until its primed. - """ - assert self.event_session is not None - if hashes := [ - EventData.hash_shared_data_bytes(shared_event_bytes) - for event in events - if (shared_event_bytes := self._serialize_event_data_from_event(event)) - ]: - with self.event_session.no_autoflush: - for hash_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): - for id_, shared_data in self.event_session.execute( - get_shared_event_datas(hash_chunk) - ).fetchall(): - self._event_data_ids[shared_data] = id_ + 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.""" @@ -896,141 +946,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 _serialize_event_data_from_event(self, event: Event) -> bytes | None: - """Serialize event data.""" - try: - return 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) - 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): + 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 - if not (shared_data_bytes := self._serialize_event_data_from_event(event)): + + 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) - - def _serialize_state_attributes_from_event(self, event: Event) -> bytes | None: - """Serialize state changed event data.""" - try: - return 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, - ) - return None + 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 + 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) - if not ( - shared_attrs_bytes := self._serialize_state_attributes_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.""" @@ -1043,9 +1076,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.""" @@ -1071,36 +1104,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.""" @@ -1109,17 +1132,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.clear() - self._state_attributes_ids.clear() - self._event_data_ids.clear() - self._pending_state_attributes.clear() - self._pending_event_data.clear() + 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 @@ -1146,6 +1170,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 @@ -1300,45 +1348,28 @@ class Recorder(threading.Thread): 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.""" diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 9a059c570c6c..c2252e9f68fe 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -21,17 +21,15 @@ from sqlalchemy import ( Identity, Index, Integer, + LargeBinary, SmallInteger, String, Text, - distinct, type_coerce, ) from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite from sqlalchemy.engine.interfaces import Dialect from sqlalchemy.orm import DeclarativeBase, Mapped, aliased, mapped_column, relationship -from sqlalchemy.orm.query import RowReturningQuery -from sqlalchemy.orm.session import Session from typing_extensions import Self from homeassistant.const import ( @@ -55,8 +53,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, ) @@ -66,14 +68,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" @@ -84,6 +88,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 = [ @@ -91,8 +97,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, @@ -107,17 +115,23 @@ 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" +CONTEXT_ID_BIN_MAX_LENGTH = 16 + +MYSQL_COLLATE = "utf8mb4_unicode_ci" +MYSQL_DEFAULT_CHARSET = "utf8mb4" +MYSQL_ENGINE = "InnoDB" _DEFAULT_TABLE_ARGS = { - "mysql_default_charset": "utf8mb4", - "mysql_collate": "utf8mb4_unicode_ci", - "mysql_engine": "InnoDB", - "mariadb_default_charset": "utf8mb4", - "mariadb_collate": "utf8mb4_unicode_ci", - "mariadb_engine": "InnoDB", + "mysql_default_charset": MYSQL_DEFAULT_CHARSET, + "mysql_collate": MYSQL_COLLATE, + "mysql_engine": MYSQL_ENGINE, + "mariadb_default_charset": MYSQL_DEFAULT_CHARSET, + "mariadb_collate": MYSQL_COLLATE, + "mariadb_engine": MYSQL_ENGINE, } @@ -146,6 +160,7 @@ DOUBLE_TYPE = ( .with_variant(oracle.DOUBLE_PRECISION(), "oracle") .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") ) +DOUBLE_PRECISION_TYPE_SQL = "DOUBLE PRECISION" TIMESTAMP_TYPE = DOUBLE_TYPE @@ -173,12 +188,22 @@ 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"), + 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_type: Mapped[str | None] = mapped_column( + String(MAX_LENGTH_EVENT_EVENT_TYPE) + ) # no longer used event_data: Mapped[str | None] = mapped_column( Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") ) @@ -190,25 +215,38 @@ class Events(Base): DATETIME_TYPE ) # no longer used for new rows time_fired_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, index=True) - context_id: Mapped[str | None] = mapped_column( + context_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True ) - context_user_id: Mapped[str | None] = mapped_column( + context_user_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID) ) - context_parent_id: Mapped[str | None] = mapped_column( + context_parent_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID) ) data_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("event_data.data_id"), index=True ) + context_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), + ) + context_user_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), + ) + context_parent_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) + 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 ( "" ) @@ -229,22 +267,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), + parent_id=bytes_to_ulid_or_none(self.context_parent_id_bin), ) try: return Event( @@ -288,8 +329,18 @@ class EventData(Base): ) -> bytes: """Create shared_data from an event.""" if dialect == SupportedDialect.POSTGRESQL: - return json_bytes_strip_null(event.data) - return json_bytes(event.data) + bytes_result = json_bytes_strip_null(event.data) + bytes_result = json_bytes(event.data) + if len(bytes_result) > MAX_EVENT_DATA_BYTES: + _LOGGER.warning( + "Event data for %s exceed maximum size of %s bytes. " + "This can cause database performance issues; Event data " + "will not be stored", + event.event_type, + MAX_EVENT_DATA_BYTES, + ) + return b"{}" + return bytes_result @staticmethod @lru_cache @@ -309,25 +360,50 @@ 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"), + 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( + String(MAX_LENGTH_STATE_ENTITY_ID) + ) # no longer used for new rows state: Mapped[str | None] = mapped_column(String(MAX_LENGTH_STATE_STATE)) attributes: Mapped[str | None] = mapped_column( Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") ) # no longer used for new rows - event_id: Mapped[int | None] = mapped_column( # no longer used for new rows - Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True - ) + event_id: Mapped[int | None] = mapped_column(Integer) # no longer used for new rows last_changed: Mapped[datetime | None] = mapped_column( DATETIME_TYPE ) # no longer used for new rows @@ -344,13 +420,13 @@ 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( + context_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True ) - context_user_id: Mapped[str | None] = mapped_column( + context_user_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID) ) - context_parent_id: Mapped[str | None] = mapped_column( + context_parent_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID) ) origin_idx: Mapped[int | None] = mapped_column( @@ -358,11 +434,25 @@ class States(Base): ) # 0 is local, 1 is remote old_state: Mapped[States | None] = relationship("States", remote_side=[state_id]) state_attributes: Mapped[StateAttributes | None] = relationship("StateAttributes") + context_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), + ) + context_user_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), + ) + context_parent_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) + 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"" @@ -388,9 +478,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, @@ -414,9 +507,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), + parent_id=bytes_to_ulid_or_none(self.context_parent_id_bin), ) try: attrs = json_loads_object(self.attributes) if self.attributes else {} @@ -517,6 +610,25 @@ 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.""" @@ -526,7 +638,6 @@ class StatisticsBase: 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 @@ -657,27 +768,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 diff --git a/homeassistant/components/recorder/entity_registry.py b/homeassistant/components/recorder/entity_registry.py new file mode 100644 index 000000000000..fbf6e6917770 --- /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 90f7d8c0a064..24d22704a89b 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 000000000000..7a569e70b156 --- /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 000000000000..6d0150925d37 --- /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 000000000000..33717ca78cf6 --- /dev/null +++ b/homeassistant/components/recorder/history/const.py @@ -0,0 +1,23 @@ +"""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"} +IGNORE_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in IGNORE_DOMAINS] +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 94% rename from homeassistant/components/recorder/history.py rename to homeassistant/components/recorder/history/legacy.py index a745716757f4..c33825a767cd 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -22,43 +22,30 @@ from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_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 ( +from ... import recorder +from ..db_schema import RecorderRuns, StateAttributes, States +from ..filters import Filters +from ..models import ( LazyState, - LazyStatePreSchema31, 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 +from ..models.legacy import LazyStatePreSchema31, row_to_compressed_state_pre_schema_31 +from ..util import execute_stmt_lambda_element, session_scope +from .common import _schema_version +from .const import ( + IGNORE_DOMAINS_ENTITY_ID_LIKE, + LAST_CHANGED_KEY, + NEED_ATTRIBUTE_DOMAINS, + SIGNIFICANT_DOMAINS, + SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE, + STATE_KEY, +) _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", -} - _BASE_STATES = ( States.entity_id, @@ -151,11 +138,7 @@ _FIELD_MAP_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. @@ -230,7 +213,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, @@ -268,7 +251,7 @@ def _significant_states_stmt( 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 ( @@ -323,9 +306,8 @@ def _significant_states_stmt( 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] + lambda q: q.filter(filters.states_entity_filter()), track_on=[filters] # type: ignore[union-attr] ) if schema_version >= 31: @@ -442,7 +424,7 @@ def _state_changed_during_period_stmt( 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: @@ -505,7 +487,7 @@ def state_changes_during_period( 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: + with session_scope(hass=hass, read_only=True) as session: stmt = _state_changed_during_period_stmt( _schema_version(hass), start_time, @@ -534,7 +516,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: @@ -575,7 +557,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 ) @@ -601,7 +583,7 @@ def _get_states_for_entities_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 @@ -673,7 +655,7 @@ def _get_states_for_all_stmt( no_attributes: bool, ) -> StatementLambdaElement: """Baked query to get states for all 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 did not get an include-list of entities, query all states in the inner @@ -730,8 +712,9 @@ def _get_states_for_all_stmt( ) 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 = stmt.add_criteria( + lambda q: q.filter(filters.states_entity_filter()), track_on=[filters] # type: ignore[union-attr] + ) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) @@ -759,7 +742,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 @@ -787,7 +770,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: diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py new file mode 100644 index 000000000000..f7d08c6bba80 --- /dev/null +++ b/homeassistant/components/recorder/history/modern.py @@ -0,0 +1,816 @@ +"""Provide pre-made queries on top of the recorder component.""" +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Callable, Iterable, Iterator, MutableMapping +from datetime import datetime +from itertools import groupby +from operator import itemgetter +from typing import Any, cast + +from sqlalchemy import Column, and_, func, lambda_stmt, or_, select +from sqlalchemy.engine.row import Row +from sqlalchemy.orm.properties import MappedColumn +from sqlalchemy.orm.query import Query +from sqlalchemy.orm.session import Session +from sqlalchemy.sql.expression import literal +from sqlalchemy.sql.lambdas import StatementLambdaElement + +from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE +from homeassistant.core import HomeAssistant, State, split_entity_id +import homeassistant.util.dt as dt_util + +from ... import recorder +from ..db_schema import RecorderRuns, StateAttributes, States, StatesMeta +from ..filters import Filters +from ..models import ( + LazyState, + extract_metadata_ids, + process_timestamp, + row_to_compressed_state, +) +from ..util import execute_stmt_lambda_element, session_scope +from .const import ( + IGNORE_DOMAINS_ENTITY_ID_LIKE, + LAST_CHANGED_KEY, + NEED_ATTRIBUTE_DOMAINS, + SIGNIFICANT_DOMAINS, + SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE, + STATE_KEY, +) + +_BASE_STATES = ( + States.metadata_id, + States.state, + States.last_changed_ts, + States.last_updated_ts, +) +_BASE_STATES_NO_LAST_CHANGED = ( # type: ignore[var-annotated] + States.metadata_id, + States.state, + literal(value=None).label("last_changed_ts"), + States.last_updated_ts, +) +_QUERY_STATE_NO_ATTR = (*_BASE_STATES,) +_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED = (*_BASE_STATES_NO_LAST_CHANGED,) +_QUERY_STATES = ( + *_BASE_STATES, + # Remove States.attributes once all attributes are in StateAttributes.shared_attrs + States.attributes, + StateAttributes.shared_attrs, +) +_QUERY_STATES_NO_LAST_CHANGED = ( + *_BASE_STATES_NO_LAST_CHANGED, + # Remove States.attributes once all attributes are in StateAttributes.shared_attrs + States.attributes, + StateAttributes.shared_attrs, +) +_FIELD_MAP = { + cast(MappedColumn, field).name: idx + for idx, field in enumerate(_QUERY_STATE_NO_ATTR) +} + + +def _lambda_stmt_and_join_attributes( + no_attributes: bool, include_last_changed: bool = True +) -> tuple[StatementLambdaElement, bool]: + """Return the lambda_stmt and if StateAttributes should be joined. + + Because these are lambda_stmt the values inside the lambdas need + to be explicitly written out to avoid caching the wrong values. + """ + # If no_attributes was requested we do the query + # without the attributes fields and do not join the + # state_attributes table + if no_attributes: + if include_last_changed: + return ( + lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR)), + False, + ) + return ( + lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED)), + False, + ) + + if include_last_changed: + return lambda_stmt(lambda: select(*_QUERY_STATES)), True + return lambda_stmt(lambda: select(*_QUERY_STATES_NO_LAST_CHANGED)), True + + +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 _ignore_domains_filter(query: Query) -> Query: + """Add a filter to ignore domains we do not fetch history for.""" + return query.filter( + and_( + *[ + ~StatesMeta.entity_id.like(entity_domain) + for entity_domain in IGNORE_DOMAINS_ENTITY_ID_LIKE + ] + ) + ) + + +def _significant_states_stmt( + start_time: datetime, + end_time: datetime | None, + metadata_ids: list[int] | None, + metadata_ids_in_significant_domains: list[int], + filters: Filters | None, + significant_changes_only: bool, + no_attributes: bool, +) -> StatementLambdaElement: + """Query the database for significant state changes.""" + stmt, join_attributes = _lambda_stmt_and_join_attributes( + no_attributes, include_last_changed=not significant_changes_only + ) + join_states_meta = False + if metadata_ids and significant_changes_only: + # Since we are filtering on entity_id (metadata_id) we can avoid + # the join of the states_meta table since we already know which + # metadata_ids are in the significant domains. + stmt += lambda q: q.filter( + States.metadata_id.in_(metadata_ids_in_significant_domains) + | (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) + ) + elif significant_changes_only: + # This is the case where we are not filtering on entity_id + # so we need to join the states_meta table to filter out + # the domains we do not care about. This query path was + # only used by the old history page to show all entities + # in the UI. The new history page filters on entity_id + # so this query path is not used anymore except for third + # party integrations that use the history API. + stmt += lambda q: q.filter( + or_( + *[ + StatesMeta.entity_id.like(entity_domain) + for entity_domain in SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE + ], + ( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) + ), + ) + ) + join_states_meta = True + + if metadata_ids: + stmt += lambda q: q.filter( + # https://github.com/python/mypy/issues/2608 + States.metadata_id.in_(metadata_ids) # type:ignore[arg-type] + ) + else: + stmt += _ignore_domains_filter + if filters and filters.has_config: + stmt = stmt.add_criteria( + lambda q: q.filter(filters.states_metadata_entity_filter()), # type: ignore[union-attr] + track_on=[filters], + ) + join_states_meta = True + + start_time_ts = start_time.timestamp() + stmt += lambda q: q.filter(States.last_updated_ts > start_time_ts) + if end_time: + end_time_ts = end_time.timestamp() + stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) + if join_states_meta: + stmt += lambda q: q.outerjoin( + StatesMeta, States.metadata_id == StatesMeta.metadata_id + ) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + stmt += lambda q: q.order_by(States.metadata_id, States.last_updated_ts) + return stmt + + +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). + """ + metadata_ids: list[int] | None = None + entity_id_to_metadata_id: dict[str, int | None] | None = None + metadata_ids_in_significant_domains: list[int] = [] + if entity_ids: + instance = recorder.get_instance(hass) + if not ( + entity_id_to_metadata_id := instance.states_meta_manager.get_many( + entity_ids, session, False + ) + ) or not (metadata_ids := extract_metadata_ids(entity_id_to_metadata_id)): + return {} + if significant_changes_only: + metadata_ids_in_significant_domains = [ + metadata_id + for entity_id, metadata_id in entity_id_to_metadata_id.items() + if metadata_id is not None + and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS + ] + stmt = _significant_states_stmt( + start_time, + end_time, + metadata_ids, + metadata_ids_in_significant_domains, + filters, + significant_changes_only, + no_attributes, + ) + states = execute_stmt_lambda_element( + session, stmt, None if entity_ids else start_time, end_time + ) + return _sorted_states_to_dict( + hass, + session, + states, + start_time, + entity_ids, + entity_id_to_metadata_id, + filters, + include_start_time_state, + minimal_response, + no_attributes, + 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: datetime, + end_time: datetime | None, + metadata_id: int | None, + no_attributes: bool, + descending: bool, + limit: int | None, +) -> StatementLambdaElement: + stmt, join_attributes = _lambda_stmt_and_join_attributes( + no_attributes, include_last_changed=False + ) + start_time_ts = start_time.timestamp() + stmt += lambda q: q.filter( + ( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) + ) + & (States.last_updated_ts > start_time_ts) + ) + if end_time: + end_time_ts = end_time.timestamp() + stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) + if metadata_id: + stmt += lambda q: q.filter(States.metadata_id == metadata_id) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + if descending: + stmt += lambda q: q.order_by(States.metadata_id, States.last_updated_ts.desc()) + else: + stmt += lambda q: q.order_by(States.metadata_id, States.last_updated_ts) + if limit: + stmt += lambda q: q.limit(limit) + return stmt + + +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.""" + 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, read_only=True) as session: + metadata_id: int | None = None + entity_id_to_metadata_id = None + if entity_id: + instance = recorder.get_instance(hass) + metadata_id = instance.states_meta_manager.get(entity_id, session, False) + entity_id_to_metadata_id = {entity_id: metadata_id} + stmt = _state_changed_during_period_stmt( + start_time, + end_time, + metadata_id, + no_attributes, + descending, + limit, + ) + states = execute_stmt_lambda_element( + session, stmt, None if entity_id else start_time, end_time + ) + return cast( + MutableMapping[str, list[State]], + _sorted_states_to_dict( + hass, + session, + states, + start_time, + entity_ids, + entity_id_to_metadata_id, + include_start_time_state=include_start_time_state, + ), + ) + + +def _get_last_state_changes_stmt( + number_of_states: int, metadata_id: int +) -> StatementLambdaElement: + stmt, join_attributes = _lambda_stmt_and_join_attributes( + False, include_last_changed=False + ) + if number_of_states == 1: + stmt += lambda q: q.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, + ), + ) + else: + stmt += lambda q: q.where( + States.state_id + == ( + select(States.state_id) + .filter(States.metadata_id == metadata_id) + .order_by(States.last_updated_ts.desc()) + .limit(number_of_states) + .subquery() + ).c.state_id + ) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + + stmt += lambda q: q.order_by(States.state_id.desc()) + return stmt + + +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 ( + metadata_id := instance.states_meta_manager.get(entity_id, session, False) + ): + return {} + entity_id_to_metadata_id: dict[str, int | None] = {entity_id_lower: metadata_id} + stmt = _get_last_state_changes_stmt(number_of_states, metadata_id) + states = list(execute_stmt_lambda_element(session, stmt)) + return cast( + MutableMapping[str, list[State]], + _sorted_states_to_dict( + hass, + session, + reversed(states), + dt_util.utcnow(), + entity_ids, + entity_id_to_metadata_id, + include_start_time_state=False, + ), + ) + + +def _get_states_for_entities_stmt( + run_start: datetime, + utc_point_in_time: datetime, + metadata_ids: list[int], + no_attributes: bool, +) -> StatementLambdaElement: + """Baked query to get states for specific entities.""" + stmt, join_attributes = _lambda_stmt_and_join_attributes( + no_attributes, include_last_changed=True + ) + # We got an include-list of entities, accelerate the query by filtering already + # in the inner query. + run_start_ts = process_timestamp(run_start).timestamp() + utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) + stmt += lambda q: q.join( + ( + 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 < utc_point_in_time_ts) + ) + .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 join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) + return stmt + + +def _get_states_for_all_stmt( + 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( + no_attributes, include_last_changed=True + ) + # We did not get an include-list of entities, query all states in the inner + # query, then filter out unwanted domains as well as applying the custom filter. + # This filtering can't be done in the inner query because the domain column is + # not indexed and we can't control what's in the custom filter. + run_start_ts = process_timestamp(run_start).timestamp() + utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) + stmt += lambda q: q.join( + ( + most_recent_states_by_date := ( + select( + States.metadata_id.label("max_metadata_id"), + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter( + (States.last_updated_ts >= run_start_ts) + & (States.last_updated_ts < utc_point_in_time_ts) + ) + .group_by(States.metadata_id) + .subquery() + ) + ), + and_( + States.metadata_id == most_recent_states_by_date.c.max_metadata_id, + States.last_updated_ts == most_recent_states_by_date.c.max_last_updated, + ), + ) + stmt += _ignore_domains_filter + if filters and filters.has_config: + stmt = stmt.add_criteria( + lambda q: q.filter(filters.states_metadata_entity_filter()), # type: ignore[union-attr] + track_on=[filters], + ) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) + stmt += lambda q: q.outerjoin( + StatesMeta, States.metadata_id == StatesMeta.metadata_id + ) + return stmt + + +def _get_rows_with_session( + hass: HomeAssistant, + session: Session, + utc_point_in_time: datetime, + entity_ids: list[str] | None = None, + entity_id_to_metadata_id: dict[str, int | None] | None = None, + run: RecorderRuns | None = None, + filters: Filters | None = None, + no_attributes: bool = False, +) -> Iterable[Row]: + """Return the states at a specific point in time.""" + if entity_ids and len(entity_ids) == 1: + if not entity_id_to_metadata_id or not ( + metadata_id := entity_id_to_metadata_id.get(entity_ids[0]) + ): + return [] + return execute_stmt_lambda_element( + session, + _get_single_entity_states_stmt( + utc_point_in_time, metadata_id, no_attributes + ), + ) + + if run is None: + run = recorder.get_instance(hass).recorder_runs_manager.get(utc_point_in_time) + + if run is None or process_timestamp(run.start) > utc_point_in_time: + # History did not run before utc_point_in_time + return [] + + # We have more than one entity to look at so we need to do a query on states + # since the last recorder run started. + if entity_ids: + if not entity_id_to_metadata_id or not ( + metadata_ids := extract_metadata_ids(entity_id_to_metadata_id) + ): + return [] + stmt = _get_states_for_entities_stmt( + run.start, utc_point_in_time, metadata_ids, no_attributes + ) + else: + stmt = _get_states_for_all_stmt( + run.start, utc_point_in_time, filters, no_attributes + ) + + return execute_stmt_lambda_element(session, stmt) + + +def _get_single_entity_states_stmt( + utc_point_in_time: datetime, + metadata_id: int, + no_attributes: bool = False, +) -> 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( + no_attributes, include_last_changed=True + ) + utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) + stmt += ( + lambda q: q.filter( + States.last_updated_ts < utc_point_in_time_ts, + States.metadata_id == metadata_id, + ) + .order_by(States.last_updated_ts.desc()) + .limit(1) + ) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + return stmt + + +def _sorted_states_to_dict( + hass: HomeAssistant, + session: Session, + states: Iterable[Row], + start_time: datetime, + entity_ids: list[str] | None, + entity_id_to_metadata_id: dict[str, int | None] | None, + filters: Filters | None = None, + include_start_time_state: bool = True, + minimal_response: bool = False, + no_attributes: bool = False, + compressed_state_format: 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]], datetime | 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 + + result: dict[str, list[State | dict[str, Any]]] = defaultdict(list) + metadata_id_to_entity_id: dict[int, str] = {} + metadata_id_idx = field_map["metadata_id"] + + # Set all entity IDs to empty lists in result set to maintain the order + if entity_ids is not None: + for ent_id in entity_ids: + result[ent_id] = [] + + if entity_id_to_metadata_id: + metadata_id_to_entity_id = { + v: k for k, v in entity_id_to_metadata_id.items() if v is not None + } + else: + metadata_id_to_entity_id = recorder.get_instance( + hass + ).states_meta_manager.get_metadata_id_to_entity_id(session) + + # Get the states at the start time + initial_states: dict[int, Row] = {} + if include_start_time_state: + initial_states = { + row[metadata_id_idx]: row + for row in _get_rows_with_session( + hass, + session, + start_time, + entity_ids, + entity_id_to_metadata_id, + filters=filters, + no_attributes=no_attributes, + ) + } + + if entity_ids and len(entity_ids) == 1: + if not entity_id_to_metadata_id or not ( + metadata_id := entity_id_to_metadata_id.get(entity_ids[0]) + ): + return {} + states_iter: Iterable[tuple[int, Iterator[Row]]] = ( + (metadata_id, iter(states)), + ) + else: + key_func = itemgetter(metadata_id_idx) + states_iter = groupby(states, key_func) + + # Append all changes to it + for metadata_id, group in states_iter: + attr_cache: dict[str, dict[str, Any]] = {} + prev_state: Column | str | None = None + if not (entity_id := metadata_id_to_entity_id.get(metadata_id)): + continue + ent_results = result[entity_id] + if row := initial_states.pop(metadata_id, None): + prev_state = row.state + ent_results.append(state_class(row, attr_cache, start_time, entity_id=entity_id)) # type: ignore[call-arg] + + if ( + not minimal_response + or split_entity_id(entity_id)[0] in NEED_ATTRIBUTE_DOMAINS + ): + ent_results.extend( + state_class(db_state, attr_cache, None, entity_id=entity_id) # type: ignore[call-arg] + for db_state in group + ) + continue + + # 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 + ent_results.append( + state_class(first_state, attr_cache, None, entity_id=entity_id) # type: ignore[call-arg] + ) + + state_idx = field_map["state"] + last_updated_ts_idx = field_map["last_updated_ts"] + + # + # 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 there are no states beyond the initial state, + # the state a was never popped from initial_states + for metadata_id, row in initial_states.items(): + if entity_id := metadata_id_to_entity_id.get(metadata_id): + result[entity_id].append( + state_class(row, {}, start_time, entity_id=entity_id) # type: ignore[call-arg] + ) + + # 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 4f87c19ca7a5..c64c38fb7e5f 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["sqlalchemy==2.0.6", "fnvhash==0.1.0"] + "requirements": ["sqlalchemy==2.0.7", "fnvhash==0.1.0"] } diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 838cb181d072..23382a9aeb39 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -7,9 +7,10 @@ from dataclasses import dataclass, replace as dataclass_replace from datetime import timedelta import logging 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, @@ -24,43 +25,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_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_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 database_job_retry_wrapper, 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_CONTEXT_ID = b"\x00" * 16 +_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()] @@ -74,7 +143,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) @@ -93,7 +166,7 @@ class SchemaValidationStatus: """Store schema validation status.""" current_version: int - statistics_schema_errors: set[str] + schema_errors: set[str] valid: bool @@ -120,13 +193,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 @@ -164,14 +248,18 @@ def migrate_schema( 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)) @@ -199,11 +287,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: @@ -218,8 +307,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. @@ -231,83 +336,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, ) @@ -485,11 +560,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 @@ -520,13 +591,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 @@ -556,7 +623,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, @@ -677,38 +744,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 # @@ -788,12 +832,14 @@ def _apply_update( # noqa: C901 # 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"]) @@ -836,7 +882,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" ) @@ -852,11 +898,16 @@ 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") @@ -874,7 +925,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 @@ -887,18 +939,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") @@ -936,18 +988,124 @@ def _apply_update( # noqa: C901 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, @@ -1193,6 +1351,281 @@ def _migrate_statistics_columns_to_timestamp( ) +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) == 32: + return UUID(context_id).bytes + if len(context_id) == 26: + return ulid_to_bytes(context_id) + return None + + +@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 _EMPTY_CONTEXT_ID, + "context_user_id": None, + "context_user_id_bin": _to_bytes(context_user_id), + "context_parent_id": None, + "context_parent_id_bin": _to_bytes(context_parent_id), + } + for state_id, context_id, context_user_id, context_parent_id in states + ], + ) + # 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 _EMPTY_CONTEXT_ID, + "context_user_id": None, + "context_user_id_bin": _to_bytes(context_user_id), + "context_parent_id": None, + "context_parent_id_bin": _to_bytes(context_parent_id), + } + for event_id, context_id, context_user_id, context_parent_id in events + ], + ) + # 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 + + 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", "ix_states_entity_id_last_updated_ts") + + _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.py b/homeassistant/components/recorder/models.py deleted file mode 100644 index acdf61743f9c..000000000000 --- a/homeassistant/components/recorder/models.py +++ /dev/null @@ -1,483 +0,0 @@ -"""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 awesomeversion import AwesomeVersion -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 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 = "{}" - - -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): - """A lazy version of core State before schema 31.""" - - __slots__ = [ - "_row", - "_attributes", - "_last_changed", - "_last_updated", - "_context", - "attr_cache", - ] - - def __init__( # pylint: disable=super-init-not-called - self, - row: Row, - attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, - ) -> None: - """Init the lazy state.""" - self._row = row - self.entity_id: str = self._row.entity_id - self.state = self._row.state or "" - self._attributes: dict[str, Any] | None = None - self._last_changed: datetime | None = start_time - self._last_updated: datetime | None = start_time - self._context: Context | None = None - self.attr_cache = attr_cache - - @property # type: ignore[override] - def attributes(self) -> dict[str, Any]: - """State attributes.""" - if self._attributes is None: - self._attributes = decode_attributes_from_row(self._row, self.attr_cache) - return self._attributes - - @attributes.setter - def attributes(self, value: dict[str, Any]) -> None: - """Set attributes.""" - self._attributes = value - - @property - def context(self) -> Context: - """State context.""" - if self._context is None: - self._context = Context(id=None) - return self._context - - @context.setter - def context(self, value: Context) -> None: - """Set context.""" - self._context = value - - @property - def last_changed(self) -> datetime: - """Last changed datetime.""" - if self._last_changed is None: - if (last_changed := self._row.last_changed) is not None: - self._last_changed = process_timestamp(last_changed) - else: - self._last_changed = self.last_updated - return self._last_changed - - @last_changed.setter - def last_changed(self, value: datetime) -> None: - """Set last changed datetime.""" - self._last_changed = value - - @property - def last_updated(self) -> datetime: - """Last updated datetime.""" - if self._last_updated is None: - self._last_updated = process_timestamp(self._row.last_updated) - return self._last_updated - - @last_updated.setter - def last_updated(self, value: datetime) -> None: - """Set last updated datetime.""" - self._last_updated = value - - 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. - """ - if self._last_changed is None and self._last_updated is None: - last_updated_isoformat = process_timestamp_to_utc_isoformat( - self._row.last_updated - ) - if ( - self._row.last_changed is None - or self._row.last_changed == self._row.last_updated - ): - last_changed_isoformat = last_updated_isoformat - else: - last_changed_isoformat = process_timestamp_to_utc_isoformat( - self._row.last_changed - ) - else: - last_updated_isoformat = self.last_updated.isoformat() - if self.last_changed == self.last_updated: - 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, - } - - -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: datetime | None, - ) -> None: - """Init the lazy state.""" - self._row = row - self.entity_id: str = self._row.entity_id - self.state = self._row.state or "" - self._attributes: dict[str, Any] | None = None - self._last_updated_ts: float | None = self._row.last_updated_ts or ( - dt_util.utc_to_timestamp(start_time) if start_time else None - ) - self._last_changed_ts: float | None = ( - self._row.last_changed_ts or self._last_updated_ts - ) - self._context: Context | None = None - self.attr_cache = attr_cache - - @property # type: ignore[override] - def attributes(self) -> dict[str, Any]: - """State attributes.""" - if self._attributes is None: - self._attributes = decode_attributes_from_row(self._row, self.attr_cache) - return self._attributes - - @attributes.setter - def attributes(self, value: dict[str, Any]) -> None: - """Set attributes.""" - self._attributes = value - - @property - def context(self) -> Context: - """State context.""" - if self._context is None: - self._context = Context(id=None) - return self._context - - @context.setter - def context(self, value: Context) -> None: - """Set context.""" - self._context = value - - @property - def last_changed(self) -> datetime: - """Last changed datetime.""" - assert self._last_changed_ts is not None - return dt_util.utc_from_timestamp(self._last_changed_ts) - - @last_changed.setter - def last_changed(self, value: datetime) -> None: - """Set last changed datetime.""" - self._last_changed_ts = process_timestamp(value).timestamp() - - @property - def last_updated(self) -> datetime: - """Last updated datetime.""" - assert self._last_updated_ts is not None - return dt_util.utc_from_timestamp(self._last_updated_ts) - - @last_updated.setter - def last_updated(self, value: datetime) -> None: - """Set last updated datetime.""" - self._last_updated_ts = process_timestamp(value).timestamp() - - def as_dict(self) -> dict[str, Any]: # type: ignore[override] - """Return a dict representation of the LazyState. - - Async friendly. - - To be used for JSON serialization. - """ - last_updated_isoformat = self.last_updated.isoformat() - if self._last_changed_ts == self._last_updated_ts: - last_changed_isoformat = last_updated_isoformat - else: - last_changed_isoformat = self.last_changed.isoformat() - return { - "entity_id": self.entity_id, - "state": self.state, - "attributes": self._attributes or self.attributes, - "last_changed": last_changed_isoformat, - "last_updated": last_updated_isoformat, - } - - -def 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( - row: Row, - attr_cache: dict[str, dict[str, Any]], - start_time: datetime | 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), - } - if start_time: - comp_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp(start_time) - else: - row_last_updated_ts: float = row.last_updated_ts - comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts - if ( - row_changed_changed_ts := row.last_changed_ts - ) and row_last_updated_ts != row_changed_changed_ts: - comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_changed_changed_ts - return comp_state - - -def 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(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 - # - slow_range_in_select: bool diff --git a/homeassistant/components/recorder/models/__init__.py b/homeassistant/components/recorder/models/__init__.py new file mode 100644 index 000000000000..91dd80c4aa2e --- /dev/null +++ b/homeassistant/components/recorder/models/__init__.py @@ -0,0 +1,54 @@ +"""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 .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_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 000000000000..dbd9383bdeba --- /dev/null +++ b/homeassistant/components/recorder/models/context.py @@ -0,0 +1,42 @@ +"""Models for Recorder.""" +from __future__ import annotations + +from contextlib import suppress +from functools import lru_cache +from uuid import UUID + +from homeassistant.util.ulid import bytes_to_ulid, ulid_to_bytes + + +def ulid_to_bytes_or_none(ulid: str | None) -> bytes | None: + """Convert an ulid to bytes.""" + if ulid is None: + return None + return ulid_to_bytes(ulid) + + +def bytes_to_ulid_or_none(_bytes: bytes | None) -> str | None: + """Convert bytes to a ulid.""" + if _bytes is None: + return None + return bytes_to_ulid(_bytes) + + +@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 000000000000..e39f05cd9c5a --- /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/legacy.py b/homeassistant/components/recorder/models/legacy.py new file mode 100644 index 000000000000..c26e51777203 --- /dev/null +++ b/homeassistant/components/recorder/models/legacy.py @@ -0,0 +1,164 @@ +"""Models for Recorder.""" +from __future__ import annotations + +from datetime import datetime +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 + +from .state_attributes import decode_attributes_from_row +from .time import ( + process_datetime_to_timestamp, + process_timestamp, + process_timestamp_to_utc_isoformat, +) + +# pylint: disable=invalid-name + + +class LazyStatePreSchema31(State): + """A lazy version of core State before schema 31.""" + + __slots__ = [ + "_row", + "_attributes", + "_last_changed", + "_last_updated", + "_context", + "attr_cache", + ] + + def __init__( # pylint: disable=super-init-not-called + self, + row: Row, + attr_cache: dict[str, dict[str, Any]], + start_time: datetime | None, + ) -> None: + """Init the lazy state.""" + self._row = row + self.entity_id: str = self._row.entity_id + self.state = self._row.state or "" + self._attributes: dict[str, Any] | None = None + self._last_changed: datetime | None = start_time + self._last_updated: datetime | None = start_time + self._context: Context | None = None + self.attr_cache = attr_cache + + @property # type: ignore[override] + def attributes(self) -> dict[str, Any]: + """State attributes.""" + if self._attributes is None: + self._attributes = decode_attributes_from_row(self._row, self.attr_cache) + return self._attributes + + @attributes.setter + def attributes(self, value: dict[str, Any]) -> None: + """Set attributes.""" + self._attributes = value + + @property + def context(self) -> Context: + """State context.""" + if self._context is None: + self._context = Context(id=None) + return self._context + + @context.setter + def context(self, value: Context) -> None: + """Set context.""" + self._context = value + + @property + def last_changed(self) -> datetime: + """Last changed datetime.""" + if self._last_changed is None: + if (last_changed := self._row.last_changed) is not None: + self._last_changed = process_timestamp(last_changed) + else: + self._last_changed = self.last_updated + return self._last_changed + + @last_changed.setter + def last_changed(self, value: datetime) -> None: + """Set last changed datetime.""" + self._last_changed = value + + @property + def last_updated(self) -> datetime: + """Last updated datetime.""" + if self._last_updated is None: + self._last_updated = process_timestamp(self._row.last_updated) + return self._last_updated + + @last_updated.setter + def last_updated(self, value: datetime) -> None: + """Set last updated datetime.""" + self._last_updated = value + + 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. + """ + if self._last_changed is None and self._last_updated is None: + last_updated_isoformat = process_timestamp_to_utc_isoformat( + self._row.last_updated + ) + if ( + self._row.last_changed is None + or self._row.last_changed == self._row.last_updated + ): + last_changed_isoformat = last_updated_isoformat + else: + last_changed_isoformat = process_timestamp_to_utc_isoformat( + self._row.last_changed + ) + else: + last_updated_isoformat = self.last_updated.isoformat() + if self.last_changed == self.last_updated: + 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_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(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 diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py new file mode 100644 index 000000000000..5594f5f6d437 --- /dev/null +++ b/homeassistant/components/recorder/models/state.py @@ -0,0 +1,158 @@ +"""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_row +from .time import process_timestamp + +# pylint: disable=invalid-name + +_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: datetime | None, + entity_id: str | None = None, + ) -> None: + """Init the lazy state.""" + self._row = row + self.entity_id = entity_id or self._row.entity_id + self.state = self._row.state or "" + self._attributes: dict[str, Any] | None = None + self._last_updated_ts: float | None = self._row.last_updated_ts or ( + dt_util.utc_to_timestamp(start_time) if start_time else None + ) + self._last_changed_ts: float | None = ( + self._row.last_changed_ts or self._last_updated_ts + ) + self._context: Context | None = None + self.attr_cache = attr_cache + + @property # type: ignore[override] + def attributes(self) -> dict[str, Any]: + """State attributes.""" + if self._attributes is None: + self._attributes = decode_attributes_from_row(self._row, self.attr_cache) + return self._attributes + + @attributes.setter + def attributes(self, value: dict[str, Any]) -> None: + """Set attributes.""" + self._attributes = value + + @property + def context(self) -> Context: + """State context.""" + if self._context is None: + self._context = Context(id=None) + return self._context + + @context.setter + def context(self, value: Context) -> None: + """Set context.""" + self._context = value + + @property + def last_changed(self) -> datetime: + """Last changed datetime.""" + assert self._last_changed_ts is not None + return dt_util.utc_from_timestamp(self._last_changed_ts) + + @last_changed.setter + def last_changed(self, value: datetime) -> None: + """Set last changed datetime.""" + self._last_changed_ts = process_timestamp(value).timestamp() + + @property + def last_updated(self) -> datetime: + """Last updated datetime.""" + assert self._last_updated_ts is not None + return dt_util.utc_from_timestamp(self._last_updated_ts) + + @last_updated.setter + def last_updated(self, value: datetime) -> None: + """Set last updated datetime.""" + self._last_updated_ts = process_timestamp(value).timestamp() + + def as_dict(self) -> dict[str, Any]: # type: ignore[override] + """Return a dict representation of the LazyState. + + Async friendly. + + To be used for JSON serialization. + """ + last_updated_isoformat = self.last_updated.isoformat() + if self._last_changed_ts == self._last_updated_ts: + last_changed_isoformat = last_updated_isoformat + else: + last_changed_isoformat = self.last_changed.isoformat() + return { + "entity_id": self.entity_id, + "state": self.state, + "attributes": self._attributes or self.attributes, + "last_changed": last_changed_isoformat, + "last_updated": last_updated_isoformat, + } + + +def 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), + } + if start_time: + comp_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp(start_time) + else: + row_last_updated_ts: float = row.last_updated_ts + comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts + if ( + row_changed_changed_ts := row.last_changed_ts + ) and row_last_updated_ts != row_changed_changed_ts: + comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_changed_changed_ts + 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 000000000000..3ed109afa071 --- /dev/null +++ b/homeassistant/components/recorder/models/state_attributes.py @@ -0,0 +1,32 @@ +"""State attributes models.""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy.engine.row import Row + +from homeassistant.util.json import json_loads_object + +EMPTY_JSON_OBJECT = "{}" +_LOGGER = logging.getLogger(__name__) + + +def decode_attributes_from_row( + row: Row, attr_cache: dict[str, dict[str, Any]] +) -> dict[str, Any]: + """Decode attributes from a database row.""" + source: str | None = getattr(row, "shared_attrs", None) or getattr( + row, "attributes", None + ) + 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 000000000000..4cf465955c51 --- /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 000000000000..078a982d5ad9 --- /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/purge.py b/homeassistant/components/recorder/purge.py index 7ae63ef026b5..662be41b1c8e 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -1,21 +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 itertools import zip_longest import logging +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 SQLITE_MAX_BIND_VARS -from .db_schema import Events, StateAttributes, States +from .db_schema import Events, States, StatesMeta from .models import DatabaseEngine from .queries import ( attributes_ids_exist_in_states, @@ -24,12 +22,16 @@ 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_event_state_and_attributes_and_data_ids_to_purge, @@ -71,7 +73,7 @@ def purge_old_data( with session_scope(session=instance.get_session()) as session: # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states or events record 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" @@ -109,6 +111,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) @@ -132,11 +142,9 @@ 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) @@ -436,6 +444,8 @@ def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( 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 @@ -448,61 +458,7 @@ 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( @@ -516,7 +472,7 @@ def _purge_batch_attributes_ids( _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( @@ -528,7 +484,7 @@ def _purge_batch_data_ids( _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: @@ -547,8 +503,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) @@ -559,120 +517,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(SQLITE_MAX_BIND_VARS) - .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(SQLITE_MAX_BIND_VARS) + 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.state_id for state 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 SQLITE_MAX_BIND_VARS, 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 d93a6b0d62b1..f983224e212b 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -12,14 +12,27 @@ from .const import SQLITE_MAX_BIND_VARS from .db_schema import ( EventData, Events, + EventTypes, RecorderRuns, StateAttributes, States, + StatesMeta, StatisticsRuns, StatisticsShortTerm, ) +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 get_shared_attributes(hashes: list[int]) -> StatementLambdaElement: """Load shared attributes from the database.""" return lambda_stmt( @@ -38,23 +51,26 @@ def get_shared_event_datas(hashes: list[int]) -> StatementLambdaElement: ) -def find_shared_attributes_id( - data_hash: int, shared_attrs: str -) -> StatementLambdaElement: - """Find an attributes_id by hash and shared_attrs.""" +def find_event_type_ids(event_types: Iterable[str]) -> StatementLambdaElement: + """Find an event_type id by event_type.""" return lambda_stmt( - lambda: select(StateAttributes.attributes_id) - .filter(StateAttributes.hash == data_hash) - .filter(StateAttributes.shared_attrs == shared_attrs) + lambda: select(EventTypes.event_type_id, EventTypes.event_type).filter( + 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 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(EventData.data_id) - .filter(EventData.hash == attr_hash) - .filter(EventData.shared_data == shared_data) + lambda: select(StatesMeta.metadata_id, StatesMeta.entity_id).filter( + StatesMeta.entity_id.in_(entity_ids) + ) ) @@ -667,3 +683,164 @@ 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.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, + ) + ) + ) + .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.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 14337290c9b6..fb2cd1f0befb 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 43ff7548dd6d..f099cede9f21 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 bd11744ab09d..0122ba4464b8 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2,36 +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 Select, and_, bindparam, func, lambda_stmt, select, text -from sqlalchemy.engine import Engine 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 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 ( @@ -55,14 +47,15 @@ from .const import ( DOMAIN, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, - SQLITE_MAX_BIND_VARS, + 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, ) @@ -72,9 +65,9 @@ from .models import ( StatisticMetaData, StatisticResult, datetime_to_timestamp_or_none, + process_timestamp, ) from .util import ( - database_job_retry_wrapper, execute, execute_stmt_lambda_element, get_instance, @@ -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,24 @@ 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 + + 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 +318,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,287 +327,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 * 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 - - -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: @@ -724,68 +415,146 @@ 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) - end = start + timedelta(minutes=5) - # 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: - if session.query(StatisticsRuns).filter_by(start=start).first(): - _LOGGER.debug("Statistics already compiled for %s-%s", start, end) - return True + modified_statistic_ids = _compile_statistics( + instance, session, start, fire_events + ) - _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"): - continue - compiled: PlatformCompiledStatistics = platform.compile_statistics( - instance.hass, start, end - ) - _LOGGER.debug( - "Statistics for %s during %s-%s: %s", - domain, - start, - end, - compiled.platform_stats, - ) - platform_stats.extend(compiled.platform_stats) - current_metadata.update(compiled.current_metadata) + 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) - # Insert collected statistics in the database - for stats in platform_stats: - metadata_id = _update_or_add_metadata( - session, stats["meta"], current_metadata - ) - _insert_statistics( - session, - StatisticsShortTerm, - metadata_id, - stats["stat"], - ) + return True - if start.minute == 55: - # A full hour is ready, summarize it - _compile_hourly_statistics(session, start) - session.add(StatisticsRuns(start=start)) +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 + 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 ( + platform_compile_statistics := getattr( + platform, INTEGRATION_PLATFORM_COMPILE_STATISTICS, None + ) + ): + continue + compiled: PlatformCompiledStatistics = platform_compile_statistics( + instance.hass, start, end + ) + _LOGGER.debug( + "Statistics for %s during %s-%s: %s", + domain, + start, + end, + compiled.platform_stats, + ) + platform_stats.extend(compiled.platform_stats) + current_metadata.update(compiled.current_metadata) + + # Insert collected statistics in the database + 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) + + 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( @@ -859,74 +628,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, @@ -934,17 +668,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( @@ -954,25 +681,95 @@ 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 + ) + + +async def async_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. + """ + instance = get_instance(hass) + + 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) + + 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() + } + + +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, + "display_unit_of_measurement": info["display_unit_of_measurement"], + "has_mean": info["has_mean"], + "has_sum": info["has_sum"], + "name": info.get("name"), + "source": info["source"], + "statistics_unit_of_measurement": info["unit_of_measurement"], + "unit_class": info["unit_class"], + } + for _id, info in result.items() + ] def 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. @@ -982,38 +779,29 @@ def list_statistic_ids( period. """ result = {} - statistic_ids_set = set(statistic_ids) if statistic_ids else None + instance = get_instance(hass) + statistics_meta_manager = instance.statistics_meta_manager # Query the database - with session_scope(hass=hass) as session: - metadata = get_metadata_with_session( + 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) - 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() - } - - if not statistic_ids_set or statistic_ids_set.difference(result): + 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 hasattr(platform, "list_statistic_ids"): + if not ( + platform_list_statistic_ids := getattr( + platform, INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, None + ) + ): continue - platform_statistic_ids = platform.list_statistic_ids( + platform_statistic_ids = platform_list_statistic_ids( hass, statistic_ids=statistic_ids, statistic_type=statistic_type ) @@ -1032,30 +820,18 @@ def list_statistic_ids( } # Return a list of statistic_id + metadata - return [ - { - "statistic_id": _id, - "display_unit_of_measurement": info["display_unit_of_measurement"], - "has_mean": info["has_mean"], - "has_sum": info["has_sum"], - "name": info.get("name"), - "source": info["source"], - "statistics_unit_of_measurement": info["unit_of_measurement"], - "unit_class": info["unit_class"], - } - for _id, info in result.items() - ] + 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 @@ -1067,16 +843,15 @@ 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, } @@ -1146,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( @@ -1196,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( @@ -1248,9 +1023,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( @@ -1264,7 +1039,6 @@ def _generate_statistics_during_period_stmt( end_time: datetime | None, metadata_ids: list[int] | None, table: type[StatisticsBase], - types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> StatementLambdaElement: """Prepare a database query for statistics during a given period. @@ -1589,14 +1363,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 @@ -1707,7 +1483,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) @@ -1720,19 +1496,25 @@ def _statistics_during_period_with_session( 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]]]: +) -> 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. """ + 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] metadata = None # 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 {} @@ -1757,7 +1539,7 @@ def _statistics_during_period_with_session( if "sum" in types: columns = columns.add_columns(table.sum) stmt = _generate_statistics_during_period_stmt( - columns, start_time, end_time, metadata_ids, table, types + columns, start_time, end_time, metadata_ids, table ) stats = cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) @@ -1804,17 +1586,17 @@ 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]]]: +) -> 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, @@ -1863,12 +1645,14 @@ 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] @@ -1902,7 +1686,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 @@ -1915,7 +1699,7 @@ 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 @@ -1926,40 +1710,39 @@ 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)) - stmt += lambda s: s.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 # 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 = [ @@ -2050,17 +1833,17 @@ def _sorted_statistics_to_dict( 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 @@ -2126,7 +1909,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, } @@ -2163,9 +1946,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 @@ -2304,16 +2088,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) @@ -2336,7 +2124,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") @@ -2350,7 +2140,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 @@ -2409,10 +2201,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 @@ -2433,9 +2224,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 @@ -2459,247 +2251,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. diff --git a/homeassistant/components/recorder/system_health/__init__.py b/homeassistant/components/recorder/system_health/__init__.py index da463d38610d..a3545ec2c894 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 000000000000..e56ee4f3415e --- /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 000000000000..4c661e3dc294 --- /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) + ): + 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 000000000000..5b77e9116c7d --- /dev/null +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -0,0 +1,107 @@ +"""Support managing EventTypes.""" +from __future__ import annotations + +from collections.abc import Iterable +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 EventTypes +from ..queries import find_event_type_ids +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) + + 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, + ) + + def get(self, event_type: str, session: Session) -> 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 + ) -> 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] = [] + for event_type in event_types: + if (event_type_id := self._id_map.get(event_type)) is None: + 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) + ): + results[event_type] = self._id_map[event_type] = cast( + int, event_type_id + ) + + 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._pending.clear() + + 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 b424c9999953..455c8375b1cf 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 000000000000..51c626bd3660 --- /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) + ): + 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 000000000000..fcfdcef08911 --- /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 000000000000..639e0acaa3a7 --- /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() + ), + ) + ) + + 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 000000000000..ba47b3600d66 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -0,0 +1,342 @@ +"""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 + ), + ): + 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 c8ad1aeb897f..dfa6ce32d259 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 +_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.""" @@ -330,7 +361,7 @@ class StatisticsTimestampMigrationCleanupTask(RecorderTask): instance.queue_task(StatisticsTimestampMigrationCleanupTask()) -@dataclass +@dataclass(slots=True) class AdjustLRUSizeTask(RecorderTask): """An object to insert into the recorder queue to adjust the LRU size.""" @@ -339,3 +370,99 @@ class AdjustLRUSizeTask(RecorderTask): 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] diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index bfdd8ff5b148..4ec0a0c4501a 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -18,7 +18,7 @@ from awesomeversion import ( AwesomeVersionStrategy, ) import ciso8601 -from sqlalchemy import text +from sqlalchemy import inspect, text from sqlalchemy.engine import Result, Row from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy.orm.query import Query @@ -110,8 +110,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() @@ -121,7 +127,7 @@ 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 @@ -826,3 +832,22 @@ def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: 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 29c0808e6ad9..df42c519fe2c 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -37,6 +37,7 @@ from .statistics import ( 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,7 +152,7 @@ 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"]], @@ -208,7 +209,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, @@ -329,11 +330,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 +413,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") diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index 4a2b20c65de4..f0d2787b6586 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 f309a8f188af..c0db562bd8c0 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -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 b34e14d365a4..01881d6947ab 100644 --- a/homeassistant/components/renault/button.py +++ b/homeassistant/components/renault/button.py @@ -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/device_tracker.py b/homeassistant/components/renault/device_tracker.py index 87ca3c9eb5f0..e646a9648e3b 100644 --- a/homeassistant/components/renault/device_tracker.py +++ b/homeassistant/components/renault/device_tracker.py @@ -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_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 69835552ba4c..9580ea2b7d00 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -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 8fef7d9aee0d..02cf6d5c6b0e 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -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 d75dc55aa210..1433d1e74f5c 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -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 b28f1727c2f9..066b49abcc09 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 2faa89232afa..76c0963e2c06 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,7 +24,17 @@ from .host import ReolinkHost _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.NUMBER, Platform.UPDATE] +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) @@ -33,8 +44,8 @@ class ReolinkData: """Data for the Reolink integration.""" host: ReolinkHost - device_coordinator: DataUpdateCoordinator - firmware_coordinator: DataUpdateCoordinator + device_coordinator: DataUpdateCoordinator[None] + firmware_coordinator: DataUpdateCoordinator[str | Literal[False]] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -56,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 @@ -64,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: @@ -77,7 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async with async_timeout.timeout(host.api.timeout): await host.renew() - async def async_check_firmware_update(): + async def async_check_firmware_update() -> str | Literal[False]: """Check for firmware updates.""" if not host.api.supported(None, "update"): return False diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 541ad9ec9989..1a7649f367ac 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 @@ -113,7 +113,7 @@ 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.""" entity_description: ReolinkBinarySensorEntityDescription diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py new file mode 100644 index 000000000000..65bb8036c0bc --- /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_tilt"), + 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_tilt"), + 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, "pan_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, "pan_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 d14906a57820..a34f8c85d36c 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,7 +41,7 @@ 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 @@ -51,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 e4bc98cc0f87..a29871f28dc1 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: diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 5f983ab34945..48652eac21a5 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -1,6 +1,10 @@ """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 ( @@ -11,24 +15,20 @@ from homeassistant.helpers.update_coordinator import ( from . import ReolinkData from .const import DOMAIN +_T = TypeVar("_T") -class ReolinkBaseCoordinatorEntity(CoordinatorEntity): - """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. - """ +class ReolinkBaseCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[_T]]): + """Parent class fo Reolink entities.""" _attr_has_entity_name = True def __init__( self, reolink_data: ReolinkData, - coordinator: DataUpdateCoordinator | None = None, + coordinator: DataUpdateCoordinator[_T], ) -> None: - """Initialize ReolinkBaseCoordinatorEntity for a NVR entity without a channel.""" - if coordinator is None: - coordinator = reolink_data.device_coordinator + """Initialize ReolinkBaseCoordinatorEntity.""" super().__init__(coordinator) self._host = reolink_data.host @@ -52,26 +52,41 @@ class ReolinkBaseCoordinatorEntity(CoordinatorEntity): return self._host.api.session_active and super().available -class ReolinkCoordinatorEntity(ReolinkBaseCoordinatorEntity): +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, - coordinator: DataUpdateCoordinator | None = None, ) -> None: - """Initialize ReolinkCoordinatorEntity for a hardware camera connected to a channel of the NVR.""" - super().__init__(reolink_data, coordinator) + """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{self._channel}")}, + identifiers={(DOMAIN, f"{self._host.unique_id}_ch{dev_ch}")}, via_device=(DOMAIN, self._host.unique_id), - name=self._host.api.camera_name(self._channel), - model=self._host.api.camera_model(self._channel), + 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 73c0e70812cc..f7810746481b 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 @@ -54,7 +54,9 @@ class ReolinkHost: ) self.webhook_id: str | None = None - self._webhook_url: str | None = None + self._base_url: str = "" + self._webhook_url: str = "" + self._webhook_reachable: asyncio.Event = asyncio.Event() self._lost_subscription: bool = False @property @@ -80,9 +82,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 +103,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 +112,82 @@ 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: + await asyncio.wait_for(self._webhook_reachable.wait(), timeout=15) + except asyncio.TimeoutError: + _LOGGER.debug( + "Did not receive initial ONVIF state on webhook '%s' after 15 seconds", + self._webhook_url, + ) + ir.async_create_issue( + self._hass, + DOMAIN, + "webhook_url", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="webhook_url", + translation_placeholders={ + "name": self._api.nvr_name, + "base_url": self._base_url, + "network_link": "https://my.home-assistant.io/redirect/network/", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") + + if self._api.sw_version_update_required: + ir.async_create_issue( + self._hass, + 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() @@ -260,10 +317,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 +329,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 +340,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/", }, ) @@ -300,10 +357,18 @@ class ReolinkHost: async def handle_webhook( self, hass: HomeAssistant, webhook_id: str, request: Request + ): + """Shield the incoming webhook callback from cancellation.""" + await asyncio.shield(self.handle_webhook_shielded(hass, webhook_id, request)) + + async def handle_webhook_shielded( + self, hass: HomeAssistant, webhook_id: str, request: Request ): """Handle incoming webhook from Reolink for inbound messages and calls.""" _LOGGER.debug("Webhook '%s' called", webhook_id) + if not self._webhook_reachable.is_set(): + self._webhook_reachable.set() if not request.body_exists: _LOGGER.debug("Webhook '%s' triggered without payload", webhook_id) diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py new file mode 100644 index 000000000000..c4923c0088bf --- /dev/null +++ b/homeassistant/components/reolink/light.py @@ -0,0 +1,157 @@ +"""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", + 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, "status_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 62b2b5a038e5..b8de6cd83991 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.5.1"] + "requirements": ["reolink-aio==0.5.9"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 7c807ddadc30..bb19974114d5 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,10 +63,185 @@ 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_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,7 +261,7 @@ async def async_setup_entry( ) -class ReolinkNumberEntity(ReolinkCoordinatorEntity, NumberEntity): +class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): """Base number entity class for Reolink IP cameras.""" entity_description: ReolinkNumberEntityDescription @@ -100,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 @@ -119,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 000000000000..d1da30a01a85 --- /dev/null +++ b/homeassistant/components/reolink/select.py @@ -0,0 +1,147 @@ +"""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, 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), + ), +) + + +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 000000000000..405c3e2716de --- /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"), + ), +) + + +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 cc609488762b..c36001e0377d 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": "[%key:common::config_flow::error::cannot_connect%], check the IP address of the camera and see the troubleshooting steps in the documentation", "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,49 @@ "issues": { "https_webhook": { "title": "Reolink webhook URL uses HTTPS (SSL)", - "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`" + "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device" + }, + "webhook_url": { + "title": "Reolink webhook URL unreachable", + "description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera can not reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL." + }, + "enable_port": { + "title": "Reolink port not enabled", + "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" + } + } } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py new file mode 100644 index 000000000000..a7ed9b6a98d9 --- /dev/null +++ b/homeassistant/components/reolink/switch.py @@ -0,0 +1,239 @@ +"""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), + ), +) + +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 index 5752afc92aca..aeb44cb77408 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, Literal from reolink_aio.exceptions import ReolinkError @@ -34,7 +34,9 @@ async def async_setup_entry( async_add_entities([ReolinkUpdateEntity(reolink_data)]) -class ReolinkUpdateEntity(ReolinkBaseCoordinatorEntity, UpdateEntity): +class ReolinkUpdateEntity( + ReolinkBaseCoordinatorEntity[str | Literal[False]], UpdateEntity +): """Update entity for a Netgear device.""" _attr_device_class = UpdateDeviceClass.FIRMWARE @@ -59,9 +61,6 @@ class ReolinkUpdateEntity(ReolinkBaseCoordinatorEntity, UpdateEntity): @property def latest_version(self) -> str | None: """Latest version available for install.""" - if self.coordinator.data is None: - return None - if not self.coordinator.data: return self.installed_version diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 37c483505b89..637e9da6f9cd 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -41,7 +41,15 @@ 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, + COORDINATOR, + DOMAIN, + PLATFORM_IDX, + REST, + REST_DATA, + REST_IDX, +) from .data import RestData from .schema import CONFIG_SCHEMA, RESOURCE_SCHEMA # noqa: F401 @@ -182,7 +190,7 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res 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 +209,14 @@ 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, + timeout, ) diff --git a/homeassistant/components/rest/const.py b/homeassistant/components/rest/const.py index 5fd32d8fba77..bdc0c5af4922 100644 --- a/homeassistant/components/rest/const.py +++ b/homeassistant/components/rest/const.py @@ -5,6 +5,8 @@ DOMAIN = "rest" DEFAULT_METHOD = "GET" DEFAULT_VERIFY_SSL = True DEFAULT_FORCE_UPDATE = False +DEFAULT_ENCODING = "UTF-8" +CONF_ENCODING = "encoding" 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 c1990b283368..7a5d62694b9f 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -7,7 +7,7 @@ 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 DEFAULT_TIMEOUT = 10 @@ -22,6 +22,7 @@ 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, @@ -33,6 +34,7 @@ class RestData: self._hass = hass self._method = method self._resource = resource + self._encoding = encoding self._auth = auth self._headers = headers self._params = params @@ -51,8 +53,8 @@ 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 ) rendered_headers = template.render_complex(self._headers, parse_result=False) diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index cfd8f8a38527..8e0fa9de00e7 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -33,8 +33,10 @@ from homeassistant.helpers.template_entity import ( ) from .const import ( + CONF_ENCODING, CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, + DEFAULT_ENCODING, DEFAULT_FORCE_UPDATE, DEFAULT_METHOD, DEFAULT_VERIFY_SSL, @@ -57,6 +59,7 @@ RESOURCE_SCHEMA = { vol.Optional(CONF_PAYLOAD): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, 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/switch.py b/homeassistant/components/rest/switch.py index cda35d1f918a..9e016db0376a 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -97,8 +98,8 @@ async def async_setup_platform( "Missing resource or schema in configuration. " "Add http:// or https:// to your URL" ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("No route to resource/endpoint: %s", resource) + except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc class RestSwitch(TemplateEntity, SwitchEntity): diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index 8b9c9165b276..0d0cf218cd07 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/ridwell/__init__.py b/homeassistant/components/ridwell/__init__.py index 116528f4ca85..1b0a83f1c058 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 000000000000..57919ed1feba --- /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 a3b83c70aaeb..9561cd26e4b7 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 772efb87ac73..f48861cee197 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 29dd68e2a817..9c7ceee7f56b 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 05cee54ba9dc..1eba555e9550 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 f16bbaebab63..7a948f8b8832 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 06872cc73387..d2c01bbd4f36 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 f5d70a86cb36..e99fabfab2f2 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 e6b29b94fbf8..143c333f6006 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 027eccb1c3d7..3d198ce7573c 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 b83d3e7b2aea..626444a9dcf5 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 0fa6e3b11142..9a3c80114e9e 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/roku/media_player.py b/homeassistant/components/roku/media_player.py index b09ddb7ef7d7..877e58233d5b 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/roon/manifest.json b/homeassistant/components/roon/manifest.json index f1d26af1909d..4fa527d07694 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 09ecc3cec9fb..3bcafe4ba9a2 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 5861486457fc..f276c0f8fc2a 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 5e8998c47ddb..dd6d7fd67642 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/rympro/manifest.json b/homeassistant/components/rympro/manifest.json index 613a1c33613b..e14ac9af71f1 100644 --- a/homeassistant/components/rympro/manifest.json +++ b/homeassistant/components/rympro/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rympro", "iot_class": "cloud_polling", - "requirements": ["pyrympro==0.0.4"] + "requirements": ["pyrympro==0.0.7"] } diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 993100262e79..0d90157f76ba 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] diff --git a/homeassistant/components/samsungtv/device_trigger.py b/homeassistant/components/samsungtv/device_trigger.py new file mode 100644 index 000000000000..f3a69e637e68 --- /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 000000000000..06a3c3e70e1f --- /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/media_player.py b/homeassistant/components/samsungtv/media_player.py index 3e544b181f15..302d9c4915df 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 e67b50fae78c..cfa04244e829 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 000000000000..cd78ff18be77 --- /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 000000000000..9e2e2af6d42a --- /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 000000000000..de0036234ad1 --- /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/schedule/strings.json b/homeassistant/components/schedule/strings.json index fdcb8c4ffdc8..4c22e5ecead3 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 419dd04f606b..3ca13e56b299 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 fc433ebb6f0b..cd64199fa23f 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 22184a17b803..5ddd6c48e433 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 907aa2a9dfdf..857d53eb5276 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 ad2f9c64f3ee..6662c20ad4f6 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,26 +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, ScreenLogicWarning) as ex: - _LOGGER.warning("Update error - attempting reconnect: %s", ex) - await self._async_reconnect_update_data() + 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 80b4df4d2de7..eb006b553671 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 977ef59f9e7f..5b8b83694274 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.2"] + "requirements": ["screenlogicpy==0.8.2"] } diff --git a/homeassistant/components/script/strings.json b/homeassistant/components/script/strings.json index 2d39b6ac6332..b9624f16a313 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 70702f351f6f..b574081d5d42 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 854c0158439a..39a52e57b10b 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 a568e51ed9d8..27a46943bb3a 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 ce1cea89c906..d553cdf30439 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 6e6a3c704b36..13280ba4f0e2 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 897ed855a5e7..8e8267cb5e0f 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 11a4ba9517fc..9080b940b2a5 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/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 1812f41693d8..4f56be77a944 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -737,7 +737,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 58cf985b09f4..892bc611b3da 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -160,6 +160,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` """ @@ -351,6 +362,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. @@ -429,6 +452,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, @@ -439,6 +463,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.TEMPERATURE: TemperatureConverter, SensorDeviceClass.VOLTAGE: ElectricPotentialConverter, SensorDeviceClass.VOLUME: VolumeConverter, + SensorDeviceClass.VOLUME_STORAGE: VolumeConverter, SensorDeviceClass.WATER: VolumeConverter, SensorDeviceClass.WEIGHT: MassConverter, SensorDeviceClass.WIND_SPEED: SpeedConverter, @@ -460,8 +485,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, @@ -526,6 +553,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}, @@ -559,6 +587,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { 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 5746b8b8e8cb..8547827d7488 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -89,6 +89,7 @@ ENTITY_CONDITIONS = { 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}], @@ -121,6 +122,7 @@ ENTITY_CONDITIONS = { ], 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}], diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index dfd0a576d212..3b2a0485554a 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -88,6 +88,7 @@ ENTITY_TRIGGERS = { 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}], @@ -120,6 +121,7 @@ ENTITY_TRIGGERS = { ], 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}], diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 0d2dc06b83f0..21fbf453ac3f 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,13 @@ 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 + new_state = old_state = last_stat["state"] + _sum = last_stat["sum"] or 0.0 - for fstate, state in fstates: + for fstate, state in valid_float_states: reset = False if ( state_class != SensorStateClass.TOTAL_INCREASING @@ -596,7 +621,7 @@ def _compile_statistics( # noqa: C901 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 2396bbf295b8..262f7033a415 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -94,10 +94,173 @@ "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": "Particulate matter 1 μm" + }, + "pm10": { + "name": "Particulate matter 10 μm" + }, + "pm25": { + "name": "Particulate matter 2.5 μm" + }, + "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" + }, + "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 10699b8c1c65..2457bfcabe35 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 91da03209961..95eff4e7a552 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.16.0"] } diff --git a/homeassistant/components/sfr_box/__init__.py b/homeassistant/components/sfr_box/__init__.py index 4873acf753e5..564f1970b640 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -1,11 +1,13 @@ """SFR Box.""" from __future__ import annotations +import asyncio + from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -34,18 +36,23 @@ 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()), ) + # Preload system information await data.system.async_config_entry_first_refresh() system_info = data.system.data - if system_info.net_infra == "adsl": - await data.dsl.async_config_entry_first_refresh() - else: - platforms = list(platforms) - platforms.remove(Platform.BINARY_SENSOR) + # 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 diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index d90c1944aa4d..e4d41fb0cb85 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 32ebeac97acc..f6741da13984 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 6a7ceb0e86b7..1fb980532679 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 e2f86aeb9241..ff723c2c6efa 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 5f4aadce7e20..19512f438215 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -3,7 +3,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, SystemInfo, WanInfo from homeassistant.components.sensor import ( SensorDeviceClass, @@ -46,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, @@ -135,12 +134,11 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( "loss_of_signal_quality", "unknown", ], - translation_key="line_status", + translation_key="dsl_line_status", value_fn=lambda x: x.line_status.lower().replace(" ", "_"), ), SFRBoxSensorEntityDescription[DslInfo]( key="training", - name="Training", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -156,14 +154,13 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( "showtime", "unknown", ], - translation_key="training", + translation_key="dsl_training", value_fn=lambda x: x.training.lower().replace(" ", "_").replace(".", "_"), ), ) SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( SFRBoxSensorEntityDescription[SystemInfo]( key="net_infra", - name="Network infrastructure", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -178,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( @@ -207,6 +221,10 @@ async def async_setup_entry( 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) diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index ddff342a10d4..cf74e9eb656c 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 0c4f7bb0bfc0..b6cae8ad6053 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 b0aae5259dd9..57de36ce4159 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: # pylint: disable=broad-except 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 fb683bb525a8..b12a86dc2407 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 1457f8f8a620..0e07dd969023 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 bc920ac7c7eb..23f949be4cc6 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/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 820afcf0f099..449fc1422182 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/sensor.py b/homeassistant/components/shelly/sensor.py index 2737270ac764..b83131a1004c 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -295,6 +295,9 @@ SENSORS: Final = { ("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}, @@ -439,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", diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 62a43c5bbc0d..f0d4e1c9bd95 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/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index 6849c9648d00..6b26eb7854be 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -1,7 +1,7 @@ """Support for SimpliSafe binary sensors.""" from __future__ import annotations -from simplipy.device import DeviceTypes +from simplipy.device import DeviceTypes, DeviceV3 from simplipy.device.sensor.v3 import SensorV3 from simplipy.system.v3 import SystemV3 @@ -67,6 +67,9 @@ async def async_setup_entry( if sensor.type in SUPPORTED_BATTERY_SENSOR_TYPES: sensors.append(BatteryBinarySensor(simplisafe, system, sensor)) + for lock in system.locks.values(): + sensors.append(BatteryBinarySensor(simplisafe, system, lock)) + async_add_entities(sensors) @@ -99,14 +102,14 @@ class BatteryBinarySensor(SimpliSafeEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( - self, simplisafe: SimpliSafe, system: SystemV3, sensor: SensorV3 + self, simplisafe: SimpliSafe, system: SystemV3, device: DeviceV3 ) -> None: """Initialize.""" - super().__init__(simplisafe, system, device=sensor) + super().__init__(simplisafe, system, device=device) self._attr_name = "Battery" self._attr_unique_id = f"{super().unique_id}-battery" - self._device: SensorV3 + self._device: DeviceV3 @callback def async_update_from_rest_api(self) -> None: diff --git a/homeassistant/components/siren/strings.json b/homeassistant/components/siren/strings.json index c8e60e91ce09..60d8843c1515 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 65d806a9bcad..8741b2ed5609 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/sleepiq/entity.py b/homeassistant/components/sleepiq/entity.py index d4ca2c894da4..e6eeaa98c227 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/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py index 993ed9571a9d..641d3b8ae4d9 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/sms/sensor.py b/homeassistant/components/sms/sensor.py index d72bda048bbb..cfa31d56e807 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 b4a9279845d1..6bf8cbcc166c 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/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index 8194b3f96ce9..324a1e493661 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 c20e5fe6e367..fc8068fb532a 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 4699aaefd772..d0fe393d5508 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/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index e181e995c748..4b68030f843c 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -177,6 +177,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() @@ -353,10 +354,19 @@ class SonosDiscoveryManager: 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 diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 22517b93991f..cb18ec43887a 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -303,7 +303,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] @@ -493,7 +493,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): @soco_error() def play_media( # noqa: C901 - 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. @@ -691,7 +691,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 +712,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/speaker.py b/homeassistant/components/sonos/speaker.py index 3e66f5690b41..638ede722f5c 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -23,7 +23,7 @@ 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.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -591,13 +591,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 +844,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/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 17c197d692fa..721184313309 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/media_player.py b/homeassistant/components/spotify/media_player.py index 1145686efe76..b63a9513818e 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -104,7 +104,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 +160,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 +199,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 +227,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 +281,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 +321,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 +384,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 +398,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 bba49c415f82..92b640580eb9 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -3,9 +3,15 @@ from __future__ import annotations 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, @@ -36,6 +42,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 +53,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 +85,9 @@ 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.""" + 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 a6b1afe40494..d52f2d10d0d1 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -11,13 +11,14 @@ 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 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__) @@ -85,34 +86,37 @@ 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) - ) 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 SQLAlchemyError: errors["db_url"] = "db_url_invalid" except ValueError: errors["query"] = "query_invalid" + add_db_url = ( + {CONF_DB_URL: db_url} if db_url == db_url_for_validation else {} + ) + if not errors: return self.async_create_entry( title=name, data={}, options={ - CONF_DB_URL: db_url, + **add_db_url, CONF_QUERY: query, CONF_COLUMN_NAME: column, CONF_UNIT_OF_MEASUREMENT: uom, @@ -140,32 +144,32 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow): ) -> FlowResult: """Manage SQL options.""" errors = {} - db_url_default = DEFAULT_URL.format( - hass_config_path=self.hass.config.path(DEFAULT_DB_FILE) - ) 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) 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 SQLAlchemyError: errors["db_url"] = "db_url_invalid" except ValueError: errors["query"] = "query_invalid" else: + new_user_input = user_input + if new_user_input.get(CONF_DB_URL) and db_url == db_url_for_validation: + new_user_input.pop(CONF_DB_URL) return self.async_create_entry( title="", data={ CONF_NAME: name, - CONF_DB_URL: db_url, - **user_input, + **new_user_input, }, ) @@ -176,7 +180,7 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow): vol.Optional( CONF_DB_URL, description={ - "suggested_value": self.entry.options[CONF_DB_URL] + "suggested_value": self.entry.options.get(CONF_DB_URL) }, ): selector.TextSelector(), vol.Required( diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 7513bbd8c7f9..2fed7d979489 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["sqlalchemy==2.0.6"] + "requirements": ["sqlalchemy==2.0.7"] } diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 5d51087a9ddf..c19c2c258bc0 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -6,14 +6,23 @@ 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, 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, @@ -28,9 +37,12 @@ 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 .util import resolve_db_url _LOGGER = logging.getLogger(__name__) +_SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000) + def redact_credentials(data: str) -> str: """Redact credentials from string data.""" @@ -53,7 +65,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 +82,8 @@ async def async_setup_platform( unique_id, db_url, True, + device_class, + state_class, async_add_entities, ) @@ -77,7 +93,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,6 +120,8 @@ async def async_setup_entry( entry.entry_id, db_url, False, + None, + None, async_add_entities, ) @@ -116,34 +134,24 @@ 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 + if use_database_executor := (db_url == instance.db_url): + assert instance.engine is not None + sessmaker = scoped_session(sessionmaker(bind=instance.engine, future=True)) + elif not ( + sessmaker := await hass.async_add_executor_job( + _validate_and_get_session_maker_for_db_url, db_url ) + ): return - finally: - if sess: - sess.close() # MSSQL uses TOP and not LIMIT if not ("LIMIT" in query_str.upper() or "SELECT TOP" in query_str.upper()): @@ -163,12 +171,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 +229,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 +254,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 +282,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/util.py b/homeassistant/components/sql/util.py new file mode 100644 index 000000000000..81d8cd9900cb --- /dev/null +++ b/homeassistant/components/sql/util.py @@ -0,0 +1,12 @@ +"""Utils for sql.""" +from __future__ import annotations + +from homeassistant.components.recorder import get_instance +from homeassistant.core import HomeAssistant + + +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.""" + 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 1411b8bc7827..bb175ee00be1 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 22812f06ed89..d3fae39bc4d3 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/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index b7e28f270457..abff81c05513 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -401,7 +401,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/starlink/__init__.py b/homeassistant/components/starlink/__init__.py index ceb962c88cd9..c59269d2e077 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 000000000000..10711e7155e2 --- /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 af745c6f1555..79cd5ca38958 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -66,7 +66,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 +76,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 +87,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", diff --git a/homeassistant/components/statistics/manifest.json b/homeassistant/components/statistics/manifest.json index 6a41dec447bc..04b5277ecf51 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 9a87129e5d11..078eb59fe723 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/stookalert/binary_sensor.py b/homeassistant/components/stookalert/binary_sensor.py index 70a25c2bfdf6..d3920d3f0e42 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 9eb70fda7ee5..cd84bec11b22 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 94e08d253635..631994021942 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -3,11 +3,12 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio +from collections.abc import AsyncIterable from dataclasses import asdict, dataclass import logging from typing import Any -from aiohttp import StreamReader, web +from aiohttp import web from aiohttp.hdrs import istr from aiohttp.web_exceptions import ( HTTPBadRequest, @@ -153,7 +154,7 @@ class Provider(ABC): @abstractmethod 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. diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index 3e72b079adfc..49ad3cf0d983 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 3de4930a6917..42badfc0185c 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 1aade9465439..5852136ca456 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.5"] } diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index 5479f56cf969..6c8e8fc100b1 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 65836e0c619d..a43bf4fd8082 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 000000000000..8a390ea2a27c --- /dev/null +++ b/homeassistant/components/sun/sensor.py @@ -0,0 +1,142 @@ +"""Sensor platform for Sun integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEGREE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import Sun +from .const import DOMAIN + +ENTITY_ID_SENSOR_FORMAT = SENSOR_DOMAIN + ".sun_{}" + + +@dataclass +class SunEntityDescriptionMixin: + """Mixin for required Sun base description keys.""" + + value_fn: Callable[[Sun], StateType | datetime] + + +@dataclass +class SunSensorEntityDescription(SensorEntityDescription, SunEntityDescriptionMixin): + """Describes Sun sensor entity.""" + + +SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( + SunSensorEntityDescription( + key="next_dawn", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="next_dawn", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_dawn, + ), + SunSensorEntityDescription( + key="next_dusk", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="next_dusk", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_dusk, + ), + SunSensorEntityDescription( + key="next_midnight", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="next_midnight", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_midnight, + ), + SunSensorEntityDescription( + key="next_noon", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="next_noon", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_noon, + ), + SunSensorEntityDescription( + key="next_rising", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="next_rising", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_rising, + ), + SunSensorEntityDescription( + key="next_setting", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="next_setting", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_setting, + ), + SunSensorEntityDescription( + key="solar_elevation", + translation_key="solar_elevation", + icon="mdi:theme-light-dark", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.solar_elevation, + entity_registry_enabled_default=False, + native_unit_of_measurement=DEGREE, + ), + SunSensorEntityDescription( + key="solar_azimuth", + translation_key="solar_azimuth", + icon="mdi:sun-angle", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.solar_azimuth, + entity_registry_enabled_default=False, + native_unit_of_measurement=DEGREE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Sun sensor platform.""" + + sun: Sun = hass.data[DOMAIN] + + async_add_entities( + [SunSensor(sun, description, entry.entry_id) for description in SENSOR_TYPES] + ) + + +class SunSensor(SensorEntity): + """Representation of a Sun Sensor.""" + + _attr_has_entity_name = True + entity_description: SunSensorEntityDescription + + def __init__( + self, sun: Sun, entity_description: SunSensorEntityDescription, entry_id: str + ) -> None: + """Initiate Sun Sensor.""" + self.entity_description = entity_description + self.entity_id = ENTITY_ID_SENSOR_FORMAT.format(entity_description.key) + self._attr_unique_id = f"{entry_id}-{entity_description.key}" + self.sun = sun + + self._attr_device_info = DeviceInfo( + name="Sun", + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def native_value(self) -> StateType | datetime: + """Return value of sensor.""" + state = self.entity_description.value_fn(self.sun) + return state diff --git a/homeassistant/components/sun/strings.json b/homeassistant/components/sun/strings.json index cdcaa416eda1..3d0374f1de0f 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/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 29da03b262e7..900117a54b75 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 7ea84e649ef3..a7934ba42092 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/cover.py b/homeassistant/components/switch_as_x/cover.py index 9d7a7bf61788..b7f8e5bf971e 100644 --- a/homeassistant/components/switch_as_x/cover.py +++ b/homeassistant/components/switch_as_x/cover.py @@ -30,18 +30,14 @@ 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, 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 bc24460a105a..8432c46f856a 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -10,30 +10,47 @@ 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 def __init__( self, - name: str, + hass: HomeAssistant, + config_entry_title: str, switch_entity_id: str, unique_id: str | None, - device_id: str | None = None, - entity_category: EntityCategory | None = None, ) -> 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.name or 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 @@ -67,10 +84,14 @@ 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}, + ) class BaseToggleEntity(BaseEntity, ToggleEntity): diff --git a/homeassistant/components/switch_as_x/fan.py b/homeassistant/components/switch_as_x/fan.py index bfc4d2e037e9..87a6c3872958 100644 --- a/homeassistant/components/switch_as_x/fan.py +++ b/homeassistant/components/switch_as_x/fan.py @@ -23,18 +23,14 @@ 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, 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 c8181bf35f80..7bcdb659e9ce 100644 --- a/homeassistant/components/switch_as_x/light.py +++ b/homeassistant/components/switch_as_x/light.py @@ -21,18 +21,14 @@ 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, 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 a0aac15a702a..e3c29a1cf424 100644 --- a/homeassistant/components/switch_as_x/lock.py +++ b/homeassistant/components/switch_as_x/lock.py @@ -30,18 +30,14 @@ 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, 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 635aa4e2d79b..88ff9a322d30 100644 --- a/homeassistant/components/switch_as_x/siren.py +++ b/homeassistant/components/switch_as_x/siren.py @@ -21,18 +21,14 @@ 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, 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 675f3128721b..00b22d9428cb 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -20,50 +20,50 @@ PARALLEL_UPDATES = 0 BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { "calibration": BinarySensorEntityDescription( key="calibration", - name="Calibration", + translation_key="calibration", entity_category=EntityCategory.DIAGNOSTIC, ), "motion_detected": BinarySensorEntityDescription( key="pir_state", - name="Motion detected", + translation_key="motion", device_class=BinarySensorDeviceClass.MOTION, ), "contact_open": BinarySensorEntityDescription( key="contact_open", - name="Door open", + translation_key="door_open", device_class=BinarySensorDeviceClass.DOOR, ), "contact_timeout": BinarySensorEntityDescription( key="contact_timeout", - name="Door timeout", + translation_key="door_timeout", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, ), "is_light": BinarySensorEntityDescription( key="is_light", - name="Light", + translation_key="light", device_class=BinarySensorDeviceClass.LIGHT, ), "door_open": BinarySensorEntityDescription( key="door_status", - name="Door status", + translation_key="door_open", device_class=BinarySensorDeviceClass.DOOR, ), "unclosed_alarm": BinarySensorEntityDescription( key="unclosed_alarm", - name="Door unclosed alarm", + translation_key="door_unclosed_alarm", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, ), "unlocked_alarm": BinarySensorEntityDescription( key="unlocked_alarm", - name="Door unlocked alarm", + translation_key="door_unclosed_alarm", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, ), "auto_lock_paused": BinarySensorEntityDescription( key="auto_lock_paused", - name="Door auto-lock paused", + translation_key="door_auto_lock_paused", entity_category=EntityCategory.DIAGNOSTIC, ), } diff --git a/homeassistant/components/switchbot/humidifier.py b/homeassistant/components/switchbot/humidifier.py index 2bb71bacea10..148e4c3545f5 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 ada24bcee577..31ce20bea3f1 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.4"] + "requirements": ["PySwitchbot==0.37.5"] } diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index 3ed43790b83b..b5b34bf54ec8 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 3262ffbd7bba..74226583420c 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 67749ea0c5aa..befbf00f8be8 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 bc352989799d..abb18a19ed32 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 a8e4f503d171..ec2f4c0bc904 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 57d4d9977f23..be966d67eefa 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 584f3d7124fa..1d72184ad4d3 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/sensor.py b/homeassistant/components/switcher_kis/sensor.py index c75d27d67d4b..2c74f14cb5cb 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 9d1b5d4bdc50..caed3c3c3204 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/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 9dc40d2dc53d..1f335aee4b9c 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/entity.py b/homeassistant/components/synology_dsm/entity.py index 0cc17222802b..0865686ef20d 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/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 06bfd166bb5e..db0082daeabe 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,7 +114,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_size", - name="Memory Size", + translation_key="memory_size", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -124,7 +124,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_cached", - name="Memory Cached", + translation_key="memory_cached", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -134,7 +134,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_available_swap", - name="Memory Available (Swap)", + translation_key="memory_available_swap", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -143,7 +143,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_available_real", - name="Memory Available (Real)", + translation_key="memory_available_real", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -152,7 +152,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_total_swap", - name="Memory Total (Swap)", + translation_key="memory_total_swap", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -161,7 +161,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="memory_total_real", - name="Memory Total (Real)", + translation_key="memory_total_real", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -170,7 +170,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="network_up", - name="Upload Throughput", + translation_key="network_up", native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", @@ -179,7 +179,7 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoCoreUtilization.API_KEY, key="network_down", - name="Download Throughput", + translation_key="network_down", native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", @@ -190,13 +190,13 @@ 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", + translation_key="volume_size_total", native_unit_of_measurement=UnitOfInformation.TERABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", @@ -206,7 +206,7 @@ STORAGE_VOL_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoStorage.API_KEY, key="volume_size_used", - name="Used Space", + translation_key="volume_size_used", native_unit_of_measurement=UnitOfInformation.TERABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", @@ -215,14 +215,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 +230,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 +241,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 +249,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 +268,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 +277,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,7 +349,7 @@ 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): @@ -357,19 +357,23 @@ class SynoDSMUtilSensor(SynoDSMSensor): if attr is None: return None + result: StateType = attr # Data (RAM) if self.native_unit_of_measurement == UnitOfInformation.MEGABYTES: - return round(attr / 1024.0**2, 1) + result = round(attr / 1024.0**2, 1) + return result # Network if self.native_unit_of_measurement == UnitOfDataRate.KILOBYTES_PER_SECOND: - return round(attr / 1024.0, 1) + result = round(attr / 1024.0, 1) + return result # CPU load average if self.native_unit_of_measurement == ENTITY_UNIT_LOAD: - return round(attr / 100, 2) + result = round(attr / 100, 2) + return result - return attr + return result @property def available(self) -> bool: @@ -393,7 +397,7 @@ 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: @@ -401,9 +405,9 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor): # Data (disk space) if self.native_unit_of_measurement == UnitOfInformation.TERABYTES: - return round(attr / 1024.0**4, 2) + return round(attr / 1024.0**4, 2) # type: ignore[no-any-return] - return attr + return attr # type: ignore[no-any-return] class SynoDSMInfoSensor(SynoDSMSensor): @@ -421,7 +425,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 +438,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 f571b9c53263..bf18a9707a17 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -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 e44c578f4d24..208d299cc2eb 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 ec3ea52293c9..c550b1805539 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 15c61ff0a3cf..e67f7ecf34eb 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/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 8feb1114285f..bb83d90235fc 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/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 320c09a6f07d..2810bcfac72a 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -186,6 +186,12 @@ class SystemBridgeDataUpdateCoordinator( await self.websocket_client.connect( session=async_get_clientsession(self.hass), ) + + self.hass.async_create_task(self._listen_for_data()) + + 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 +217,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 6146a32fd8ec..7462966ae394 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 bc02c9f1cdaf..a6bf29ac5460 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_log/__init__.py b/homeassistant/components/system_log/__init__.py index cd46b4fd9570..8a5f53d52de2 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/tado/__init__.py b/homeassistant/components/tado/__init__.py index 9146d4d83d17..691ca639656c 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 7f009c278fe3..24d62d760269 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 a72451b00231..cab3c42184e0 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 72eb9c8e289b..4d50bc35c3b7 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 11de7ceb3143..c825bafc4b95 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 4289813494a3..d218e9ca9337 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/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py index 2bc23655a20b..d84087b31325 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/sensor.py b/homeassistant/components/tasmota/sensor.py index 61c03b707cfa..ddcdb3e8c26e 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/text/device_action.py b/homeassistant/components/text/device_action.py index 3d14da9bdb85..89fbbc7fbc72 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 0f5ddf5b3318..034f1ab315b8 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 4af21ec8e160..e42ee4478e01 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 345fca854d2c..4fc88479818d 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/dataset_store.py b/homeassistant/components/thread/dataset_store.py index b9a27b617e68..786ea55b34fb 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.""" @@ -159,6 +169,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 index eb1e2a5ef681..8dc5dd43041e 100644 --- a/homeassistant/components/thread/diagnostics.py +++ b/homeassistant/components/thread/diagnostics.py @@ -29,7 +29,7 @@ from .dataset_store import async_get_store from .discovery import async_read_zeroconf_cache if TYPE_CHECKING: - from pyroute2 import NDB # pylint: disable=no-name-in-module + from pyroute2 import NDB class Neighbour(TypedDict): @@ -121,7 +121,7 @@ def _get_routes_and_neighbors(): NDB, ) - with NDB() as ndb: # pylint: disable=not-callable + with NDB() as ndb: routes, reverse_routes = _get_possible_thread_routes(ndb) neighbours = _get_neighbours(ndb) diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 5a2ee54c5bb0..7dce5a429d86 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -6,6 +6,7 @@ import dataclasses import logging from typing import cast +from python_otbr_api.mdns import StateBitmap from zeroconf import BadTypeInNameException, DNSPointer, ServiceListener, Zeroconf from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf @@ -29,14 +30,16 @@ TYPE_PTR = 12 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 - vendor_name: str | None - addresses: list[str] | None thread_version: str | None + unconfigured: bool | None + vendor_name: str | None def async_discovery_data_from_service( @@ -53,21 +56,38 @@ def async_discovery_data_from_service( 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( - brand=KNOWN_BRANDS.get(vendor_name), + 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, - vendor_name=vendor_name, - addresses=service.parsed_addresses(), thread_version=thread_version, + unconfigured=unconfigured, + vendor_name=vendor_name, ) diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 547def834502..3d61315f3d17 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==1.0.5", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==1.0.9", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 5edea3a61ab1..aca0d5e5d966 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,31 @@ 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) + if not (store.async_get(dataset_id)): + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "unknown dataset" + ) + return + + store.preferred_dataset = dataset_id + connection.send_result(msg["id"]) + + @websocket_api.require_admin @websocket_api.websocket_command( { @@ -118,6 +144,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 +187,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 8cec85bf20da..538655ec0ce3 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 fbb12872306c..31d51fee3f37 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 f149bda05d3e..60ef45c845ea 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/config_flow.py b/homeassistant/components/tibber/config_flow.py index b5cb4486cc93..fbd2345fb80d 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,15 +46,15 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await tibber_connection.update_info() except asyncio.TimeoutError: - errors[CONF_ACCESS_TOKEN] = "timeout" + 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] = "cannot_connect" + errors[CONF_ACCESS_TOKEN] = ERR_CLIENT if errors: return self.async_show_form( diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 7c563208720a..a2f1db7536fa 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,6 +41,7 @@ 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, @@ -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"]): @@ -614,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 @@ -636,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/timer/strings.json b/homeassistant/components/timer/strings.json index 985cea0aa6ef..217de09a534c 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/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 8fdafee6cfd8..c3e8f61fcc89 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 @@ -25,6 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt from .const import ( @@ -56,6 +55,7 @@ from .const import ( START, SUMMARY, ) +from .coordinator import TodoistCoordinator from .types import CalData, CustomProject, ProjectData, TodoistEvent _LOGGER = logging.getLogger(__name__) @@ -117,18 +117,15 @@ 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() # 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 +135,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 +161,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 +184,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 +271,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 +311,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 +379,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 +387,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) @@ -581,8 +575,8 @@ class TodoistProjectData: self, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all tasks in a specific time frame.""" + tasks = self._coordinator.data if self._id is None: - tasks = await self._api.get_tasks() project_task_data = [ task for task in tasks @@ -590,7 +584,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] events = [] for task in project_task_data: @@ -612,15 +606,15 @@ class TodoistProjectData: event = CalendarEvent( summary=task.content, start=due_date_value, - end=due_date_value, + end=due_date_value + timedelta(days=1), ) events.append(event) return events async def async_update(self) -> None: """Get the latest data.""" + tasks = self._coordinator.data if self._id is None: - tasks = await self._api.get_tasks() project_task_data = [ task for task in tasks @@ -628,7 +622,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: diff --git a/homeassistant/components/todoist/coordinator.py b/homeassistant/components/todoist/coordinator.py new file mode 100644 index 000000000000..b573d1d11277 --- /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 e10bc3b81d6b..da64157dad86 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/toon/sensor.py b/homeassistant/components/toon/sensor.py index 3b06f5d38b99..90dd466045cc 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/tplink/light.py b/homeassistant/components/tplink/light.py index 7bbde327e183..e4f91f282f6b 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -267,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: diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 1e7db69cc95e..824ea8df4239 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/controller.py b/homeassistant/components/tplink_omada/controller.py new file mode 100644 index 000000000000..508a8b914da9 --- /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 6950e3b6d74a..3ff73501bdc0 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 3e7f21409bce..41cb1c69180a 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 005589a2f995..9d7234077645 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.1.4"] } diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index dd5ee3168d27..830f75b6a936 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 000000000000..685ad9c57614 --- /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 bbc089de0c9f..4581e2868196 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() diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py index c98bd0fa0201..4b3769413440 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 0cb73723369c..038461494d6f 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 d9b92386bea3..9c0f8f307edc 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 f63f0cdf0afd..d5aee51ed613 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 a7f4a273bbee..7ae480d4f98a 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/sensor.py b/homeassistant/components/tradfri/sensor.py index 689964cb1517..81cce80aa739 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/transmission/const.py b/homeassistant/components/transmission/const.py index 742ef874a354..517ef0a853a2 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -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/sensor.py b/homeassistant/components/transmission/sensor.py index b1ff20627e15..2c7bf24cdfd7 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 = ( + b_spd = ( float(data.downloadSpeed) if self._sub_type == "download" else float(data.uploadSpeed) ) - 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 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: diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 2cf9fafff483..ed1b2f185a2d 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -40,5 +40,17 @@ } } } + }, + "entity": { + "sensor": { + "transmission_status": { + "state": { + "idle": "Idle", + "up_down": "Up/Down", + "seeding": "Seeding", + "downloading": "Downloading" + } + } + } } } diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index ec62da376536..6a30c1b62ba3 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 0d253d7d94f9..aa8864ad23da 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import functools as ft import hashlib from http import HTTPStatus @@ -380,11 +381,12 @@ class SpeechManager: raise HomeAssistantError(f"Not supported language {language}") # Options - if provider.default_options and options: - merged_options = provider.default_options.copy() + if (default_options := provider.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 [] @@ -502,7 +504,8 @@ class SpeechManager: ) # Save to memory - data = self.write_tags(filename, data, provider, message, language, options) + if extension == "mp3": + data = self.write_tags(filename, data, provider, message, language, options) self._async_store_to_memcache(cache_key, filename, data) if cache: @@ -664,8 +667,8 @@ class Provider: return None @property - def default_options(self) -> dict[str, Any] | None: - """Return a dict include default options.""" + def default_options(self) -> Mapping[str, Any] | None: + """Return a mapping with the default options.""" return None def get_tts_audio( diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index ffc00e6f92ca..959a1834f8da 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -221,7 +221,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { ), TuyaLightEntityDescription( key=DPCode.BASIC_INDICATOR, - name="Indicator Light", + name="Indicator light", entity_category=EntityCategory.CONFIG, ), ), @@ -499,9 +499,14 @@ class TuyaLightEntity(TuyaEntity, LightEntity): ), }, ] - elif self._color_data_type and ( + + if self._color_data_type and ( ATTR_HS_COLOR in kwargs - or (ATTR_BRIGHTNESS in kwargs and self.color_mode == ColorMode.HS) + or ( + ATTR_BRIGHTNESS in kwargs + and self.color_mode == ColorMode.HS + and ATTR_COLOR_TEMP not in kwargs + ) ): if self._color_mode_dpcode: commands += [ @@ -542,11 +547,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): }, ] - if ( - ATTR_BRIGHTNESS in kwargs - and self.color_mode != ColorMode.HS - and self._brightness - ): + elif ATTR_BRIGHTNESS in kwargs and self._brightness: brightness = kwargs[ATTR_BRIGHTNESS] # If there is a min/max value, the brightness is actually limited. diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 020099ba5d6b..a2cd2d5fc410 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 1b2fdca32601..a7245913e735 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 27fe764b1e3c..7827fb061ead 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 d36850517345..e4ecbd9d866d 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 f5745734f1a7..cfacc9072f2c 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 20b0ad6593d8..48d5b4bd6f6b 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 adaa7c977f78..a7e8aede3619 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 d4e885b295e8..d283b668995a 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 2721e254de86..60507d5a8c6b 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 c845b6d5d389..149f865e776e 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__) @@ -58,6 +64,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 +73,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 +136,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 +147,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: None, + ), UnifiTrackerEntityDescription[Devices, Device]( key="Device scanner", has_entity_name=True, @@ -140,239 +210,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 +227,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 +268,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 +303,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 +338,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 783950310e4f..18a132be6a8b 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 92f879c10473..473c4ed21a51 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==46"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 05598589febd..3682fa0bf6cd 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 bf724cec1fd7..87c9b9f4f4f7 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, @@ -136,18 +136,18 @@ async def async_poe_port_control_fn( @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 +247,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 6c13bb978523..000000000000 --- 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 11b5eac2d3c2..000000000000 --- 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 42f83dad5d51..13ebd0e33e57 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/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index d61a47e8c7ab..7aa7c6d5cf14 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/light.py b/homeassistant/components/unifiprotect/light.py index feb0be66ecd0..500b4b4703ea 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/number.py b/homeassistant/components/unifiprotect/number.py index ba6ae819dd2b..247e401b2ca1 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/select.py b/homeassistant/components/unifiprotect/select.py index 7bc54aa7afe1..36870bf9c37e 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -1,4 +1,4 @@ -"""This component provides select entities for UniFi Protect.""" +"""Component providing select entities for UniFi Protect.""" from __future__ import annotations from collections.abc import Callable @@ -32,12 +32,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, 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 import config_validation as cv, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.util.dt import utcnow from .const import ATTR_DURATION, ATTR_MESSAGE, DISPATCH_ADOPT, DOMAIN, TYPE_EMPTY_VALUE @@ -319,9 +319,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,7 +352,7 @@ async def async_setup_entry( ) async_add_entities(entities) - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( SERVICE_SET_DOORBELL_MESSAGE, SET_DOORBELL_LCD_MESSAGE_SCHEMA, diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 5b17ed0020cf..783955b34012 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 diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 295b70142615..ea2d8256cbe0 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/universal/media_player.py b/homeassistant/components/universal/media_player.py index c7cc0dd098d3..fd73ad33e16f 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/upc_connect/device_tracker.py b/homeassistant/components/upc_connect/device_tracker.py index 3025ea746d0a..2b5ee2915ef3 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/update/strings.json b/homeassistant/components/update/strings.json index c26d3968ae1a..776c2c59a3a7 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 ac9fe19f4e7c..5f77d58c5ea8 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 2820a5846324..72e14ecc4ffa 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 ed06a9eb3631..b62edbf9bc22 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 b787018adcc9..cd39609d9d51 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/sensor.py b/homeassistant/components/upnp/sensor.py index 1a374714be87..6f0fe340f304 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/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py index 28927baf9268..99aecfc406b5 100644 --- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py +++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py @@ -195,7 +195,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 c436ea757ac7..11e58fca775c 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 c1f82e902d2c..eb5c19941dc9 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 9b85e9e3ae96..f8a4c2d4b75d 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 066a3cd6e104..dad2d8dfaf34 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 @@ -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 = { @@ -127,6 +129,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 +145,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 +164,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 +228,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 +243,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 +271,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 +280,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 +297,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 +315,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): extra.native_unit_of_measurement, last_period, last_reset, + last_valid_state, status, ) @@ -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,70 @@ 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( + "Invalid state (%s > %s)", + 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.""" + 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 + + if (new_state_val := self._validate_state(new_state)) is None: + _LOGGER.warning("Invalid state %s", 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 +461,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 +528,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 +553,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)) ) @@ -590,6 +641,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 @@ -620,6 +672,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 e9f8e7f25053..1eeacbae8003 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 e8fe53b08ae6..9b53c7612477 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 fa76dd800ecd..cf5b09346639 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 c90aa1756e4e..6a2646922b67 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 033946735f77..a27a60bba4f3 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/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index fc451ff2626b..554b16877c72 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: diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index e89c81bc110c..ca00a3134ce4 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 000000000000..af79b5d12769 --- /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 9ad8db08d59b..94e8d667d752 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 5030e01c8b1c..9615404a9a6c 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 8283480a145d..68d549eaa5d1 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 98ed41c5b9f0..1f890a22a644 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 d53c7c9ed667..9392cdd9bc12 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 e8720baa1d53..ac30c58fde56 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 17cadb9598f6..47fbde3ef202 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 740aff0b908a..8dbffe6eee33 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 02cdad158ca0..d13005b265db 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,28 +150,39 @@ 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: diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 9e177a514a13..66dccdc07de9 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 bbc1c15159c3..7c9639b65423 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 c8326d737569..17feb4a7fe9e 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 ffb6e434fea9..62e9bdf6cf80 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 000000000000..d2c0b7751430 --- /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 000000000000..301db38c8bde --- /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 1693f79ec649..bdebf9f0255e 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 000000000000..8043e93b9e4f --- /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/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index 9fc40c40c2bc..d694f4b93f88 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 572aba0829fc..9b63ef17a9ca 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["pyvizio"], "quality_scale": "platinum", - "requirements": ["pyvizio==0.1.57"], + "requirements": ["pyvizio==0.1.60"], "zeroconf": ["_viziocast._tcp.local."] } diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index e1ca306ddf7a..a989cea488f8 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -137,7 +137,7 @@ class VizioDevice(MediaPlayerEntity): 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 +330,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/voice_assistant/__init__.py b/homeassistant/components/voice_assistant/__init__.py new file mode 100644 index 000000000000..2ae169a28eb4 --- /dev/null +++ b/homeassistant/components/voice_assistant/__init__.py @@ -0,0 +1,16 @@ +"""The Voice Assistant integration.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN +from .websocket_api import async_register_websocket_api + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Voice Assistant integration.""" + hass.data[DOMAIN] = {} + async_register_websocket_api(hass) + + return True diff --git a/homeassistant/components/voice_assistant/const.py b/homeassistant/components/voice_assistant/const.py new file mode 100644 index 000000000000..86572fb459f2 --- /dev/null +++ b/homeassistant/components/voice_assistant/const.py @@ -0,0 +1,3 @@ +"""Constants for the Voice Assistant integration.""" +DOMAIN = "voice_assistant" +DEFAULT_PIPELINE = "default" diff --git a/homeassistant/components/voice_assistant/manifest.json b/homeassistant/components/voice_assistant/manifest.json new file mode 100644 index 000000000000..644c49e94597 --- /dev/null +++ b/homeassistant/components/voice_assistant/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "voice_assistant", + "name": "Voice Assistant", + "codeowners": ["@balloob", "@synesthesiam"], + "dependencies": ["conversation", "stt", "tts"], + "documentation": "https://www.home-assistant.io/integrations/voice_assistant", + "iot_class": "local_push", + "quality_scale": "internal" +} diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py new file mode 100644 index 000000000000..806a603f5e50 --- /dev/null +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -0,0 +1,433 @@ +"""Classes for voice assistant pipelines.""" +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterable, Callable +from dataclasses import asdict, dataclass, field +import logging +from typing import Any + +from homeassistant.backports.enum import StrEnum +from homeassistant.components import conversation, media_source, stt +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.util.dt import utcnow + +from .const import DOMAIN + +DEFAULT_TIMEOUT = 30 # seconds + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_get_pipeline( + hass: HomeAssistant, pipeline_id: str | None = None, language: str | None = None +) -> Pipeline | None: + """Get a pipeline by id or create one for a language.""" + if pipeline_id is not None: + return hass.data[DOMAIN].get(pipeline_id) + + # Construct a pipeline for the required/configured language + language = language or hass.config.language + return Pipeline( + name=language, + language=language, + stt_engine=None, # first engine + conversation_engine=None, # first agent + tts_engine=None, # first engine + ) + + +class PipelineError(Exception): + """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 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.""" + + +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 +class PipelineEvent: + """Events emitted during a pipeline run.""" + + type: PipelineEventType + data: dict[str, Any] | None = None + timestamp: str = field(default_factory=lambda: utcnow().isoformat()) + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the event.""" + return { + "type": self.type, + "timestamp": self.timestamp, + "data": self.data or {}, + } + + +@dataclass +class Pipeline: + """A voice assistant pipeline.""" + + name: str + language: str | None + stt_engine: str | None + conversation_engine: str | None + tts_engine: str | None + + +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: Callable[[PipelineEvent], None] + language: str = None # type: ignore[assignment] + runner_data: Any | None = None + + def __post_init__(self): + """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) + + def start(self): + """Emit run start event.""" + data = { + "pipeline": self.pipeline.name, + "language": self.language, + } + if self.runner_data is not None: + data["runner_data"] = self.runner_data + + self.event_callback(PipelineEvent(PipelineEventType.RUN_START, data)) + + def end(self): + """Emit run end event.""" + self.event_callback( + PipelineEvent( + PipelineEventType.RUN_END, + ) + ) + + async def speech_to_text( + self, + metadata: stt.SpeechMetadata, + stream: AsyncIterable[bytes], + ) -> str: + """Run speech to text portion of pipeline. Returns the spoken text.""" + engine = self.pipeline.stt_engine or "default" + self.event_callback( + PipelineEvent( + PipelineEventType.STT_START, + { + "engine": engine, + "metadata": asdict(metadata), + }, + ) + ) + + try: + # Load provider + stt_provider: stt.Provider = stt.async_get_provider( + self.hass, self.pipeline.stt_engine + ) + assert stt_provider is not None + except Exception as src_error: + _LOGGER.exception("No speech to text provider for %s", engine) + raise SpeechToTextError( + code="stt-provider-missing", + message=f"No speech to text provider for: {engine}", + ) from src_error + + if not stt_provider.check_metadata(metadata): + raise SpeechToTextError( + code="stt-provider-unsupported-metadata", + message=f"Provider {engine} does not support input speech to text metadata", + ) + + try: + # Transcribe audio stream + result = await 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.event_callback( + PipelineEvent( + PipelineEventType.STT_END, + { + "stt_output": { + "text": result.text, + } + }, + ) + ) + + return result.text + + async def recognize_intent( + self, intent_input: str, conversation_id: str | None + ) -> str: + """Run intent recognition portion of pipeline. Returns text to speak.""" + self.event_callback( + PipelineEvent( + PipelineEventType.INTENT_START, + { + "engine": self.pipeline.conversation_engine or "default", + "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.language, + agent_id=self.pipeline.conversation_engine, + ) + 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.event_callback( + PipelineEvent( + PipelineEventType.INTENT_END, + {"intent_output": conversation_result.as_dict()}, + ) + ) + + speech = conversation_result.response.speech.get("plain", {}).get("speech", "") + + return speech + + async def text_to_speech(self, tts_input: str) -> str: + """Run text to speech portion of pipeline. Returns URL of TTS audio.""" + self.event_callback( + PipelineEvent( + PipelineEventType.TTS_START, + { + "engine": self.pipeline.tts_engine or "default", + "tts_input": tts_input, + }, + ) + ) + + try: + # Synthesize audio and get URL + tts_media = await media_source.async_resolve_media( + self.hass, + tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.pipeline.tts_engine, + ), + ) + 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.event_callback( + PipelineEvent( + PipelineEventType.TTS_END, + {"tts_output": asdict(tts_media)}, + ) + ) + + return tts_media.url + + +@dataclass +class PipelineInput: + """Input to a pipeline run.""" + + 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, run: PipelineRun, timeout: int | float | None = DEFAULT_TIMEOUT + ): + """Run pipeline with optional timeout.""" + await asyncio.wait_for( + self._execute(run), + timeout=timeout, + ) + + async def _execute(self, run: PipelineRun): + self._validate(run.start_stage) + + # stt -> intent -> tts + run.start() + current_stage = 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 run.speech_to_text( + self.stt_metadata, + self.stt_stream, + ) + current_stage = PipelineStage.INTENT + + if run.end_stage != PipelineStage.STT: + tts_input = self.tts_input + + if current_stage == PipelineStage.INTENT: + assert intent_input is not None + tts_input = await run.recognize_intent( + intent_input, self.conversation_id + ) + current_stage = PipelineStage.TTS + + if run.end_stage != PipelineStage.INTENT: + if current_stage == PipelineStage.TTS: + assert tts_input is not None + await run.text_to_speech(tts_input) + + except PipelineError as err: + run.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": err.code, "message": err.message}, + ) + ) + return + + run.end() + + def _validate(self, stage: PipelineStage): + """Validate pipeline input against start stage.""" + if stage == PipelineStage.STT: + 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 stage == PipelineStage.INTENT: + if self.intent_input is None: + raise PipelineRunValidationError( + "intent_input is required for intent recognition" + ) + elif stage == PipelineStage.TTS: + if self.tts_input is None: + raise PipelineRunValidationError( + "tts_input is required for text to speech" + ) diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py new file mode 100644 index 000000000000..28cafb7a3556 --- /dev/null +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -0,0 +1,200 @@ +"""Voice Assistant Websocket API.""" +import asyncio +import audioop +from collections.abc import Callable +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import stt, websocket_api +from homeassistant.core import HomeAssistant, callback + +from .pipeline import ( + DEFAULT_TIMEOUT, + PipelineError, + PipelineEvent, + PipelineEventType, + PipelineInput, + PipelineRun, + PipelineStage, + async_get_pipeline, +) + +_LOGGER = logging.getLogger(__name__) + +_VAD_ENERGY_THRESHOLD = 1000 +_VAD_SPEECH_FRAMES = 25 +_VAD_SILENCE_FRAMES = 25 + + +@callback +def async_register_websocket_api(hass: HomeAssistant) -> None: + """Register the websocket API.""" + websocket_api.async_register_command(hass, websocket_run) + + +def _get_debiased_energy(audio_data: bytes, width: int = 2) -> float: + """Compute RMS of debiased audio.""" + energy = -audioop.rms(audio_data, width) + energy_bytes = bytes([energy & 0xFF, (energy >> 8) & 0xFF]) + debiased_energy = audioop.rms( + audioop.add(audio_data, energy_bytes * (len(audio_data) // width), width), width + ) + + return debiased_energy + + +@websocket_api.websocket_command( + { + vol.Required("type"): "voice_assistant/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"): {"text": str}, + vol.Optional("language"): str, + vol.Optional("pipeline"): str, + vol.Optional("conversation_id"): vol.Any(str, None), + vol.Optional("timeout"): vol.Any(float, int), + } +) +@websocket_api.async_response +async def websocket_run( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Run a pipeline.""" + language = msg.get("language", hass.config.language) + + # Temporary workaround for language codes + if language == "en": + language = "en-US" + + pipeline_id = msg.get("pipeline") + pipeline = async_get_pipeline( + hass, + pipeline_id=pipeline_id, + language=language, + ) + if pipeline is None: + connection.send_error( + msg["id"], + "pipeline-not-found", + f"Pipeline not found: id={pipeline_id}, language={language}", + ) + 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() + + async def stt_stream(): + state = None + speech_count = 0 + in_voice_command = False + + # Yield until we receive an empty chunk + while chunk := await audio_queue.get(): + chunk, state = audioop.ratecv(chunk, 2, 1, 44100, 16000, state) + is_speech = _get_debiased_energy(chunk) > _VAD_ENERGY_THRESHOLD + + if in_voice_command: + if is_speech: + speech_count += 1 + else: + speech_count -= 1 + + if speech_count <= -_VAD_SILENCE_FRAMES: + _LOGGER.info("Voice command stopped") + break + else: + if is_speech: + speech_count += 1 + + if speech_count >= _VAD_SPEECH_FRAMES: + in_voice_command = True + _LOGGER.info("Voice command started") + + yield chunk + + def handle_binary(_hass, _connection, data: bytes): + # 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=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"] + + run_task = hass.async_create_task( + PipelineInput(**input_args).execute( + 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.as_dict() + ), + runner_data={ + "stt_binary_handler_id": handler_id, + }, + ), + timeout=timeout, + ) + ) + + # Cancel pipeline if user unsubscribes + connection.subscriptions[msg["id"]] = run_task.cancel + + # Confirm subscription + connection.send_result(msg["id"]) + + try: + # Task contains a timeout + await run_task + except PipelineError as error: + # Report more specific error when possible + connection.send_error(msg["id"], error.code, error.message) + except asyncio.TimeoutError: + connection.send_event( + msg["id"], + PipelineEvent( + PipelineEventType.ERROR, + {"code": "timeout", "message": "Timeout running pipeline"}, + ), + ) + finally: + if unregister_handler is not None: + # Unregister binary handler + unregister_handler() diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index b6d97dea216e..ab4fa781110f 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 c1b3ab3f66bd..d56d10ded5a8 100644 --- a/homeassistant/components/volvooncall/config_flow.py +++ b/homeassistant/components/volvooncall/config_flow.py @@ -106,10 +106,6 @@ class VolvoOnCallConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=user_schema, errors=errors ) - async def async_step_import(self, import_data) -> FlowResult: - """Import volvooncall config from configuration.yaml.""" - return await self.async_step_user(import_data) - async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_entry = self.hass.config_entries.async_get_entry( diff --git a/homeassistant/components/volvooncall/strings.json b/homeassistant/components/volvooncall/strings.json index 9e8471b04b1d..44b821b4b017 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 6bc7e1ca635a..8ae75527abcd 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 3d9ab67eab4c..6344b5a847a2 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/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index b26732e4cb1f..b885da3f37be 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -31,6 +31,19 @@ from .const import ( ) from .helpers import is_valid_config_entry +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_INCL_FILTER, default=""): cv.string, + vol.Optional(CONF_EXCL_FILTER, default=""): cv.string, + vol.Optional(CONF_REALTIME): cv.boolean, + vol.Optional(CONF_VEHICLE_TYPE): vol.In(VEHICLE_TYPES), + vol.Optional(CONF_UNITS): vol.In(UNITS), + vol.Optional(CONF_AVOID_TOLL_ROADS): cv.boolean, + vol.Optional(CONF_AVOID_SUBSCRIPTION_ROADS): cv.boolean, + vol.Optional(CONF_AVOID_FERRIES): cv.boolean, + } +) + def default_options(hass: HomeAssistant) -> dict[str, str | bool]: """Get the default options.""" @@ -57,43 +70,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 ), ) diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index f69f9a019fc7..cf709805f6d1 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/weather/__init__.py b/homeassistant/components/weather/__init__.py index 52642c4f1bf9..0a99b6aaaf7e 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 000000000000..2dcfd8a2ddc1 --- /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 c4764beb5b6e..a64f84672242 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 000000000000..51f129fc4a2c --- /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/webostv/trigger.py b/homeassistant/components/webostv/trigger.py index 5441917cc313..4d237993f959 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 710caffef7a8..d8c5a28ef3f8 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 fa5c6aac2944..2b146d944724 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -399,9 +399,9 @@ async def handle_manifest_get( connection.send_error(msg["id"], const.ERR_NOT_FOUND, "Integration not found") +@callback @decorators.websocket_command({vol.Required("type"): "integration/setup_info"}) -@decorators.async_response -async def handle_integration_setup_info( +def handle_integration_setup_info( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle integrations command.""" @@ -648,6 +648,7 @@ async def handle_execute_script( connection.send_result(msg["id"], {"context": context}) +@callback @decorators.websocket_command( { vol.Required("type"): "fire_event", @@ -656,8 +657,7 @@ async def handle_execute_script( } ) @decorators.require_admin -@decorators.async_response -async def handle_fire_event( +def handle_fire_event( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle fire event command.""" diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 90c7e9906e4e..f91cc3a827af 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -25,6 +25,9 @@ current_connection = ContextVar["ActiveConnection | None"]( "current_connection", default=None ) +MessageHandler = Callable[[HomeAssistant, "ActiveConnection", dict[str, Any]], None] +BinaryHandler = Callable[[HomeAssistant, "ActiveConnection", bytes], None] + class ActiveConnection: """Handle an active websocket client connection.""" @@ -46,7 +49,10 @@ class ActiveConnection: self.subscriptions: dict[Hashable, Callable[[], Any]] = {} self.last_id = 0 self.supported_features: dict[str, float] = {} - self.handlers = self.hass.data[const.DOMAIN] + self.handlers: dict[str, tuple[MessageHandler, vol.Schema]] = self.hass.data[ + const.DOMAIN + ] + self.binary_handlers: list[BinaryHandler | None] = [] current_connection.set(self) def get_description(self, request: web.Request | None) -> str: @@ -60,16 +66,73 @@ 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_binary(self, handler_id: int, payload: bytes) -> None: + """Handle a single incoming binary message.""" + index = handler_id - 1 + if ( + index < 0 + or index >= len(self.binary_handlers) + or (handler := self.binary_handlers[index]) is None + ): + self.logger.error( + "Received binary message for non-existing handler %s", handler_id + ) + return + + try: + handler(self.hass, self, payload) + except Exception: # pylint: disable=broad-except + self.logger.exception("Error handling binary message") + self.binary_handlers[index] = None + @callback def async_handle(self, msg: dict[str, Any]) -> None: """Handle a single incoming message.""" diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index d92e52dbf843..75eccc7aba99 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -268,6 +268,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 +312,15 @@ class WebSocketHandler: if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): break + if msg.type == WSMsgType.BINARY: + if len(msg.data) < 1: + disconnect_warn = "Received invalid binary message." + break + handler = msg.data[0] + payload = msg.data[1:] + connection.async_handle_binary(handler, payload) + continue + if msg.type != WSMsgType.TEXT: disconnect_warn = "Received non-Text message." break diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 0765c6a5b7c6..ec1ab267a37b 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -132,7 +132,6 @@ def _state_diff_event(event: Event) -> dict: return _state_diff(event_old_state, event_new_state) -@lru_cache(maxsize=128) def _state_diff( old_state: State, new_state: State ) -> dict[str, dict[str, dict[str, dict[str, str | list[str]]]]]: diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index d44c3aaefb78..a802535441a0 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/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 5afb5a6b44ee..9ba3fd2cb3d0 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 99309b9f0080..b6d205912c6d 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 924414cadf3f..668b90159b54 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/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index e66efa039a12..a2e7f1e589f3 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,28 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, BinarySensorEntity, ) -from homeassistant.const import CONF_NAME, WEEKDAYS +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.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, + LOGGER, +) def valid_country(value: Any) -> str: @@ -96,52 +92,44 @@ def setup_platform( sensor_name: str = config[CONF_NAME] workdays: list[str] = config[CONF_WORKDAYS] - year: int = (get_date(dt.now()) + timedelta(days=days_offset)).year + 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)], @@ -149,19 +137,6 @@ def setup_platform( ) -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.""" @@ -209,12 +184,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/const.py b/homeassistant/components/workday/const.py new file mode 100644 index 000000000000..810e1de3934f --- /dev/null +++ b/homeassistant/components/workday/const.py @@ -0,0 +1,25 @@ +"""Add constants for Workday integration.""" +from __future__ import annotations + +import logging + +from homeassistant.const import WEEKDAYS + +LOGGER = logging.getLogger(__package__) + +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 diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 442456066c4a..c9299b21ce14 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -1,7 +1,7 @@ { "domain": "workday", "name": "Workday", - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@gjohansson-ST"], "documentation": "https://www.home-assistant.io/integrations/workday", "iot_class": "local_polling", "loggers": [ @@ -11,5 +11,5 @@ "korean_lunar_calendar" ], "quality_scale": "internal", - "requirements": ["holidays==0.18.0"] + "requirements": ["holidays==0.21.13"] } diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py index 1d56cfc71c57..ab16afa9280a 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/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py index 8b7abcd2fe63..e9d686a63653 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 b8cf5f005c46..f277060304aa 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_miio/device_tracker.py b/homeassistant/components/xiaomi_miio/device_tracker.py index e4bebdd0e629..977dc29ac423 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 50e0cd8c72d9..6fde33309e4e 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 bbf2764ceb6f..249774519d08 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/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 6bb58752a00f..f1ec6ba14c46 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.1"] + "requirements": ["yalexs-ble==2.1.14"] } diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index 0f1f138fd6cf..c2d1a2155c3a 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -22,7 +22,7 @@ } }, "error": { - "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and again.", + "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and try again.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index aeb38c0faac7..e2658c21f372 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/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index 01e5e1b8986b..05518a6c3c93 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/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 7362a09609a8..c10cc8158eae 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 61cbc8b3028f..935889a0368e 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 000000000000..aac860c6a27b --- /dev/null +++ b/homeassistant/components/yolink/device_trigger.py @@ -0,0 +1,88 @@ +"""Provides device triggers for YoLink.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +from yolink.const import ATTR_DEVICE_SMART_REMOTER + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN, YOLINK_EVENT + +CONF_BUTTON_1 = "button_1" +CONF_BUTTON_2 = "button_2" +CONF_BUTTON_3 = "button_3" +CONF_BUTTON_4 = "button_4" +CONF_SHORT_PRESS = "short_press" +CONF_LONG_PRESS = "long_press" + +REMOTE_TRIGGER_TYPES = { + f"{CONF_BUTTON_1}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_1}_{CONF_LONG_PRESS}", + f"{CONF_BUTTON_2}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_2}_{CONF_LONG_PRESS}", + f"{CONF_BUTTON_3}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_3}_{CONF_LONG_PRESS}", + f"{CONF_BUTTON_4}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_4}_{CONF_LONG_PRESS}", +} + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + {vol.Required(CONF_TYPE): vol.In(REMOTE_TRIGGER_TYPES)} +) + + +# YoLink Remotes YS3604/YS3605/YS3606/YS3607 +DEVICE_TRIGGER_TYPES: dict[str, set[str]] = { + ATTR_DEVICE_SMART_REMOTER: REMOTE_TRIGGER_TYPES, +} + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """List device triggers for YoLink devices.""" + device_registry = dr.async_get(hass) + registry_device = device_registry.async_get(device_id) + if not registry_device or registry_device.model != ATTR_DEVICE_SMART_REMOTER: + return [] + + triggers = [] + for trigger in DEVICE_TRIGGER_TYPES[ATTR_DEVICE_SMART_REMOTER]: + triggers.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_PLATFORM: "device", + CONF_TYPE: trigger, + } + ) + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + event_config = { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: YOLINK_EVENT, + event_trigger.CONF_EVENT_DATA: { + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + CONF_TYPE: config[CONF_TYPE], + }, + } + event_config = event_trigger.TRIGGER_SCHEMA(event_config) + return await event_trigger.async_attach_trigger( + hass, event_config, action, trigger_info, platform_type="device" + ) diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 4850df4a26de..5f89f54ccbe7 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 94fe5dc09aa5..de16e1a6e392 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 02cca76c4aa1..7c0ea36a060a 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 b9120f433dec..057533081e65 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -10,7 +10,14 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE, UnitOfEnergy, UnitOfPower, UnitOfVolume +from homeassistant.const import ( + CONF_DEVICE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfVolume, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -47,6 +54,15 @@ async def async_setup_entry( DeliveryMeterSensor(coordinator, device, "high"), ExtraMeterSensor(coordinator, device, "total"), ExtraMeterPowerSensor(coordinator, device, "usage"), + PhasePowerSensor(coordinator, device, 1), + PhaseVoltageSensor(coordinator, device, 1), + PhaseCurrentSensor(coordinator, device, 1), + PhasePowerSensor(coordinator, device, 2), + PhaseVoltageSensor(coordinator, device, 2), + PhaseCurrentSensor(coordinator, device, 2), + PhasePowerSensor(coordinator, device, 3), + PhaseVoltageSensor(coordinator, device, 3), + PhaseCurrentSensor(coordinator, device, 3), ] ) @@ -193,6 +209,87 @@ class EnergyMeterSensor(YoulessBaseSensor): return getattr(self.coordinator.data.power_meter, f"_{self._type}", None) +class PhasePowerSensor(YoulessBaseSensor): + """The current power usage of a single phase.""" + + _attr_native_unit_of_measurement = UnitOfPower.WATT + _attr_device_class = SensorDeviceClass.POWER + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int + ) -> None: + """Initialize the power phase sensor.""" + super().__init__( + coordinator, device, "power", "Energy usage", f"phase_{phase}_power" + ) + self._attr_name = f"Phase {phase} power" + self._phase = phase + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor value from the coordinator.""" + phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) + if phase_sensor is None: + return None + + return phase_sensor.power + + +class PhaseVoltageSensor(YoulessBaseSensor): + """The current voltage of a single phase.""" + + _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT + _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int + ) -> None: + """Initialize the voltage phase sensor.""" + super().__init__( + coordinator, device, "power", "Energy usage", f"phase_{phase}_voltage" + ) + self._attr_name = f"Phase {phase} voltage" + self._phase = phase + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor value from the coordinator for phase voltage.""" + phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) + if phase_sensor is None: + return None + + return phase_sensor.voltage + + +class PhaseCurrentSensor(YoulessBaseSensor): + """The current current of a single phase.""" + + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + _attr_device_class = SensorDeviceClass.CURRENT + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int + ) -> None: + """Initialize the current phase sensor.""" + super().__init__( + coordinator, device, "power", "Energy usage", f"phase_{phase}_current" + ) + self._attr_name = f"Phase {phase} current" + self._phase = phase + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor value from the coordinator for phase current.""" + phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) + if phase_sensor is None: + return None + + return phase_sensor.current + + class ExtraMeterSensor(YoulessBaseSensor): """The Youless extra meter value sensor (s0).""" diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index badc1242714b..28c2b799f4c7 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -564,14 +564,20 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: if isinstance(value, bytes): properties[key] = value.decode("utf-8") - if not (addresses := service.addresses or service.parsed_addresses()): + if not (ip_addresses := service.ip_addresses_by_version(IPVersion.All)): return None - if (host := _first_non_link_local_address(addresses)) is None: + host: str | None = None + for ip_addr in ip_addresses: + if not ip_addr.is_link_local and not ip_addr.is_unspecified: + host = str(ip_addr) + break + if not host: return None + assert service.server is not None, "server cannot be none if there are addresses" return ZeroconfServiceInfo( - host=str(host), - addresses=service.parsed_addresses(), + host=host, + addresses=[str(ip_addr) for ip_addr in ip_addresses], port=service.port, hostname=service.server, type=service.type, @@ -580,30 +586,6 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: ) -def _first_non_link_local_address( - addresses: list[bytes] | list[str], -) -> str | None: - """Return the first ipv6 or non-link local ipv4 address, preferring IPv4.""" - for address in addresses: - ip_addr = ip_address(address) - if ( - not ip_addr.is_link_local - and not ip_addr.is_unspecified - and ip_addr.version == 4 - ): - return str(ip_addr) - # If we didn't find a good IPv4 address, check for IPv6 addresses. - for address in addresses: - ip_addr = ip_address(address) - if ( - not ip_addr.is_link_local - and not ip_addr.is_unspecified - and ip_addr.version == 6 - ): - return str(ip_addr) - return None - - def _suppress_invalid_properties(properties: dict) -> None: """Suppress any properties that will cause zeroconf to fail to startup.""" diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 02b5982e5869..de2fe44b630c 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.54.0"] } diff --git a/homeassistant/components/zeroconf/usage.py b/homeassistant/components/zeroconf/usage.py index 0c452149bfd0..b9d51cd3c367 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/zha/__init__.py b/homeassistant/components/zha/__init__.py index dd07d4da4280..5607cabffea1 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -17,7 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType -from . import api +from . import websocket_api from .core import ZHAGateway from .core.const import ( BAUD_RATES, @@ -131,7 +131,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b model=zha_gateway.radio_description, ) - api.async_load_api(hass) + websocket_api.async_load_api(hass) async def async_zha_shutdown(event): """Handle shutdown tasks.""" @@ -150,11 +150,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload ZHA config entry.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA].pop(DATA_ZHA_GATEWAY) await zha_gateway.shutdown() GROUP_PROBE.cleanup() - api.async_unload_api(hass) + websocket_api.async_unload_api(hass) # our components don't have unload methods so no need to look at return values await asyncio.gather( diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index d0e04e0c1628..652f19d24bac 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -1,1549 +1,113 @@ -"""Web socket API for Zigbee Home Automation devices.""" +"""API for Zigbee Home Automation.""" + from __future__ import annotations -import asyncio -import logging -from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast +from typing import TYPE_CHECKING -import voluptuous as vol -import zigpy.backups from zigpy.backups import NetworkBackup -from zigpy.config.validators import cv_boolean -from zigpy.types.named import EUI64 -from zigpy.zcl.clusters.security import IasAce -import zigpy.zdo.types as zdo_types - -from homeassistant.components import websocket_api -from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME -from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.service import async_register_admin_service +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from .core.const import ( - ATTR_ARGS, - ATTR_ATTRIBUTE, - ATTR_CLUSTER_ID, - ATTR_CLUSTER_TYPE, - ATTR_COMMAND_TYPE, - ATTR_ENDPOINT_ID, - ATTR_IEEE, - ATTR_LEVEL, - ATTR_MANUFACTURER, - ATTR_MEMBERS, - ATTR_PARAMS, - ATTR_TYPE, - ATTR_VALUE, - ATTR_WARNING_DEVICE_DURATION, - ATTR_WARNING_DEVICE_MODE, - ATTR_WARNING_DEVICE_STROBE, - ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, - ATTR_WARNING_DEVICE_STROBE_INTENSITY, - BINDINGS, - CHANNEL_IAS_WD, - CLUSTER_COMMAND_SERVER, - CLUSTER_COMMANDS_CLIENT, - CLUSTER_COMMANDS_SERVER, - CLUSTER_TYPE_IN, - CLUSTER_TYPE_OUT, CONF_RADIO_TYPE, - CUSTOM_CONFIGURATION, DATA_ZHA, + DATA_ZHA_CONFIG, DATA_ZHA_GATEWAY, DOMAIN, - EZSP_OVERWRITE_EUI64, - GROUP_ID, - GROUP_IDS, - GROUP_NAME, - MFG_CLUSTER_ID_START, - WARNING_DEVICE_MODE_EMERGENCY, - WARNING_DEVICE_SOUND_HIGH, - WARNING_DEVICE_SQUAWK_MODE_ARMED, - WARNING_DEVICE_STROBE_HIGH, - WARNING_DEVICE_STROBE_YES, - ZHA_ALARM_OPTIONS, - ZHA_CHANNEL_MSG, - ZHA_CONFIG_SCHEMAS, -) -from .core.gateway import EntityReference -from .core.group import GroupMember -from .core.helpers import ( - async_cluster_exists, - async_is_bindable_target, - cluster_command_schema_to_vol_schema, - convert_install_code, - get_matched_clusters, - qr_to_install_code, + RadioType, ) +from .core.gateway import ZHAGateway if TYPE_CHECKING: - from homeassistant.components.websocket_api.connection import ActiveConnection - - from .core.device import ZHADevice - from .core.gateway import ZHAGateway - -_LOGGER = logging.getLogger(__name__) - -TYPE = "type" -CLIENT = "client" -ID = "id" -RESPONSE = "response" -DEVICE_INFO = "device_info" - -ATTR_DURATION = "duration" -ATTR_GROUP = "group" -ATTR_IEEE_ADDRESS = "ieee_address" -ATTR_INSTALL_CODE = "install_code" -ATTR_SOURCE_IEEE = "source_ieee" -ATTR_TARGET_IEEE = "target_ieee" -ATTR_QR_CODE = "qr_code" - -SERVICE_PERMIT = "permit" -SERVICE_REMOVE = "remove" -SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = "set_zigbee_cluster_attribute" -SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = "issue_zigbee_cluster_command" -SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND = "issue_zigbee_group_command" -SERVICE_DIRECT_ZIGBEE_BIND = "issue_direct_zigbee_bind" -SERVICE_DIRECT_ZIGBEE_UNBIND = "issue_direct_zigbee_unbind" -SERVICE_WARNING_DEVICE_SQUAWK = "warning_device_squawk" -SERVICE_WARNING_DEVICE_WARN = "warning_device_warn" -SERVICE_ZIGBEE_BIND = "service_zigbee_bind" -IEEE_SERVICE = "ieee_based_service" - -IEEE_SCHEMA = vol.All(cv.string, EUI64.convert) - -# typing typevar -_T = TypeVar("_T") + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant -def _ensure_list_if_present(value: _T | None) -> list[_T] | list[Any] | None: - """Wrap value in list if it is provided and not one.""" - if value is None: - return None - return cast("list[_T]", value) if isinstance(value, list) else [value] +def _get_gateway(hass: HomeAssistant) -> ZHAGateway: + """Get a reference to the ZHA gateway device.""" + return hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] -SERVICE_PERMIT_PARAMS = { - vol.Optional(ATTR_IEEE): IEEE_SCHEMA, - vol.Optional(ATTR_DURATION, default=60): vol.All( - vol.Coerce(int), vol.Range(0, 254) - ), - vol.Inclusive(ATTR_SOURCE_IEEE, "install_code"): IEEE_SCHEMA, - vol.Inclusive(ATTR_INSTALL_CODE, "install_code"): vol.All( - cv.string, convert_install_code - ), - vol.Exclusive(ATTR_QR_CODE, "install_code"): vol.All(cv.string, qr_to_install_code), -} +def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Find the singleton ZHA config entry, if one exists.""" -SERVICE_SCHEMAS = { - SERVICE_PERMIT: vol.Schema( - vol.All( - cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), - SERVICE_PERMIT_PARAMS, - ) - ), - IEEE_SERVICE: vol.Schema( - vol.All( - cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), - {vol.Required(ATTR_IEEE): IEEE_SCHEMA}, - ) - ), - SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - vol.Required(ATTR_ATTRIBUTE): vol.Any(cv.positive_int, str), - vol.Required(ATTR_VALUE): vol.Any(int, cv.boolean, cv.string), - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } - ), - SERVICE_WARNING_DEVICE_SQUAWK: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Optional( - ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_SQUAWK_MODE_ARMED - ): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES - ): cv.positive_int, - vol.Optional( - ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH - ): cv.positive_int, - } - ), - SERVICE_WARNING_DEVICE_WARN: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Optional( - ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_MODE_EMERGENCY - ): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES - ): cv.positive_int, - vol.Optional( - ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH - ): cv.positive_int, - vol.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 - ): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE_INTENSITY, default=WARNING_DEVICE_STROBE_HIGH - ): cv.positive_int, - } - ), - SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.All( - vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - vol.Required(ATTR_COMMAND): cv.positive_int, - vol.Required(ATTR_COMMAND_TYPE): cv.string, - vol.Exclusive(ATTR_ARGS, "attrs_params"): _ensure_list_if_present, - vol.Exclusive(ATTR_PARAMS, "attrs_params"): dict, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } - ), - cv.deprecated(ATTR_ARGS), - cv.has_at_least_one_key(ATTR_ARGS, ATTR_PARAMS), - ), - SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND: vol.Schema( - { - vol.Required(ATTR_GROUP): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - vol.Required(ATTR_COMMAND): cv.positive_int, - vol.Optional(ATTR_ARGS, default=[]): cv.ensure_list, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } - ), -} + # If ZHA is already running, use its config entry + try: + zha_gateway = _get_gateway(hass) + except KeyError: + pass + else: + return zha_gateway.config_entry + + # Otherwise, find one + entries = hass.config_entries.async_entries(DOMAIN) + + if len(entries) != 1: + raise ValueError(f"Invalid number of ZHA config entries: {entries!r}") + + return entries[0] -class ClusterBinding(NamedTuple): - """Describes a cluster binding.""" +def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: + """Get the network settings for the currently active ZHA network.""" + zha_gateway: ZHAGateway = _get_gateway(hass) + app = zha_gateway.application_controller - name: str - type: str - id: int - endpoint_id: int - - -def _cv_group_member(value: dict[str, Any]) -> GroupMember: - """Transform a group member.""" - return GroupMember( - ieee=value[ATTR_IEEE], - endpoint_id=value[ATTR_ENDPOINT_ID], + return NetworkBackup( + node_info=app.state.node_info, + network_info=app.state.network_info, ) -def _cv_cluster_binding(value: dict[str, Any]) -> ClusterBinding: - """Transform a cluster binding.""" - return ClusterBinding( - name=value[ATTR_NAME], - type=value[ATTR_TYPE], - id=value[ATTR_ID], - endpoint_id=value[ATTR_ENDPOINT_ID], - ) +async def async_get_last_network_settings( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> NetworkBackup | None: + """Get the network settings for the last-active ZHA network.""" + if config_entry is None: + config_entry = _get_config_entry(hass) + config = hass.data.get(DATA_ZHA, {}).get(DATA_ZHA_CONFIG, {}) + zha_gateway = ZHAGateway(hass, config, config_entry) -def _cv_zigpy_network_backup(value: dict[str, Any]) -> zigpy.backups.NetworkBackup: - """Transform a zigpy network backup.""" + app_controller_cls, app_config = zha_gateway.get_application_controller_data() + app = app_controller_cls(app_config) try: - return zigpy.backups.NetworkBackup.from_dict(value) - except ValueError as err: - raise vol.Invalid(str(err)) from err + await app._load_db() # pylint: disable=protected-access + settings = max(app.backups, key=lambda b: b.backup_time) + except ValueError: + settings = None + finally: + await app.shutdown() + return settings -GROUP_MEMBER_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), - } - ), - _cv_group_member, -) +async def async_get_network_settings( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> NetworkBackup | None: + """Get ZHA network settings, preferring the active settings if ZHA is running.""" -CLUSTER_BINDING_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(ATTR_NAME): cv.string, - vol.Required(ATTR_TYPE): cv.string, - vol.Required(ATTR_ID): vol.Coerce(int), - vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), - } - ), - _cv_cluster_binding, -) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required("type"): "zha/devices/permit", - **SERVICE_PERMIT_PARAMS, - } -) -@websocket_api.async_response -async def websocket_permit_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Permit ZHA zigbee devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - duration: int = msg[ATTR_DURATION] - ieee: EUI64 | None = msg.get(ATTR_IEEE) - - async def forward_messages(data): - """Forward events to websocket.""" - connection.send_message(websocket_api.event_message(msg["id"], data)) - - remove_dispatcher_function = async_dispatcher_connect( - hass, "zha_gateway_message", forward_messages - ) - - @callback - def async_cleanup() -> None: - """Remove signal listener and turn off debug mode.""" - zha_gateway.async_disable_debug_mode() - remove_dispatcher_function() - - connection.subscriptions[msg["id"]] = async_cleanup - zha_gateway.async_enable_debug_mode() - src_ieee: EUI64 - code: bytes - if ATTR_SOURCE_IEEE in msg: - src_ieee = msg[ATTR_SOURCE_IEEE] - code = msg[ATTR_INSTALL_CODE] - _LOGGER.debug("Allowing join for %s device with install code", src_ieee) - await zha_gateway.application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - elif ATTR_QR_CODE in msg: - src_ieee, code = msg[ATTR_QR_CODE] - _LOGGER.debug("Allowing join for %s device with install code", src_ieee) - await zha_gateway.application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - else: - await zha_gateway.application_controller.permit(time_s=duration, node=ieee) - connection.send_result(msg[ID]) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices"}) -@websocket_api.async_response -async def websocket_get_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - devices = [device.zha_device_info for device in zha_gateway.devices.values()] - connection.send_result(msg[ID], devices) - - -@callback -def _get_entity_name( - zha_gateway: ZHAGateway, entity_ref: EntityReference -) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) - return entry.name if entry else None - - -@callback -def _get_entity_original_name( - zha_gateway: ZHAGateway, entity_ref: EntityReference -) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) - return entry.original_name if entry else None - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices/groupable"}) -@websocket_api.async_response -async def websocket_get_groupable_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA devices that can be grouped.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - - devices = [device for device in zha_gateway.devices.values() if device.is_groupable] - groupable_devices = [] - - for device in devices: - entity_refs = zha_gateway.device_registry[device.ieee] - for ep_id in device.async_get_groupable_endpoints(): - groupable_devices.append( - { - "endpoint_id": ep_id, - "entities": [ - { - "name": _get_entity_name(zha_gateway, entity_ref), - "original_name": _get_entity_original_name( - zha_gateway, entity_ref - ), - } - for entity_ref in entity_refs - if list(entity_ref.cluster_channels.values())[ - 0 - ].cluster.endpoint.endpoint_id - == ep_id - ], - "device": device.zha_device_info, - } - ) - - connection.send_result(msg[ID], groupable_devices) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/groups"}) -@websocket_api.async_response -async def websocket_get_groups( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - groups = [group.group_info for group in zha_gateway.groups.values()] - connection.send_result(msg[ID], groups) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/device", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_get_device( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - - if not (zha_device := zha_gateway.devices.get(ieee)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Device not found" - ) - ) - return - - device_info = zha_device.zha_device_info - connection.send_result(msg[ID], device_info) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group", - vol.Required(GROUP_ID): cv.positive_int, - } -) -@websocket_api.async_response -async def websocket_get_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id: int = msg[GROUP_ID] - - if not (zha_group := zha_gateway.groups.get(group_id)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" - ) - ) - return - - group_info = zha_group.group_info - connection.send_result(msg[ID], group_info) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/add", - vol.Required(GROUP_NAME): cv.string, - vol.Optional(GROUP_ID): cv.positive_int, - vol.Optional(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_add_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Add a new ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_name: str = msg[GROUP_NAME] - group_id: int | None = msg.get(GROUP_ID) - members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) - group = await zha_gateway.async_create_zigpy_group(group_name, members, group_id) - assert group - connection.send_result(msg[ID], group.group_info) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/remove", - vol.Required(GROUP_IDS): vol.All(cv.ensure_list, [cv.positive_int]), - } -) -@websocket_api.async_response -async def websocket_remove_groups( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Remove the specified ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_ids: list[int] = msg[GROUP_IDS] - - if len(group_ids) > 1: - tasks = [] - for group_id in group_ids: - tasks.append(zha_gateway.async_remove_zigpy_group(group_id)) - await asyncio.gather(*tasks) - else: - await zha_gateway.async_remove_zigpy_group(group_ids[0]) - ret_groups = [group.group_info for group in zha_gateway.groups.values()] - connection.send_result(msg[ID], ret_groups) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/members/add", - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_add_group_members( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Add members to a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id: int = msg[GROUP_ID] - members: list[GroupMember] = msg[ATTR_MEMBERS] - - if not (zha_group := zha_gateway.groups.get(group_id)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" - ) - ) - return - - await zha_group.async_add_members(members) - ret_group = zha_group.group_info - connection.send_result(msg[ID], ret_group) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/members/remove", - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_remove_group_members( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Remove members from a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id: int = msg[GROUP_ID] - members: list[GroupMember] = msg[ATTR_MEMBERS] - - if not (zha_group := zha_gateway.groups.get(group_id)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" - ) - ) - return - - await zha_group.async_remove_members(members) - ret_group = zha_group.group_info - connection.send_result(msg[ID], ret_group) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/reconfigure", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_reconfigure_node( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Reconfigure a ZHA nodes entities by its ieee address.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - device: ZHADevice | None = zha_gateway.get_device(ieee) - - async def forward_messages(data): - """Forward events to websocket.""" - connection.send_message(websocket_api.event_message(msg["id"], data)) - - remove_dispatcher_function = async_dispatcher_connect( - hass, ZHA_CHANNEL_MSG, forward_messages - ) - - @callback - def async_cleanup() -> None: - """Remove signal listener.""" - remove_dispatcher_function() - - connection.subscriptions[msg["id"]] = async_cleanup - - _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) - assert device - hass.async_create_task(device.async_configure()) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/topology/update", - } -) -@websocket_api.async_response -async def websocket_update_topology( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Update the ZHA network topology.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - hass.async_create_task(zha_gateway.application_controller.topology.scan()) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_device_clusters( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a list of device clusters.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - zha_device = zha_gateway.get_device(ieee) - response_clusters = [] - if zha_device is not None: - clusters_by_endpoint = zha_device.async_get_clusters() - for ep_id, clusters in clusters_by_endpoint.items(): - for c_id, cluster in clusters[CLUSTER_TYPE_IN].items(): - response_clusters.append( - { - TYPE: CLUSTER_TYPE_IN, - ID: c_id, - ATTR_NAME: cluster.__class__.__name__, - "endpoint_id": ep_id, - } - ) - for c_id, cluster in clusters[CLUSTER_TYPE_OUT].items(): - response_clusters.append( - { - TYPE: CLUSTER_TYPE_OUT, - ID: c_id, - ATTR_NAME: cluster.__class__.__name__, - "endpoint_id": ep_id, - } - ) - - connection.send_result(msg[ID], response_clusters) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters/attributes", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - } -) -@websocket_api.async_response -async def websocket_device_cluster_attributes( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a list of cluster attributes.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - endpoint_id: int = msg[ATTR_ENDPOINT_ID] - cluster_id: int = msg[ATTR_CLUSTER_ID] - cluster_type: str = msg[ATTR_CLUSTER_TYPE] - cluster_attributes: list[dict[str, Any]] = [] - zha_device = zha_gateway.get_device(ieee) - attributes = None - if zha_device is not None: - attributes = zha_device.async_get_cluster_attributes( - endpoint_id, cluster_id, cluster_type - ) - if attributes is not None: - for attr_id, attr in attributes.items(): - cluster_attributes.append({ID: attr_id, ATTR_NAME: attr.name}) - _LOGGER.debug( - "Requested attributes for: %s: %s, %s: '%s', %s: %s, %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - RESPONSE, - cluster_attributes, - ) - - connection.send_result(msg[ID], cluster_attributes) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters/commands", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - } -) -@websocket_api.async_response -async def websocket_device_cluster_commands( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a list of cluster commands.""" - import voluptuous_serialize # pylint: disable=import-outside-toplevel - - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - endpoint_id: int = msg[ATTR_ENDPOINT_ID] - cluster_id: int = msg[ATTR_CLUSTER_ID] - cluster_type: str = msg[ATTR_CLUSTER_TYPE] - zha_device = zha_gateway.get_device(ieee) - cluster_commands: list[dict[str, Any]] = [] - commands = None - if zha_device is not None: - commands = zha_device.async_get_cluster_commands( - endpoint_id, cluster_id, cluster_type - ) - - if commands is not None: - for cmd_id, cmd in commands[CLUSTER_COMMANDS_CLIENT].items(): - cluster_commands.append( - { - TYPE: CLIENT, - ID: cmd_id, - ATTR_NAME: cmd.name, - "schema": voluptuous_serialize.convert( - cluster_command_schema_to_vol_schema(cmd.schema), - custom_serializer=cv.custom_serializer, - ), - } - ) - for cmd_id, cmd in commands[CLUSTER_COMMANDS_SERVER].items(): - cluster_commands.append( - { - TYPE: CLUSTER_COMMAND_SERVER, - ID: cmd_id, - ATTR_NAME: cmd.name, - "schema": voluptuous_serialize.convert( - cluster_command_schema_to_vol_schema(cmd.schema), - custom_serializer=cv.custom_serializer, - ), - } - ) - _LOGGER.debug( - "Requested commands for: %s: %s, %s: '%s', %s: %s, %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - RESPONSE, - cluster_commands, - ) - - connection.send_result(msg[ID], cluster_commands) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters/attributes/value", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - vol.Required(ATTR_ATTRIBUTE): int, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } -) -@websocket_api.async_response -async def websocket_read_zigbee_cluster_attributes( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Read zigbee attribute for cluster on ZHA entity.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - endpoint_id: int = msg[ATTR_ENDPOINT_ID] - cluster_id: int = msg[ATTR_CLUSTER_ID] - cluster_type: str = msg[ATTR_CLUSTER_TYPE] - attribute: int = msg[ATTR_ATTRIBUTE] - manufacturer: int | None = msg.get(ATTR_MANUFACTURER) - zha_device = zha_gateway.get_device(ieee) - success = {} - failure = {} - if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code - cluster = zha_device.async_get_cluster( - endpoint_id, cluster_id, cluster_type=cluster_type - ) - success, failure = await cluster.read_attributes( - [attribute], allow_cache=False, only_cache=False, manufacturer=manufacturer - ) - _LOGGER.debug( - ( - "Read attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" - " %s: [%s]," - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_ATTRIBUTE, - attribute, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - str(success.get(attribute)), - "failure", - failure, - ) - connection.send_result(msg[ID], str(success.get(attribute))) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/bindable", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_get_bindable_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_IEEE] - source_device = zha_gateway.get_device(source_ieee) - - devices = [ - device.zha_device_info - for device in zha_gateway.devices.values() - if async_is_bindable_target(source_device, device) - ] - - _LOGGER.debug( - "Get bindable devices: %s: [%s], %s: [%s]", - ATTR_SOURCE_IEEE, - source_ieee, - "bindable devices", - devices, - ) - - connection.send_message(websocket_api.result_message(msg[ID], devices)) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/bind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_bind_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] - await async_binding_operation( - zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Bind_req - ) - _LOGGER.info( - "Devices bound: %s: [%s] %s: [%s]", - ATTR_SOURCE_IEEE, - source_ieee, - ATTR_TARGET_IEEE, - target_ieee, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/unbind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_unbind_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Remove a direct binding between devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] - await async_binding_operation( - zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Unbind_req - ) - _LOGGER.info( - "Devices un-bound: %s: [%s] %s: [%s]", - ATTR_SOURCE_IEEE, - source_ieee, - ATTR_TARGET_IEEE, - target_ieee, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/groups/bind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_bind_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Directly bind a device to a group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - group_id: int = msg[GROUP_ID] - bindings: list[ClusterBinding] = msg[BINDINGS] - source_device = zha_gateway.get_device(source_ieee) - assert source_device - await source_device.async_bind_to_group(group_id, bindings) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/groups/unbind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_unbind_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Unbind a device from a group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - group_id: int = msg[GROUP_ID] - bindings: list[ClusterBinding] = msg[BINDINGS] - source_device = zha_gateway.get_device(source_ieee) - assert source_device - await source_device.async_unbind_from_group(group_id, bindings) - - -async def async_binding_operation( - zha_gateway: ZHAGateway, - source_ieee: EUI64, - target_ieee: EUI64, - operation: zdo_types.ZDOCmd, -) -> None: - """Create or remove a direct zigbee binding between 2 devices.""" - - source_device = zha_gateway.get_device(source_ieee) - target_device = zha_gateway.get_device(target_ieee) - - assert source_device - assert target_device - clusters_to_bind = await get_matched_clusters(source_device, target_device) - - zdo = source_device.device.zdo - bind_tasks = [] - for binding_pair in clusters_to_bind: - op_msg = "cluster: %s %s --> [%s]" - op_params = ( - binding_pair.source_cluster.cluster_id, - operation.name, - target_ieee, - ) - zdo.debug(f"processing {op_msg}", *op_params) - - bind_tasks.append( - ( - zdo.request( - operation, - source_device.ieee, - binding_pair.source_cluster.endpoint.endpoint_id, - binding_pair.source_cluster.cluster_id, - binding_pair.destination_address, - ), - op_msg, - op_params, - ) - ) - res = await asyncio.gather(*(t[0] for t in bind_tasks), return_exceptions=True) - for outcome, log_msg in zip(res, bind_tasks): - if isinstance(outcome, Exception): - fmt = f"{log_msg[1]} failed: %s" - else: - fmt = f"{log_msg[1]} completed: %s" - zdo.debug(fmt, *(log_msg[2] + (outcome,))) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/configuration"}) -@websocket_api.async_response -async def websocket_get_configuration( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - import voluptuous_serialize # pylint: disable=import-outside-toplevel - - def custom_serializer(schema: Any) -> Any: - """Serialize additional types for voluptuous_serialize.""" - if schema is cv_boolean: - return {"type": "bool"} - if schema is vol.Schema: - return voluptuous_serialize.convert( - schema, custom_serializer=custom_serializer - ) - - return cv.custom_serializer(schema) - - data: dict[str, dict[str, Any]] = {"schemas": {}, "data": {}} - for section, schema in ZHA_CONFIG_SCHEMAS.items(): - if section == ZHA_ALARM_OPTIONS and not async_cluster_exists( - hass, IasAce.cluster_id - ): - continue - data["schemas"][section] = voluptuous_serialize.convert( - schema, custom_serializer=custom_serializer - ) - data["data"][section] = zha_gateway.config_entry.options.get( - CUSTOM_CONFIGURATION, {} - ).get(section, {}) - - # send default values for unconfigured options - for entry in data["schemas"][section]: - if data["data"][section].get(entry["name"]) is None: - data["data"][section][entry["name"]] = entry["default"] - - connection.send_result(msg[ID], data) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/configuration/update", - vol.Required("data"): ZHA_CONFIG_SCHEMAS, - } -) -@websocket_api.async_response -async def websocket_update_zha_configuration( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Update the ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - options = zha_gateway.config_entry.options - data_to_save = {**options, **{CUSTOM_CONFIGURATION: msg["data"]}} - - for section, schema in ZHA_CONFIG_SCHEMAS.items(): - for entry in schema.schema: - # remove options that match defaults - if ( - data_to_save[CUSTOM_CONFIGURATION].get(section, {}).get(entry) - == entry.default() - ): - data_to_save[CUSTOM_CONFIGURATION][section].pop(entry) - # remove entire section block if empty - if ( - not data_to_save[CUSTOM_CONFIGURATION].get(section) - and section in data_to_save[CUSTOM_CONFIGURATION] - ): - data_to_save[CUSTOM_CONFIGURATION].pop(section) - - # remove entire custom_configuration block if empty - if ( - not data_to_save.get(CUSTOM_CONFIGURATION) - and CUSTOM_CONFIGURATION in data_to_save - ): - data_to_save.pop(CUSTOM_CONFIGURATION) - - _LOGGER.info( - "Updating ZHA custom configuration options from %s to %s", - options, - data_to_save, - ) - - hass.config_entries.async_update_entry( - zha_gateway.config_entry, options=data_to_save - ) - status = await hass.config_entries.async_reload(zha_gateway.config_entry.entry_id) - connection.send_result(msg[ID], status) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/settings"}) -@websocket_api.async_response -async def websocket_get_network_settings( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA network settings.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - # Serialize the current network settings - backup = NetworkBackup( - node_info=application_controller.state.node_info, - network_info=application_controller.state.network_info, - ) - - connection.send_result( - msg[ID], - { - "radio_type": zha_gateway.config_entry.data[CONF_RADIO_TYPE], - "settings": backup.as_dict(), - }, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/list"}) -@websocket_api.async_response -async def websocket_list_network_backups( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA network settings.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - # Serialize known backups - connection.send_result( - msg[ID], [backup.as_dict() for backup in application_controller.backups] - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/create"}) -@websocket_api.async_response -async def websocket_create_network_backup( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Create a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - # This can take 5-30s - backup = await application_controller.backups.create_backup(load_devices=True) - connection.send_result( - msg[ID], - { - "backup": backup.as_dict(), - "is_complete": backup.is_complete(), - }, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/network/backups/restore", - vol.Required("backup"): _cv_zigpy_network_backup, - vol.Optional("ezsp_force_write_eui64", default=False): cv.boolean, - } -) -@websocket_api.async_response -async def websocket_restore_network_backup( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Restore a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - backup = msg["backup"] - - if msg["ezsp_force_write_eui64"]: - backup.network_info.stack_specific.setdefault("ezsp", {})[ - EZSP_OVERWRITE_EUI64 - ] = True - - # This can take 30-40s try: - await application_controller.backups.restore_backup(backup) - except ValueError as err: - connection.send_error(msg[ID], websocket_api.const.ERR_INVALID_FORMAT, str(err)) - else: - connection.send_result(msg[ID]) + return async_get_active_network_settings(hass) + except KeyError: + return await async_get_last_network_settings(hass, config_entry) -@callback -def async_load_api(hass: HomeAssistant) -> None: - """Set up the web socket API.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller +def async_get_radio_type( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> RadioType: + """Get ZHA radio type.""" + if config_entry is None: + config_entry = _get_config_entry(hass) - async def permit(service: ServiceCall) -> None: - """Allow devices to join this network.""" - duration: int = service.data[ATTR_DURATION] - ieee: EUI64 | None = service.data.get(ATTR_IEEE) - src_ieee: EUI64 - code: bytes - if ATTR_SOURCE_IEEE in service.data: - src_ieee = service.data[ATTR_SOURCE_IEEE] - code = service.data[ATTR_INSTALL_CODE] - _LOGGER.info("Allowing join for %s device with install code", src_ieee) - await application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - return - - if ATTR_QR_CODE in service.data: - src_ieee, code = service.data[ATTR_QR_CODE] - _LOGGER.info("Allowing join for %s device with install code", src_ieee) - await application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - return - - if ieee: - _LOGGER.info("Permitting joins for %ss on %s device", duration, ieee) - else: - _LOGGER.info("Permitting joins for %ss", duration) - await application_controller.permit(time_s=duration, node=ieee) - - async_register_admin_service( - hass, DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT] - ) - - async def remove(service: ServiceCall) -> None: - """Remove a node from the network.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = service.data[ATTR_IEEE] - zha_device: ZHADevice | None = zha_gateway.get_device(ieee) - if zha_device is not None and zha_device.is_active_coordinator: - _LOGGER.info("Removing the coordinator (%s) is not allowed", ieee) - return - _LOGGER.info("Removing node %s", ieee) - await application_controller.remove(ieee) - - async_register_admin_service( - hass, DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[IEEE_SERVICE] - ) - - async def set_zigbee_cluster_attributes(service: ServiceCall) -> None: - """Set zigbee attribute for cluster on zha entity.""" - ieee: EUI64 = service.data[ATTR_IEEE] - endpoint_id: int = service.data[ATTR_ENDPOINT_ID] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - cluster_type: str = service.data[ATTR_CLUSTER_TYPE] - attribute: int | str = service.data[ATTR_ATTRIBUTE] - value: int | bool | str = service.data[ATTR_VALUE] - manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) - zha_device = zha_gateway.get_device(ieee) - response = None - if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code - response = await zha_device.write_zigbee_attribute( - endpoint_id, - cluster_id, - attribute, - value, - cluster_type=cluster_type, - manufacturer=manufacturer, - ) - _LOGGER.debug( - ( - "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" - " [%s] %s: [%s]" - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_ATTRIBUTE, - attribute, - ATTR_VALUE, - value, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - response, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, - set_zigbee_cluster_attributes, - schema=SERVICE_SCHEMAS[SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE], - ) - - async def issue_zigbee_cluster_command(service: ServiceCall) -> None: - """Issue command on zigbee cluster on ZHA entity.""" - ieee: EUI64 = service.data[ATTR_IEEE] - endpoint_id: int = service.data[ATTR_ENDPOINT_ID] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - cluster_type: str = service.data[ATTR_CLUSTER_TYPE] - command: int = service.data[ATTR_COMMAND] - command_type: str = service.data[ATTR_COMMAND_TYPE] - args: list | None = service.data.get(ATTR_ARGS) - params: dict | None = service.data.get(ATTR_PARAMS) - manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) - zha_device = zha_gateway.get_device(ieee) - if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code - - await zha_device.issue_cluster_command( - endpoint_id, - cluster_id, - command, - command_type, - args, - params, - cluster_type=cluster_type, - manufacturer=manufacturer, - ) - _LOGGER.debug( - ( - "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" - " %s: [%s] %s: [%s] %s: [%s]" - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_COMMAND, - command, - ATTR_COMMAND_TYPE, - command_type, - ATTR_ARGS, - args, - ATTR_PARAMS, - params, - ATTR_MANUFACTURER, - manufacturer, - ) - else: - raise ValueError(f"Device with IEEE {str(ieee)} not found") - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, - issue_zigbee_cluster_command, - schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND], - ) - - async def issue_zigbee_group_command(service: ServiceCall) -> None: - """Issue command on zigbee cluster on a zigbee group.""" - group_id: int = service.data[ATTR_GROUP] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - command: int = service.data[ATTR_COMMAND] - args: list = service.data[ATTR_ARGS] - manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) - group = zha_gateway.get_group(group_id) - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - _LOGGER.error("Missing manufacturer attribute for cluster: %d", cluster_id) - response = None - if group is not None: - cluster = group.endpoint[cluster_id] - response = await cluster.command( - command, *args, manufacturer=manufacturer, expect_reply=True - ) - _LOGGER.debug( - "Issued group command for: %s: [%s] %s: [%s] %s: %s %s: [%s] %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_COMMAND, - command, - ATTR_ARGS, - args, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - response, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, - issue_zigbee_group_command, - schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], - ) - - def _get_ias_wd_channel(zha_device): - """Get the IASWD channel for a device.""" - cluster_channels = { - ch.name: ch - for pool in zha_device.channels.pools - for ch in pool.claimed_channels.values() - } - return cluster_channels.get(CHANNEL_IAS_WD) - - async def warning_device_squawk(service: ServiceCall) -> None: - """Issue the squawk command for an IAS warning device.""" - ieee: EUI64 = service.data[ATTR_IEEE] - mode: int = service.data[ATTR_WARNING_DEVICE_MODE] - strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] - level: int = service.data[ATTR_LEVEL] - - if (zha_device := zha_gateway.get_device(ieee)) is not None: - if channel := _get_ias_wd_channel(zha_device): - await channel.issue_squawk(mode, strobe, level) - else: - _LOGGER.error( - "Squawking IASWD: %s: [%s] is missing the required IASWD channel!", - ATTR_IEEE, - str(ieee), - ) - else: - _LOGGER.error( - "Squawking IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) - ) - _LOGGER.debug( - "Squawking IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", - ATTR_IEEE, - str(ieee), - ATTR_WARNING_DEVICE_MODE, - mode, - ATTR_WARNING_DEVICE_STROBE, - strobe, - ATTR_LEVEL, - level, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_WARNING_DEVICE_SQUAWK, - warning_device_squawk, - schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_SQUAWK], - ) - - async def warning_device_warn(service: ServiceCall) -> None: - """Issue the warning command for an IAS warning device.""" - ieee: EUI64 = service.data[ATTR_IEEE] - mode: int = service.data[ATTR_WARNING_DEVICE_MODE] - strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] - level: int = service.data[ATTR_LEVEL] - duration: int = service.data[ATTR_WARNING_DEVICE_DURATION] - duty_mode: int = service.data[ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE] - intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY] - - if (zha_device := zha_gateway.get_device(ieee)) is not None: - if channel := _get_ias_wd_channel(zha_device): - await channel.issue_start_warning( - mode, strobe, level, duration, duty_mode, intensity - ) - else: - _LOGGER.error( - "Warning IASWD: %s: [%s] is missing the required IASWD channel!", - ATTR_IEEE, - str(ieee), - ) - else: - _LOGGER.error( - "Warning IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) - ) - _LOGGER.debug( - "Warning IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", - ATTR_IEEE, - str(ieee), - ATTR_WARNING_DEVICE_MODE, - mode, - ATTR_WARNING_DEVICE_STROBE, - strobe, - ATTR_LEVEL, - level, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_WARNING_DEVICE_WARN, - warning_device_warn, - schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_WARN], - ) - - websocket_api.async_register_command(hass, websocket_permit_devices) - websocket_api.async_register_command(hass, websocket_get_devices) - websocket_api.async_register_command(hass, websocket_get_groupable_devices) - websocket_api.async_register_command(hass, websocket_get_groups) - websocket_api.async_register_command(hass, websocket_get_device) - websocket_api.async_register_command(hass, websocket_get_group) - websocket_api.async_register_command(hass, websocket_add_group) - websocket_api.async_register_command(hass, websocket_remove_groups) - websocket_api.async_register_command(hass, websocket_add_group_members) - websocket_api.async_register_command(hass, websocket_remove_group_members) - websocket_api.async_register_command(hass, websocket_bind_group) - websocket_api.async_register_command(hass, websocket_unbind_group) - websocket_api.async_register_command(hass, websocket_reconfigure_node) - websocket_api.async_register_command(hass, websocket_device_clusters) - websocket_api.async_register_command(hass, websocket_device_cluster_attributes) - websocket_api.async_register_command(hass, websocket_device_cluster_commands) - websocket_api.async_register_command(hass, websocket_read_zigbee_cluster_attributes) - websocket_api.async_register_command(hass, websocket_get_bindable_devices) - websocket_api.async_register_command(hass, websocket_bind_devices) - websocket_api.async_register_command(hass, websocket_unbind_devices) - websocket_api.async_register_command(hass, websocket_update_topology) - websocket_api.async_register_command(hass, websocket_get_configuration) - websocket_api.async_register_command(hass, websocket_update_zha_configuration) - websocket_api.async_register_command(hass, websocket_get_network_settings) - websocket_api.async_register_command(hass, websocket_list_network_backups) - websocket_api.async_register_command(hass, websocket_create_network_backup) - websocket_api.async_register_command(hass, websocket_restore_network_backup) + return RadioType[config_entry.data[CONF_RADIO_TYPE]] -@callback -def async_unload_api(hass: HomeAssistant) -> None: - """Unload the ZHA API.""" - hass.services.async_remove(DOMAIN, SERVICE_PERMIT) - hass.services.async_remove(DOMAIN, SERVICE_REMOVE) - hass.services.async_remove(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE) - hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND) - hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND) - hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_SQUAWK) - hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_WARN) +def async_get_radio_path( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> str: + """Get ZHA radio path.""" + if config_entry is None: + config_entry = _get_config_entry(hass) + + return config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index dc5a5eebbaa1..696216e3e814 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -2,13 +2,18 @@ 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 @@ -39,6 +44,9 @@ CLASS_MAPPING = { 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( @@ -76,34 +84,23 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): self._channel, 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._channel.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) @@ -124,11 +121,21 @@ class Occupancy(BinarySensor): @STRICT_MATCH(channel_names=CHANNEL_ON_OFF) class Opening(BinarySensor): - """ZHA BinarySensor.""" + """ZHA OnOff BinarySensor.""" SENSOR_ATTR = "on_off" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OPENING + # Client/out cluster attributes aren't stored in the zigpy database, but are properly stored in the runtime cache. + # 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._channel.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) class BinaryInput(BinarySensor): @@ -149,10 +156,9 @@ class BinaryInput(BinarySensor): 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_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.MOTION @@ -167,12 +173,40 @@ class IASZone(BinarySensor): """Return device class from component DEVICE_CLASSES.""" return CLASS_MAPPING.get(self._channel.cluster.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._channel.cluster.update_attribute( + IasZone.attributes_by_name[self.SENSOR_ATTR].id, migrated_state + ) @MULTI_MATCH( @@ -213,3 +247,49 @@ class XiaomiPlugConsumerConnected(BinarySensor, id_suffix="consumer_connected"): SENSOR_ATTR = "consumer_connected" _attr_name: str = "Consumer connected" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PLUG + + +@MULTI_MATCH(channel_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(channel_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(channel_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(channel_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(channel_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 14547216dcbc..b3ff3f5aedd9 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -184,3 +184,15 @@ class AqaraPetFeederFeedButton(ZHAAttributeButton, id_suffix="feeding"): _attribute_name = "feeding" _attr_name = "Feed" _attribute_value = 1 + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_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/config_flow.py b/homeassistant/components/zha/config_flow.py index 05dc67314ed7..53c4e3388108 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -101,7 +101,7 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: addon_port = ListPortInfo( - device=silabs_multiprotocol_addon.get_zigbee_socket(hass, addon_info), + device=silabs_multiprotocol_addon.get_zigbee_socket(), skip_link_detection=True, ) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 149b733be397..a708e65a07a7 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -239,6 +239,11 @@ class ChannelPool: """Return device model.""" return self._channels.zha_device.model + @property + def quirk_class(self) -> str: + """Return device quirk class.""" + return self._channels.zha_device.quirk_class + @property def skip_configuration(self) -> bool: """Return True if device does not require channel configuration.""" diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index ae5980cd6306..6d4899be37c6 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -58,15 +58,19 @@ class AttrReportConfig(TypedDict, total=True): def parse_and_log_command(channel, tsn, command_id, args): """Parse and log a zigbee cluster command.""" - cmd = channel.cluster.server_commands.get(command_id, [command_id])[0] + try: + name = channel.cluster.server_commands[command_id].name + except KeyError: + name = f"0x{command_id:02X}" + channel.debug( "received '%s' command with %s args on cluster_id '%s' tsn '%s'", - cmd, + name, args, channel.cluster.cluster_id, tsn, ) - return cmd + return name def decorate_command(channel, command): diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 85a478b0d4dd..e312f398b543 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -138,6 +138,29 @@ 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.""" @@ -324,3 +347,11 @@ class IkeaAirPurifierChannel(ZigbeeChannel): self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value ) + + +@registries.CHANNEL_ONLY_CLUSTERS.register(0xFC80) +@registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFC80) +class IkeaRemote(ZigbeeChannel): + """Ikea Matter remote channel.""" + + REPORT_CONFIG = () diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index b5a8d5d8cf59..404e4a8d258c 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -11,7 +11,7 @@ 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 @@ -332,21 +332,22 @@ class IasWd(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(security.IasZone.cluster_id) +@registries.ZIGBEE_CHANNEL_REGISTRY.register(IasZone.cluster_id) class IASZoneChannel(ZigbeeChannel): """Channel 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) @@ -389,11 +390,10 @@ class IASZoneChannel(ZigbeeChannel): @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/const.py b/homeassistant/components/zha/core/const.py index 4c10a2328a27..6423723d326d 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -137,6 +137,8 @@ CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_FLOWCONTROL = "flow_control" +CONF_NWK = "network" +CONF_NWK_CHANNEL = "channel" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_USE_THREAD = "use_thread" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 17ec04fa9e86..9d40314e0611 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -84,7 +84,7 @@ from .const import ( 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__) diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index eb7dd81e381d..d256b98cfb11 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -95,7 +95,11 @@ class ProbeEndpoint: 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 + component, + channel_pool.manufacturer, + channel_pool.model, + channels, + channel_pool.quirk_class, ) if entity_class is None: return @@ -145,7 +149,11 @@ class ProbeEndpoint: unique_id = f"{ep_channels.unique_id}-{channel.cluster.cluster_id}" entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( - component, ep_channels.manufacturer, ep_channels.model, channel_list + component, + ep_channels.manufacturer, + ep_channels.model, + channel_list, + ep_channels.quirk_class, ) if entity_class is None: return @@ -190,12 +198,14 @@ class ProbeEndpoint: channel_pool.manufacturer, channel_pool.model, list(channel_pool.all_channels.values()), + channel_pool.quirk_class, ) else: matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity( channel_pool.manufacturer, channel_pool.model, channel_pool.unclaimed_channels(), + channel_pool.quirk_class, ) channel_pool.claim_channels(claimed) @@ -210,8 +220,7 @@ class ProbeEndpoint: 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 + # for well known device types, like thermostats we'll take only 1st class channel_pool.async_new_entity( component, entity_and_channel.entity_class, diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 2f1b22e0ea2d..8858ea69590c 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -41,6 +41,8 @@ from .const import ( ATTR_TYPE, CONF_DATABASE, CONF_DEVICE_PATH, + CONF_NWK, + CONF_NWK_CHANNEL, CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, @@ -148,14 +150,8 @@ class ZHAGateway: self._unsubs: list[Callable[[], None]] = [] self.initialized: bool = False - async def async_initialize(self) -> None: - """Initialize controller and connect radio.""" - discovery.PROBE.initialize(self._hass) - discovery.GROUP_PROBE.initialize(self._hass) - - self.ha_device_registry = dr.async_get(self._hass) - self.ha_entity_registry = er.async_get(self._hass) - + def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: + """Get an uninitialized instance of a zigpy `ControllerApplication`.""" radio_type = self.config_entry.data[CONF_RADIO_TYPE] app_controller_cls = RadioType[radio_type].controller @@ -178,7 +174,31 @@ class ZHAGateway: ): app_config[CONF_USE_THREAD] = False - app_config = app_controller_cls.SCHEMA(app_config) + # Local import to avoid circular dependencies + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, + ) + + # Until we have a way to coordinate channels with the Thread half of multi-PAN, + # stick to the old zigpy default of channel 15 instead of dynamically scanning + if ( + is_multiprotocol_url(app_config[CONF_DEVICE][CONF_DEVICE_PATH]) + and app_config.get(CONF_NWK, {}).get(CONF_NWK_CHANNEL) is None + ): + app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15 + + return app_controller_cls, app_controller_cls.SCHEMA(app_config) + + async def async_initialize(self) -> None: + """Initialize controller and connect radio.""" + discovery.PROBE.initialize(self._hass) + discovery.GROUP_PROBE.initialize(self._hass) + + self.ha_device_registry = dr.async_get(self._hass) + self.ha_entity_registry = er.async_get(self._hass) + + app_controller_cls, app_config = self.get_application_controller_data() for attempt in range(STARTUP_RETRIES): try: @@ -393,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, diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 6b99d412688d..a7504ae7a96d 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -93,9 +93,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: { @@ -146,13 +144,17 @@ class MatchRule: aux_channels: 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 + ) @property 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. @@ -160,6 +162,11 @@ class MatchRule: multiple channels a better priority over rules matching a single channel. """ 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)) @@ -187,15 +194,21 @@ class MatchRule: 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, channels: 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, channels, quirk_class)) - def loose_matched(self, manufacturer: str, model: str, channels: list) -> bool: + def loose_matched( + self, manufacturer: str, model: str, channels: 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, channels, quirk_class)) - def _matched(self, manufacturer: str, model: str, channels: list) -> list: + def _matched( + self, manufacturer: str, model: str, channels: list, quirk_class: str + ) -> list: """Return a list of field matches.""" if not any(attr.asdict(self).values()): return [False] @@ -221,6 +234,12 @@ 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 @@ -261,12 +280,13 @@ class ZHAEntityRegistry: manufacturer: str, model: str, channels: list[ZigbeeChannel], + quirk_class: str, default: type[ZhaEntity] | None = None, ) -> tuple[type[ZhaEntity] | None, list[ZigbeeChannel]]: """Match a ZHA Channels 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): + if match.strict_matched(manufacturer, model, channels, quirk_class): claimed = match.claim_channels(channels) return self._strict_registry[component][match], claimed @@ -277,6 +297,7 @@ class ZHAEntityRegistry: manufacturer: str, model: str, channels: list[ZigbeeChannel], + quirk_class: str, ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ZigbeeChannel]]: """Match ZHA Channels to potentially multiple ZHA Entity classes.""" result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list) @@ -285,7 +306,7 @@ 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): + if match.strict_matched(manufacturer, model, channels, quirk_class): claimed = match.claim_channels(channels) for ent_class in stop_match_groups[stop_match_grp][match]: ent_n_channels = EntityClassAndChannels(ent_class, claimed) @@ -301,6 +322,7 @@ class ZHAEntityRegistry: manufacturer: str, model: str, channels: list[ZigbeeChannel], + quirk_class: str, ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ZigbeeChannel]]: """Match ZHA Channels to potentially multiple ZHA Entity classes.""" result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list) @@ -312,7 +334,7 @@ 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): + if match.strict_matched(manufacturer, model, channels, quirk_class): claimed = match.claim_channels(channels) for ent_class in stop_match_groups[stop_match_grp][match]: ent_n_channels = EntityClassAndChannels(ent_class, claimed) @@ -335,11 +357,17 @@ class ZHAEntityRegistry: manufacturers: Callable | set[str] | str | None = None, models: Callable | set[str] | str | None = None, aux_channels: 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 + channel_names, + generic_ids, + manufacturers, + models, + aux_channels, + quirk_classes, ) def decorator(zha_ent: _ZhaEntityT) -> _ZhaEntityT: @@ -361,6 +389,7 @@ class ZHAEntityRegistry: models: Callable | set[str] | str | None = None, aux_channels: 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.""" @@ -370,6 +399,7 @@ class ZHAEntityRegistry: manufacturers, models, aux_channels, + quirk_classes, ) def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: @@ -394,6 +424,7 @@ class ZHAEntityRegistry: models: Callable | set[str] | str | None = None, aux_channels: 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.""" @@ -403,6 +434,7 @@ class ZHAEntityRegistry: manufacturers, models, aux_channels, + quirk_classes, ) def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 9867bc5cfbb9..25a01f45baaf 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -12,10 +12,10 @@ 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.helpers import async_get_zha_device +from .websocket_api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN # mypy: disallow-any-generics diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 2e0653b47e19..966f35fe98bb 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -7,6 +7,7 @@ from typing import Any from zigpy.config import CONF_NWK_EXTENDED_PAN_ID from zigpy.profiles import PROFILES +from zigpy.types import Channels from zigpy.zcl import Cluster from homeassistant.components.diagnostics.util import async_redact_data @@ -67,11 +68,19 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" config: dict = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + energy_scan = await gateway.application_controller.energy_scan( + channels=Channels.ALL_CHANNELS, duration_exp=4, count=1 + ) + return async_redact_data( { "config": config, "config_entry": config_entry.as_dict(), "application_state": shallow_asdict(gateway.application_controller.state), + "energy_scan": { + channel: 100 * energy / 255 for channel, energy in energy_scan.items() + }, "versions": { "bellows": version("bellows"), "zigpy": version("zigpy"), diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 13d63808b61f..5153d3c45673 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -278,10 +278,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) @@ -311,12 +309,17 @@ class IkeaFan(BaseFan, ZhaEntity): """Return the current preset mode.""" return IKEA_PRESET_MODES_TO_NAME.get(self._fan_channel.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: diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index a2ec5e068cbc..433f662a7854 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -9,8 +9,12 @@ 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 @@ -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, diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 3061d867b657..bc5bf6a6d4b7 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,15 +20,15 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.34.10", + "bellows==0.35.0", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.94", - "zigpy-deconz==0.19.2", - "zigpy==0.53.2", - "zigpy-xbee==0.16.2", + "zha-quirks==0.0.95", + "zigpy-deconz==0.20.0", + "zigpy==0.54.0", + "zigpy-xbee==0.17.0", "zigpy-zigate==0.10.3", - "zigpy-znp==0.9.3" + "zigpy-znp==0.10.0" ], "usb": [ { diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 334b72dccc58..d0ec62eaf618 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -11,7 +11,7 @@ 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 @@ -375,6 +375,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): _attr_entity_category = EntityCategory.CONFIG _attr_native_step: float = 1.0 + _attr_multiplier: float = 1 _zcl_attribute: str @classmethod @@ -417,13 +418,13 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): @property def native_value(self) -> float: """Return the current value.""" - return self._channel.cluster.get(self._zcl_attribute) + return self._channel.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)} + {self._zcl_attribute: int(value / self._attr_multiplier)} ) except zigpy.exceptions.ZigbeeException as ex: self.error("Could not set value: %s", ex) @@ -861,3 +862,20 @@ class AqaraPetFeederPortionWeight( _attr_mode: NumberMode = NumberMode.BOX _attr_native_unit_of_measurement: str = UnitOfMass.GRAMS _attr_icon: str = "mdi:weight-gram" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatAwayTemp( + ZHANumberConfigurationEntity, id_suffix="away_preset_temperature" +): + """Aqara away preset temperature configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value: float = 5 + _attr_native_max_value: float = 30 + _attr_multiplier: float = 0.01 + _zcl_attribute: str = "away_preset_temperature" + _attr_name: str = "Away preset temperature" + _attr_mode: NumberMode = NumberMode.SLIDER + _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS + _attr_icon: str = ICONS[0] diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index d9074acecfce..605c7d507c66 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -26,6 +26,7 @@ from .core.const import ( CHANNEL_ON_OFF, DATA_ZHA, SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, Strobe, ) from .core.registries import ZHA_ENTITIES @@ -212,6 +213,18 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): ) 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._channel, 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.""" + self.async_write_ha_state() + @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_ON_OFF) class ZHAStartupOnOffSelectEntity( @@ -490,3 +503,20 @@ 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(channel_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 78ce47c7e571..a7a090b13afe 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -955,3 +955,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(channel_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/switch.py b/homeassistant/components/zha/switch.py index 09cebc8f4ced..f0e36750798b 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -477,3 +477,82 @@ 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(channel_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(channel_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(channel_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( + channel_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( + channel_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( + channel_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( + channel_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 000000000000..d2da6af01264 --- /dev/null +++ b/homeassistant/components/zha/websocket_api.py @@ -0,0 +1,1541 @@ +"""Web socket API for Zigbee Home Automation devices.""" +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast + +import voluptuous as vol +import zigpy.backups +from zigpy.config.validators import cv_boolean +from zigpy.types.named import EUI64 +from zigpy.zcl.clusters.security import IasAce +import zigpy.zdo.types as zdo_types + +from homeassistant.components import websocket_api +from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME +from homeassistant.core import HomeAssistant, ServiceCall, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service import async_register_admin_service + +from .api import async_get_active_network_settings, async_get_radio_type +from .core.const import ( + ATTR_ARGS, + ATTR_ATTRIBUTE, + ATTR_CLUSTER_ID, + ATTR_CLUSTER_TYPE, + ATTR_COMMAND_TYPE, + ATTR_ENDPOINT_ID, + ATTR_IEEE, + ATTR_LEVEL, + ATTR_MANUFACTURER, + ATTR_MEMBERS, + ATTR_PARAMS, + ATTR_TYPE, + ATTR_VALUE, + ATTR_WARNING_DEVICE_DURATION, + ATTR_WARNING_DEVICE_MODE, + ATTR_WARNING_DEVICE_STROBE, + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, + ATTR_WARNING_DEVICE_STROBE_INTENSITY, + BINDINGS, + CHANNEL_IAS_WD, + CLUSTER_COMMAND_SERVER, + CLUSTER_COMMANDS_CLIENT, + CLUSTER_COMMANDS_SERVER, + 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_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, +) + +if TYPE_CHECKING: + from homeassistant.components.websocket_api.connection import ActiveConnection + + from .core.device import ZHADevice + from .core.gateway import ZHAGateway + +_LOGGER = logging.getLogger(__name__) + +TYPE = "type" +CLIENT = "client" +ID = "id" +RESPONSE = "response" +DEVICE_INFO = "device_info" + +ATTR_DURATION = "duration" +ATTR_GROUP = "group" +ATTR_IEEE_ADDRESS = "ieee_address" +ATTR_INSTALL_CODE = "install_code" +ATTR_SOURCE_IEEE = "source_ieee" +ATTR_TARGET_IEEE = "target_ieee" +ATTR_QR_CODE = "qr_code" + +SERVICE_PERMIT = "permit" +SERVICE_REMOVE = "remove" +SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = "set_zigbee_cluster_attribute" +SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = "issue_zigbee_cluster_command" +SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND = "issue_zigbee_group_command" +SERVICE_DIRECT_ZIGBEE_BIND = "issue_direct_zigbee_bind" +SERVICE_DIRECT_ZIGBEE_UNBIND = "issue_direct_zigbee_unbind" +SERVICE_WARNING_DEVICE_SQUAWK = "warning_device_squawk" +SERVICE_WARNING_DEVICE_WARN = "warning_device_warn" +SERVICE_ZIGBEE_BIND = "service_zigbee_bind" +IEEE_SERVICE = "ieee_based_service" + +IEEE_SCHEMA = vol.All(cv.string, EUI64.convert) + +# typing typevar +_T = TypeVar("_T") + + +def _ensure_list_if_present(value: _T | None) -> list[_T] | list[Any] | None: + """Wrap value in list if it is provided and not one.""" + if value is None: + return None + return cast("list[_T]", value) if isinstance(value, list) else [value] + + +SERVICE_PERMIT_PARAMS = { + vol.Optional(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional(ATTR_DURATION, default=60): vol.All( + vol.Coerce(int), vol.Range(0, 254) + ), + vol.Inclusive(ATTR_SOURCE_IEEE, "install_code"): IEEE_SCHEMA, + vol.Inclusive(ATTR_INSTALL_CODE, "install_code"): vol.All( + cv.string, convert_install_code + ), + vol.Exclusive(ATTR_QR_CODE, "install_code"): vol.All(cv.string, qr_to_install_code), +} + +SERVICE_SCHEMAS = { + SERVICE_PERMIT: vol.Schema( + vol.All( + cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), + SERVICE_PERMIT_PARAMS, + ) + ), + IEEE_SERVICE: vol.Schema( + vol.All( + cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), + {vol.Required(ATTR_IEEE): IEEE_SCHEMA}, + ) + ), + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_ATTRIBUTE): vol.Any(cv.positive_int, str), + vol.Required(ATTR_VALUE): vol.Any(int, cv.boolean, cv.string), + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } + ), + SERVICE_WARNING_DEVICE_SQUAWK: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional( + ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_SQUAWK_MODE_ARMED + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ): cv.positive_int, + vol.Optional( + ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ): cv.positive_int, + } + ), + SERVICE_WARNING_DEVICE_WARN: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional( + ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_MODE_EMERGENCY + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ): cv.positive_int, + vol.Optional( + ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ): cv.positive_int, + vol.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE_INTENSITY, default=WARNING_DEVICE_STROBE_HIGH + ): cv.positive_int, + } + ), + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.All( + vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_COMMAND): cv.positive_int, + vol.Required(ATTR_COMMAND_TYPE): cv.string, + vol.Exclusive(ATTR_ARGS, "attrs_params"): _ensure_list_if_present, + vol.Exclusive(ATTR_PARAMS, "attrs_params"): dict, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } + ), + cv.deprecated(ATTR_ARGS), + cv.has_at_least_one_key(ATTR_ARGS, ATTR_PARAMS), + ), + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND: vol.Schema( + { + vol.Required(ATTR_GROUP): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_COMMAND): cv.positive_int, + vol.Optional(ATTR_ARGS, default=[]): cv.ensure_list, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } + ), +} + + +class ClusterBinding(NamedTuple): + """Describes a cluster binding.""" + + name: str + type: str + id: int + endpoint_id: int + + +def _cv_group_member(value: dict[str, Any]) -> GroupMember: + """Transform a group member.""" + return GroupMember( + ieee=value[ATTR_IEEE], + endpoint_id=value[ATTR_ENDPOINT_ID], + ) + + +def _cv_cluster_binding(value: dict[str, Any]) -> ClusterBinding: + """Transform a cluster binding.""" + return ClusterBinding( + name=value[ATTR_NAME], + type=value[ATTR_TYPE], + id=value[ATTR_ID], + endpoint_id=value[ATTR_ENDPOINT_ID], + ) + + +def _cv_zigpy_network_backup(value: dict[str, Any]) -> zigpy.backups.NetworkBackup: + """Transform a zigpy network backup.""" + + try: + return zigpy.backups.NetworkBackup.from_dict(value) + except ValueError as err: + raise vol.Invalid(str(err)) from err + + +GROUP_MEMBER_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), + } + ), + _cv_group_member, +) + + +CLUSTER_BINDING_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_TYPE): cv.string, + vol.Required(ATTR_ID): vol.Coerce(int), + vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), + } + ), + _cv_cluster_binding, +) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "zha/devices/permit", + **SERVICE_PERMIT_PARAMS, + } +) +@websocket_api.async_response +async def websocket_permit_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Permit ZHA zigbee devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + duration: int = msg[ATTR_DURATION] + ieee: EUI64 | None = msg.get(ATTR_IEEE) + + async def forward_messages(data): + """Forward events to websocket.""" + connection.send_message(websocket_api.event_message(msg["id"], data)) + + remove_dispatcher_function = async_dispatcher_connect( + hass, "zha_gateway_message", forward_messages + ) + + @callback + def async_cleanup() -> None: + """Remove signal listener and turn off debug mode.""" + zha_gateway.async_disable_debug_mode() + remove_dispatcher_function() + + connection.subscriptions[msg["id"]] = async_cleanup + zha_gateway.async_enable_debug_mode() + src_ieee: EUI64 + code: bytes + if ATTR_SOURCE_IEEE in msg: + src_ieee = msg[ATTR_SOURCE_IEEE] + code = msg[ATTR_INSTALL_CODE] + _LOGGER.debug("Allowing join for %s device with install code", src_ieee) + await zha_gateway.application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + elif ATTR_QR_CODE in msg: + src_ieee, code = msg[ATTR_QR_CODE] + _LOGGER.debug("Allowing join for %s device with install code", src_ieee) + await zha_gateway.application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + else: + await zha_gateway.application_controller.permit(time_s=duration, node=ieee) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices"}) +@websocket_api.async_response +async def websocket_get_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + devices = [device.zha_device_info for device in zha_gateway.devices.values()] + connection.send_result(msg[ID], devices) + + +@callback +def _get_entity_name( + zha_gateway: ZHAGateway, entity_ref: EntityReference +) -> str | None: + entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + return entry.name if entry else None + + +@callback +def _get_entity_original_name( + zha_gateway: ZHAGateway, entity_ref: EntityReference +) -> str | None: + entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + return entry.original_name if entry else None + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices/groupable"}) +@websocket_api.async_response +async def websocket_get_groupable_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices that can be grouped.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + devices = [device for device in zha_gateway.devices.values() if device.is_groupable] + groupable_devices = [] + + for device in devices: + entity_refs = zha_gateway.device_registry[device.ieee] + for ep_id in device.async_get_groupable_endpoints(): + groupable_devices.append( + { + "endpoint_id": ep_id, + "entities": [ + { + "name": _get_entity_name(zha_gateway, entity_ref), + "original_name": _get_entity_original_name( + zha_gateway, entity_ref + ), + } + for entity_ref in entity_refs + if list(entity_ref.cluster_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.""" + backup = async_get_active_network_settings(hass) + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + connection.send_result( + msg[ID], + { + "radio_type": async_get_radio_type(hass, zha_gateway.config_entry).name, + "settings": backup.as_dict(), + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/list"}) +@websocket_api.async_response +async def websocket_list_network_backups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA network settings.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + + # Serialize known backups + connection.send_result( + msg[ID], [backup.as_dict() for backup in application_controller.backups] + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/create"}) +@websocket_api.async_response +async def websocket_create_network_backup( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Create a ZHA network backup.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + + # This can take 5-30s + backup = await application_controller.backups.create_backup(load_devices=True) + connection.send_result( + msg[ID], + { + "backup": backup.as_dict(), + "is_complete": backup.is_complete(), + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/network/backups/restore", + vol.Required("backup"): _cv_zigpy_network_backup, + vol.Optional("ezsp_force_write_eui64", default=False): cv.boolean, + } +) +@websocket_api.async_response +async def websocket_restore_network_backup( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Restore a ZHA network backup.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + backup = msg["backup"] + + if msg["ezsp_force_write_eui64"]: + backup.network_info.stack_specific.setdefault("ezsp", {})[ + EZSP_OVERWRITE_EUI64 + ] = True + + # This can take 30-40s + try: + await application_controller.backups.restore_backup(backup) + except ValueError as err: + connection.send_error(msg[ID], websocket_api.const.ERR_INVALID_FORMAT, str(err)) + else: + connection.send_result(msg[ID]) + + +@callback +def async_load_api(hass: HomeAssistant) -> None: + """Set up the web socket API.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + + async def permit(service: ServiceCall) -> None: + """Allow devices to join this network.""" + duration: int = service.data[ATTR_DURATION] + ieee: EUI64 | None = service.data.get(ATTR_IEEE) + src_ieee: EUI64 + code: bytes + if ATTR_SOURCE_IEEE in service.data: + src_ieee = service.data[ATTR_SOURCE_IEEE] + code = service.data[ATTR_INSTALL_CODE] + _LOGGER.info("Allowing join for %s device with install code", src_ieee) + await application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + return + + if ATTR_QR_CODE in service.data: + src_ieee, code = service.data[ATTR_QR_CODE] + _LOGGER.info("Allowing join for %s device with install code", src_ieee) + await application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + return + + if ieee: + _LOGGER.info("Permitting joins for %ss on %s device", duration, ieee) + else: + _LOGGER.info("Permitting joins for %ss", duration) + await application_controller.permit(time_s=duration, node=ieee) + + async_register_admin_service( + hass, DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT] + ) + + async def remove(service: ServiceCall) -> None: + """Remove a node from the network.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = service.data[ATTR_IEEE] + zha_device: ZHADevice | None = zha_gateway.get_device(ieee) + if zha_device is not None and zha_device.is_active_coordinator: + _LOGGER.info("Removing the coordinator (%s) is not allowed", ieee) + return + _LOGGER.info("Removing node %s", ieee) + await application_controller.remove(ieee) + + async_register_admin_service( + hass, DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[IEEE_SERVICE] + ) + + async def set_zigbee_cluster_attributes(service: ServiceCall) -> None: + """Set zigbee attribute for cluster on zha entity.""" + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + attribute: int | str = service.data[ATTR_ATTRIBUTE] + value: int | bool | str = service.data[ATTR_VALUE] + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + response = None + if zha_device is not None: + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code + response = await zha_device.write_zigbee_attribute( + endpoint_id, + cluster_id, + attribute, + value, + cluster_type=cluster_type, + manufacturer=manufacturer, + ) + _LOGGER.debug( + ( + "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" + " [%s] %s: [%s]" + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_ATTRIBUTE, + attribute, + ATTR_VALUE, + value, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + response, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, + set_zigbee_cluster_attributes, + schema=SERVICE_SCHEMAS[SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE], + ) + + async def issue_zigbee_cluster_command(service: ServiceCall) -> None: + """Issue command on zigbee cluster on ZHA entity.""" + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + command: int = service.data[ATTR_COMMAND] + command_type: str = service.data[ATTR_COMMAND_TYPE] + args: list | None = service.data.get(ATTR_ARGS) + params: dict | None = service.data.get(ATTR_PARAMS) + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + if zha_device is not None: + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code + + await zha_device.issue_cluster_command( + endpoint_id, + cluster_id, + command, + command_type, + args, + params, + cluster_type=cluster_type, + manufacturer=manufacturer, + ) + _LOGGER.debug( + ( + "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" + " %s: [%s] %s: [%s] %s: [%s]" + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_COMMAND, + command, + ATTR_COMMAND_TYPE, + command_type, + ATTR_ARGS, + args, + ATTR_PARAMS, + params, + ATTR_MANUFACTURER, + manufacturer, + ) + else: + raise ValueError(f"Device with IEEE {str(ieee)} not found") + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, + issue_zigbee_cluster_command, + schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND], + ) + + async def issue_zigbee_group_command(service: ServiceCall) -> None: + """Issue command on zigbee cluster on a zigbee group.""" + group_id: int = service.data[ATTR_GROUP] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + command: int = service.data[ATTR_COMMAND] + args: list = service.data[ATTR_ARGS] + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + group = zha_gateway.get_group(group_id) + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + _LOGGER.error("Missing manufacturer attribute for cluster: %d", cluster_id) + response = None + if group is not None: + cluster = group.endpoint[cluster_id] + response = await cluster.command( + command, *args, manufacturer=manufacturer, expect_reply=True + ) + _LOGGER.debug( + "Issued group command for: %s: [%s] %s: [%s] %s: %s %s: [%s] %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_COMMAND, + command, + ATTR_ARGS, + args, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + response, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, + issue_zigbee_group_command, + schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], + ) + + def _get_ias_wd_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) diff --git a/homeassistant/components/zwave_js/README.md b/homeassistant/components/zwave_js/README.md index 920fc4a6a0b6..f82f421f7528 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 8d3b93ad9db2..a2d729e22dcc 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -32,7 +32,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 +161,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 +220,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 +240,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 +311,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 +385,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 +448,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 +532,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: diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 091de1949eb3..91b1e2a71574 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -120,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" diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py index 0172176d756d..3a585b44f586 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 067551109ebd..a0ac70ccb31d 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 50130fc26327..4f52c41a0854 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 fa0c3dc13de0..36295a645589 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") @@ -390,6 +393,53 @@ DISCOVERY_SCHEMAS = [ product_type={0x0003}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, ), + # Merten 507801 Connect Roller Shutter + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="window_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="window_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, diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index a21f7a6f30b5..d41ee0272a93 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.46.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.3"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 5485870dc5f2..70d12b22dedc 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 f47b77b29d1b..1740820d0ba5 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( @@ -134,10 +143,33 @@ class ZWaveMeEntity(Entity): self.hass, f"ZWAVE_ME_INFO_{self.device.id}", self.get_new_data ) ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"ZWAVE_ME_UNAVAILABLE_{self.device.id}", + self.set_unavailable_status, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, f"ZWAVE_ME_DESTROY_{self.device.id}", self.delete_entity + ) + ) @callback - def get_new_data(self, new_data): + def get_new_data(self, new_data: ZWaveMeData) -> None: """Update info in the HAss.""" self.device = new_data self._attr_available = not new_data.isFailed self.async_write_ha_state() + + @callback + def set_unavailable_status(self): + """Update status in the HAss.""" + self._attr_available = False + self.async_write_ha_state() + + @callback + def delete_entity(self) -> None: + """Remove this entity.""" + self.hass.async_create_task(self.async_remove(force_remove=True)) diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index 633901596816..388a8c2c1d48 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.3.6", "url-normalize==1.4.3"], "zeroconf": [ { "type": "_hap._tcp.local.", diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 94f2bab75acb..454cfeade277 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -729,7 +729,8 @@ class ConfigEntry: } | (context or {}), data=self.data | (data or {}), - ) + ), + f"config entry reauth {self.title} {self.domain} {self.entry_id}", ) @callback @@ -746,15 +747,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) @@ -824,7 +830,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: @@ -945,25 +954,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 @@ -1112,7 +1103,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) @@ -1121,7 +1113,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() @@ -1337,7 +1335,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) @@ -1367,14 +1368,23 @@ class ConfigEntries: error_if_core=False, ) for platform in platforms: - self.hass.async_create_task(self.async_forward_entry_setup(entry, platform)) + self.hass.async_create_task( + self.async_forward_entry_setup(entry, platform), + f"config entry forward setup {entry.title} {entry.domain} {entry.entry_id} {platform}", + ) async def async_forward_entry_setups( self, entry: ConfigEntry, platforms: Iterable[Platform | str] ) -> None: """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( @@ -1405,7 +1415,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 ) ) @@ -1455,6 +1468,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.""" @@ -1492,18 +1527,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( @@ -1549,7 +1575,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) @@ -1810,6 +1837,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 @@ -1842,6 +1871,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.""" @@ -1935,7 +1985,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 + ) ) @@ -1968,3 +2024,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 ed959488ddbc..52a07324b7c9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,8 +7,8 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "6" +MINOR_VERSION: Final = 4 +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/homeassistant/core.py b/homeassistant/core.py index 7003b87ce677..78ceb620e53f 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -149,7 +149,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) @@ -217,16 +217,17 @@ class HassJob(Generic[_P, _R_co]): we run the job. """ - __slots__ = ("job_type", "target") + __slots__ = ("job_type", "target", "name") - def __init__(self, target: Callable[_P, _R_co]) -> None: + def __init__(self, target: Callable[_P, _R_co], name: str | None = None) -> None: """Create a job object.""" self.target = target + self.name = name self.job_type = _get_hassjob_callable_job_type(target) def __repr__(self) -> str: """Return the job.""" - return f"" + return f"" def _get_hassjob_callable_job_type(target: Callable[..., Any]) -> HassJobType: @@ -488,7 +489,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) @@ -512,15 +513,17 @@ class HomeAssistant: self.loop.call_soon_threadsafe(self.async_create_task, target) @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 @@ -531,7 +534,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 @@ -871,7 +874,7 @@ 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)) ) def as_dict(self) -> dict[str, Any]: @@ -1037,7 +1040,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 @@ -1111,7 +1117,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) @@ -1533,7 +1543,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, @@ -1558,16 +1568,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 @@ -1659,7 +1671,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 @@ -1938,7 +1950,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/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index b15642d46e1c..59e76a9c8aeb 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 86da242be80a..fc2950843156 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 """ diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 28ceb593845b..37480904f9e4 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 """ @@ -107,6 +107,7 @@ FLOWS = { "ecobee", "econet", "ecowitt", + "edl21", "efergy", "eight_sleep", "elgato", @@ -144,6 +145,7 @@ FLOWS = { "fritzbox", "fritzbox_callmonitor", "fronius", + "frontier_silicon", "fully_kiosk", "garages_amsterdam", "gdacs", @@ -280,6 +282,7 @@ FLOWS = { "netatmo", "netgear", "nexia", + "nextcloud", "nextdns", "nfandroidtv", "nibe_heatpump", @@ -293,6 +296,7 @@ FLOWS = { "nut", "nws", "nzbget", + "obihai", "octoprint", "omnilogic", "oncue", diff --git a/homeassistant/generated/countries.py b/homeassistant/generated/countries.py index 76482a524deb..452e65afb02b 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 546bc125a010..3cf2b9a1ab4b 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 8956085a5abf..333db76d4f36 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 """ @@ -387,8 +387,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 15f191c22d19..8b72a8499a75 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -926,12 +926,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", @@ -1272,7 +1266,7 @@ "edl21": { "name": "EDL21", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "efergy": { @@ -1473,6 +1467,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 +1817,7 @@ "frontier_silicon": { "name": "Frontier Silicon", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "fully_kiosk": { @@ -2300,6 +2299,12 @@ } } }, + "homeseer": { + "name": "HomeSeer", + "iot_standards": [ + "zwave" + ] + }, "homewizard": { "name": "HomeWizard Energy", "integration_type": "hub", @@ -3073,12 +3078,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", @@ -3617,7 +3616,7 @@ "nextcloud": { "name": "Nextcloud", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "nextdns": { @@ -3783,7 +3782,7 @@ "obihai": { "name": "Obihai", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "octoprint": { @@ -4310,7 +4309,7 @@ }, "qbittorrent": { "name": "qBittorrent", - "integration_type": "hub", + "integration_type": "service", "config_flow": false, "iot_class": "local_polling" }, @@ -4349,6 +4348,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", @@ -5988,6 +5992,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", @@ -6053,6 +6062,12 @@ } } }, + "voice_assistant": { + "name": "Voice Assistant", + "integration_type": "hub", + "config_flow": false, + "iot_class": "local_push" + }, "voicerss": { "name": "VoiceRSS", "integration_type": "hub", @@ -6469,7 +6484,6 @@ }, "helper": { "counter": { - "name": "Counter", "integration_type": "helper", "config_flow": false }, @@ -6488,7 +6502,6 @@ "config_flow": false }, "input_button": { - "name": "Input Button", "integration_type": "helper", "config_flow": false }, @@ -6552,6 +6565,7 @@ "alert", "aurora", "cert_expiry", + "counter", "cpuspeed", "demo", "derivative", @@ -6563,6 +6577,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 879d4a4cd41e..b4aebb0f1a4e 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 5d64546b91bb..69abf7c64fe5 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 ca6a22e85d66..3f26ec8fa78b 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", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 2d0dced89658..f58936caf8de 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 e00a0710c339..2f3dbaefb173 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 """ diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 3ab583096cdc..78a8051df1cc 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -143,7 +143,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, ) @@ -271,9 +271,9 @@ 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, diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 437cd4187194..9da6f84207a2 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -7,6 +7,7 @@ from collections.abc import Awaitable, Callable, Coroutine, Iterable from dataclasses import dataclass from itertools import groupby import logging +from operator import attrgetter from typing import Any, cast import voluptuous as vol @@ -410,9 +411,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( diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 7513e2b0087e..0029a9c906bf 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -7,15 +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, @@ -55,6 +53,7 @@ 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 @@ -77,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] @@ -152,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, @@ -160,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): @@ -928,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: @@ -991,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_validation.py b/homeassistant/helpers/config_validation.py index 42e1927e09b9..0f53c9108c89 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 @@ -597,7 +602,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() @@ -615,7 +624,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 diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 2fbdefd7ec04..dd536956a83a 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -38,7 +38,11 @@ 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}" + ) ) @property @@ -51,7 +55,10 @@ 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.""" @@ -87,11 +94,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 +103,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,6 +112,7 @@ 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() @callback @@ -121,10 +124,17 @@ 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()), - ) + 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 9ea44db16d54..b72a18786514 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -10,7 +10,7 @@ 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 @@ -907,6 +907,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 375c3b09c2ea..824b1de701a5 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -44,7 +44,7 @@ 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: """Listen for discovery events.""" @@ -103,7 +103,7 @@ 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: """Listen for platform discovery events.""" diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index f7e78e82fb4d..586824b4495f 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) diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index c7ad4fb1adf1..60aab156144f 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 c4dfd7e9c5b5..9d9e685d6a83 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -319,6 +319,15 @@ class Entity(ABC): """Return the name of the entity.""" if hasattr(self, "_attr_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.entity_translations: + name: str = self.platform.entity_translations[name_translation_key] + return name if hasattr(self, "entity_description"): return self.entity_description.name return None @@ -570,6 +579,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 +605,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 +618,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 +637,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 +654,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 +669,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 +686,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. @@ -702,7 +713,10 @@ 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() @@ -722,7 +736,9 @@ class Entity(ABC): try: task: asyncio.Future[None] if hasattr(self, "async_update"): - task = self.hass.async_create_task(self.async_update()) + task = self.hass.async_create_task( + self.async_update(), f"Entity async update {self.entity_id}" + ) elif hasattr(self, "update"): task = self.hass.async_add_executor_job(self.update) else: diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 874c37ffd9f6..0c43dddec604 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -131,7 +131,10 @@ class EntityComponent(Generic[_EntityT]): # 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 c002915a4dfe..0f93dca6939c 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,7 @@ class EntityPlatform: self.entity_namespace = entity_namespace self.config_entry: config_entries.ConfigEntry | None = None self.entities: dict[str, Entity] = {} + self.entity_translations: dict[str, Any] = {} self._tasks: list[asyncio.Task[None]] = [] # Stop tracking tasks after setup is completed self._setup_complete = False @@ -276,6 +278,15 @@ class EntityPlatform: hass = self.hass full_name = f"{self.domain}.{self.platform_name}" + try: + self.entity_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 +386,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 +401,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 +479,7 @@ class EntityPlatform: self.hass, self._update_entity_states, self.scan_interval, + name=f"EntityPlatform poll {self.domain}.{self.platform_name}", ) def _entity_id_already_exists(self, entity_id: str) -> tuple[bool, bool]: diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 4c192d916c19..9cb119b81b43 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 @@ -111,6 +112,29 @@ DISLAY_DICT_OPTIONAL = ( ) +class _EntityOptions(UserDict[str, MappingProxyType]): + """Container for entity options.""" + + def __init__(self, data: Mapping[str, Mapping] | None) -> None: + """Initialize.""" + super().__init__() + if data is None: + return + self.data = {key: MappingProxyType(val) for key, val in data.items()} + + def __setitem__(self, key: str, entry: Mapping) -> None: + """Add an item.""" + raise NotImplementedError + + def __delitem__(self, key: str) -> None: + """Remove an item.""" + raise NotImplementedError + + def as_dict(self) -> dict[str, dict]: + """Return dictionary version.""" + return {key: dict(val) for key, val in self.data.items()} + + @attr.s(slots=True, frozen=True) class RegistryEntry: """Entity Registry Entry.""" @@ -132,10 +156,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) @@ -930,7 +951,7 @@ class EntityRegistry: If the domain options are set to None, they will be removed. """ old = self.entities[entity_id] - new_options = { + new_options: dict[str, Mapping] = { key: value for key, value in old.options.items() if key != domain } if options is not None: @@ -1010,7 +1031,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, diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 7490206f0373..44a9cb087e35 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -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,7 +1284,11 @@ 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: @@ -1324,7 +1335,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 +1372,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 +1397,14 @@ def async_track_time_interval( hass: HomeAssistant, action: Callable[[datetime], Coroutine[Any, Any, None] | None], interval: timedelta, + *, + name: str | 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}") def next_interval() -> datetime: """Return the next interval.""" @@ -1400,7 +1421,12 @@ 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}" + else: + job_name = f"track time interval {interval}" + + interval_listener_job = HassJob(interval_listener, job_name) remove = async_track_point_in_utc_time(hass, interval_listener_job, next_interval()) def remove_listener() -> None: @@ -1479,7 +1505,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 +1521,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 @@ -1526,7 +1556,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) @@ -1630,12 +1660,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 e02759b09f8f..44ad81c73e90 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -11,6 +11,7 @@ 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 get_default_context, get_default_no_verify_context from .frame import warn_use @@ -64,15 +65,18 @@ def create_async_httpx_client( This method must be run in the event loop. """ + ssl_context = ( + get_default_context() if verify_ssl else get_default_no_verify_context() + ) 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 9255824cddfd..ef05dae518bc 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -8,8 +8,8 @@ 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__) @@ -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/restore_state.py b/homeassistant/helpers/restore_state.py index d7e30661b38b..d31c12d0fd52 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 9f76a639e0fc..5101e5c69a78 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -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 02fa9dc7806e..9ba4e7a9d88c 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 0ba5ee363e9a..e2f58e357ed5 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, } ) @@ -349,6 +426,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 +508,7 @@ class DeviceSelectorConfig(TypedDict, total=False): integration: str manufacturer: str model: str - entity: SingleEntitySelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] multiple: bool @@ -409,8 +518,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 +572,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 +586,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], + ), } ) @@ -784,8 +903,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 +951,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 9f6f65f1d2de..33c677454bc8 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: @@ -58,6 +61,112 @@ _LOGGER = logging.getLogger(__name__) SERVICE_DESCRIPTION_CACHE = "service_description_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): """Type for service call parameters.""" @@ -421,13 +530,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 ) diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 19e028af9004..bd9b01cd6a6c 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 25bef38ed0b7..cf944dfc4794 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 9aafe53925c9..2c2e5b2d95eb 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,7 @@ 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 voluptuous as vol from homeassistant.const import ( @@ -48,6 +50,9 @@ from homeassistant.const import ( ATTR_LONGITUDE, ATTR_PERSONS, ATTR_UNIT_OF_MEASUREMENT, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, + STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfLength, ) @@ -73,6 +78,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 @@ -85,6 +91,7 @@ _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 @@ -117,9 +124,77 @@ 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 + + +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 +349,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 +436,7 @@ class Template: "_limited", "_strict", "_hash_cache", + "_renders", ) def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: @@ -375,6 +453,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 +523,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 +600,8 @@ class Template: This method must be run in the event loop. """ + self._renders += 1 + if self.is_static: return False @@ -561,6 +644,7 @@ class Template: self, variables: TemplateVarsType = None, strict: bool = False, **kwargs: Any ) -> RenderInfo: """Render the template and collect an entity filter.""" + self._renders += 1 assert self.hass and _RENDER_INFO not in self.hass.data render_info = RenderInfo(self) @@ -610,6 +694,8 @@ class Template: This method must be run in the event loop. """ + self._renders += 1 + if self.is_static: return self.template @@ -673,7 +759,7 @@ class Template: def __repr__(self) -> str: """Representation of Template.""" - return 'Template("' + self.template + '")' + return f"Template" @cache @@ -961,21 +1047,33 @@ class TemplateStateFromEntityId(TemplateStateBase): return f"